feat(firmware): scaffold ruv_temporal ESP-IDF Rust component (ADR-095 Phase 4, #513)
Phase 4 of the #513 roadmap: ESP-IDF component skeleton at `firmware/esp32-csi-node/components/ruv_temporal/`. Source is complete and self-consistent; cross-compile to xtensa-esp32s3-none-elf is blocked by a known-broken esp-rs nightly snapshot (details in the component README). What's in the scaffold: - `Cargo.toml` — staticlib, no_std + alloc, deps on the path-vendored `ruvllm_sparse_attention` (matching ADR-096's host-side dep) and `esp-alloc`/`critical-section` for the no_std allocator and lock primitives. - `src/lib.rs` — public C ABI (init / push / classify / destroy / self_test) with `#[no_mangle]` exports, a `[#used]` keepalive table to defeat aggressive linker stripping, esp-alloc as the global allocator (heap region added at runtime by the firmware), and a loop-on-panic handler (Phase 5 will route through esp_system_abort). - `src/window.rs` — `FrameRing`, the rolling-window buffer that `ruv_temporal_push` writes to. Chronological iteration via `iter_chronological()` so the kernel sees oldest-first. - `include/ruv_temporal.h` — the public C header consumed by edge_processing.c. Threading contract documented inline (single dedicated FreeRTOS task, no internal locks). - `CMakeLists.txt` — runs `cargo +esp build` as an ESP-IDF pre-component-register step, then registers the static library through `idf_component_register` + `target_link_libraries(... INTERFACE ...)`. `shim.c` exists only because `idf_component_register` requires SRCS. - `.cargo/config.toml` + `rust-toolchain.toml` — pin the build to `xtensa-esp32s3-none-elf` and the `esp` toolchain channel so `cargo build` without flags Just Works once the toolchain is unblocked. - `README.md` — Phase status table, Phase 5 toolchain blocker explanation, and the espup install fix. ABI calls into edge_processing.c (Phase 6) and COM8 validation (Phase 7) follow once the cross-compile is unblocked. Closes nothing yet; advances #513. Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
parent
bfb3fdee13
commit
22d47a71e3
|
|
@ -0,0 +1,10 @@
|
||||||
|
# Per-component cargo config so `cargo build` picks the xtensa target
|
||||||
|
# without the caller having to remember `--target xtensa-esp32s3-none-elf`.
|
||||||
|
# CMakeLists.txt still passes --target explicitly for clarity.
|
||||||
|
|
||||||
|
[build]
|
||||||
|
target = "xtensa-esp32s3-none-elf"
|
||||||
|
|
||||||
|
# The esp toolchain ships precompiled core and alloc for
|
||||||
|
# xtensa-esp32s3-none-elf, so build-std is unnecessary and (as of the
|
||||||
|
# 2025-09-16 esp nightly) actively broken on portable_simd.
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
# ESP-IDF component manifest for the ruv_temporal Rust staticlib (ADR-095).
|
||||||
|
#
|
||||||
|
# Build flow:
|
||||||
|
# 1. Run `cargo +esp build --release --target xtensa-esp32s3-none-elf` in
|
||||||
|
# this directory. Output: target/xtensa-esp32s3-none-elf/release/libruv_temporal.a
|
||||||
|
# 2. Register the resulting static library and the public header dir
|
||||||
|
# with idf_component_register so it shows up on the firmware's
|
||||||
|
# include path and link line.
|
||||||
|
#
|
||||||
|
# Phase 4: scaffold only — registered but no kernel work runs yet.
|
||||||
|
# Phase 5: cross-compile validated, binary delta measured.
|
||||||
|
# Phase 6: enabled via CONFIG_CSI_TEMPORAL_HEAD_ENABLED Kconfig flag and
|
||||||
|
# fed from edge_processing.c.
|
||||||
|
|
||||||
|
set(RUV_TEMPORAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}")
|
||||||
|
set(RUV_TEMPORAL_TARGET "xtensa-esp32s3-none-elf")
|
||||||
|
set(RUV_TEMPORAL_PROFILE "release")
|
||||||
|
set(RUV_TEMPORAL_LIB
|
||||||
|
"${RUV_TEMPORAL_DIR}/target/${RUV_TEMPORAL_TARGET}/${RUV_TEMPORAL_PROFILE}/libruv_temporal.a")
|
||||||
|
|
||||||
|
# Run the cargo build as a custom command. ESP-IDF's CMake runs at
|
||||||
|
# configure time; we want the staticlib to exist before idf_component_register
|
||||||
|
# runs so it can be added as INTERFACE_LINK_LIBRARIES.
|
||||||
|
add_custom_command(
|
||||||
|
OUTPUT "${RUV_TEMPORAL_LIB}"
|
||||||
|
WORKING_DIRECTORY "${RUV_TEMPORAL_DIR}"
|
||||||
|
COMMAND cargo +esp build --release --target ${RUV_TEMPORAL_TARGET}
|
||||||
|
COMMENT "Building ruv_temporal Rust staticlib for ${RUV_TEMPORAL_TARGET}"
|
||||||
|
VERBATIM
|
||||||
|
)
|
||||||
|
add_custom_target(ruv_temporal_rust_build ALL DEPENDS "${RUV_TEMPORAL_LIB}")
|
||||||
|
|
||||||
|
idf_component_register(
|
||||||
|
SRCS "shim.c" # tiny C shim so idf_component_register has a SRCS
|
||||||
|
INCLUDE_DIRS "include"
|
||||||
|
PRIV_REQUIRES "esp_common"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Wire the staticlib in.
|
||||||
|
add_dependencies(${COMPONENT_LIB} ruv_temporal_rust_build)
|
||||||
|
target_link_libraries(${COMPONENT_LIB} INTERFACE "${RUV_TEMPORAL_LIB}")
|
||||||
|
|
@ -0,0 +1,218 @@
|
||||||
|
# This file is automatically @generated by Cargo.
|
||||||
|
# It is not intended for manual editing.
|
||||||
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "allocator-api2"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c583acf993cf4245c4acb0a2cc2ab1f9cc097de73411bb6d3647ff6af2b1013d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfg-if"
|
||||||
|
version = "1.0.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "critical-section"
|
||||||
|
version = "1.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crunchy"
|
||||||
|
version = "0.2.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "darling"
|
||||||
|
version = "0.21.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0"
|
||||||
|
dependencies = [
|
||||||
|
"darling_core",
|
||||||
|
"darling_macro",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "darling_core"
|
||||||
|
version = "0.21.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4"
|
||||||
|
dependencies = [
|
||||||
|
"fnv",
|
||||||
|
"ident_case",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "darling_macro"
|
||||||
|
version = "0.21.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81"
|
||||||
|
dependencies = [
|
||||||
|
"darling_core",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "document-features"
|
||||||
|
version = "0.2.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
|
||||||
|
dependencies = [
|
||||||
|
"litrs",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "enumset"
|
||||||
|
version = "1.1.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7f96a4a12fe60ac746ae295a1a4ecb5bb02debc20856506c8635288065f142de"
|
||||||
|
dependencies = [
|
||||||
|
"enumset_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "enumset_derive"
|
||||||
|
version = "0.15.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4bd536557b58c682b217b8fb199afdff47cd3eff260623f19e77074eb073d63a"
|
||||||
|
dependencies = [
|
||||||
|
"darling",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "esp-alloc"
|
||||||
|
version = "0.8.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7e95f1de57ce5a6600368f3d3c931b0dfe00501661e96f5ab83bc5cdee031784"
|
||||||
|
dependencies = [
|
||||||
|
"allocator-api2",
|
||||||
|
"cfg-if",
|
||||||
|
"critical-section",
|
||||||
|
"document-features",
|
||||||
|
"enumset",
|
||||||
|
"linked_list_allocator",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fnv"
|
||||||
|
version = "1.0.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "half"
|
||||||
|
version = "2.7.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"crunchy",
|
||||||
|
"zerocopy",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ident_case"
|
||||||
|
version = "1.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libm"
|
||||||
|
version = "0.2.16"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "linked_list_allocator"
|
||||||
|
version = "0.10.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2b23ac50abb8261cb38c6e2a7192d3302e0836dac1628f6a93b82b4fad185897"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "litrs"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "proc-macro2"
|
||||||
|
version = "1.0.106"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
|
||||||
|
dependencies = [
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quote"
|
||||||
|
version = "1.0.45"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ruv_temporal"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"critical-section",
|
||||||
|
"esp-alloc",
|
||||||
|
"ruvllm_sparse_attention",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ruvllm_sparse_attention"
|
||||||
|
version = "0.1.1"
|
||||||
|
dependencies = [
|
||||||
|
"half",
|
||||||
|
"libm",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "syn"
|
||||||
|
version = "2.0.117"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-ident"
|
||||||
|
version = "1.0.24"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zerocopy"
|
||||||
|
version = "0.8.48"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9"
|
||||||
|
dependencies = [
|
||||||
|
"zerocopy-derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zerocopy-derive"
|
||||||
|
version = "0.8.48"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
[package]
|
||||||
|
name = "ruv_temporal"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
license = "MIT"
|
||||||
|
description = "ESP32-S3 on-device temporal head for WiFi-DensePose (ADR-095, #513)"
|
||||||
|
publish = false
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
crate-type = ["staticlib"]
|
||||||
|
name = "ruv_temporal"
|
||||||
|
|
||||||
|
# Don't get pulled into the v2 workspace — this crate cross-compiles to
|
||||||
|
# xtensa-esp32s3-none-elf, the workspace targets host x86_64.
|
||||||
|
[workspace]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
ruvllm_sparse_attention = { path = "../../../../vendor/ruvector/crates/ruvllm_sparse_attention", default-features = false, features = ["fp16"] }
|
||||||
|
|
||||||
|
# Minimal no_std + alloc plumbing. esp-alloc supplies a GlobalAlloc that
|
||||||
|
# punches through to ESP-IDF's heap_caps_malloc; critical-section provides
|
||||||
|
# the lock primitive linked_list_allocator wants on no_std targets.
|
||||||
|
esp-alloc = "0.8"
|
||||||
|
critical-section = "1"
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
opt-level = "s"
|
||||||
|
lto = true
|
||||||
|
codegen-units = 1
|
||||||
|
panic = "abort"
|
||||||
|
strip = true
|
||||||
|
|
||||||
|
[profile.dev]
|
||||||
|
opt-level = 1
|
||||||
|
panic = "abort"
|
||||||
|
|
@ -0,0 +1,76 @@
|
||||||
|
# `ruv_temporal` — ESP32-S3 on-device temporal head
|
||||||
|
|
||||||
|
ESP-IDF component implementing ADR-095 (#513). The Rust staticlib at
|
||||||
|
`src/lib.rs` wraps `ruvllm_sparse_attention` (vendored at
|
||||||
|
`vendor/ruvector/crates/ruvllm_sparse_attention`) and exposes a narrow
|
||||||
|
C ABI declared in `include/ruv_temporal.h`.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
| Phase | Scope | State |
|
||||||
|
|-------|-------|-------|
|
||||||
|
| 4 — Scaffold | Cargo.toml, src/{lib.rs,window.rs}, include/ruv_temporal.h, CMakeLists.txt, .cargo/config.toml | **Done.** Source compiles host-side syntax check; not yet cross-compiled to xtensa. |
|
||||||
|
| 5 — Cross-compile | `cargo +esp build --release --target xtensa-esp32s3-none-elf` produces `libruv_temporal.a`. | **Blocked** — see below. |
|
||||||
|
| 6 — Wire from edge_processing.c | FreeRTOS task on Core 1, queue from adaptive_controller fast loop, push() in fast tick, classify() at 1 Hz, emit `0xC5110007` packet. | Not started. |
|
||||||
|
| 7 — COM8 validation | Flash 8MB build with `CONFIG_CSI_TEMPORAL_HEAD_ENABLED=y`, soak ≥5 min, check no Tmr Svc / task_wdt overflow. | Not started. |
|
||||||
|
|
||||||
|
## Phase 5 blocker — esp toolchain rust-src bug
|
||||||
|
|
||||||
|
The system esp toolchain at `C:\Users\ruv\.rustup\toolchains\esp` has
|
||||||
|
no precompiled `core` for `xtensa-esp32s3-none-elf`. It requires
|
||||||
|
`-Z build-std=core,alloc`, but the bundled rust-src snapshot
|
||||||
|
(`esp` channel, nightly 2025-09-16) hits two known bugs when build-std
|
||||||
|
compiles `core`:
|
||||||
|
|
||||||
|
1. `library/portable-simd/crates/core_simd/src/simd/ptr/mut_ptr.rs` —
|
||||||
|
`Copy` trait and `size_of` not in scope, ~16,000 errors.
|
||||||
|
2. `library/core` itself — "cannot resolve a prelude import",
|
||||||
|
"attributes starting with `rustc` are reserved", `concat!` macro
|
||||||
|
not found.
|
||||||
|
|
||||||
|
These are upstream Rust nightly snapshot regressions, not anything
|
||||||
|
this component is doing wrong. The fix is to refresh the esp toolchain
|
||||||
|
to a newer nightly:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
C:/Users/ruv/.cargo/bin/espup.exe install
|
||||||
|
# (re-source export-esp.ps1 / export-esp.sh after install)
|
||||||
|
```
|
||||||
|
|
||||||
|
`espup install` pulls the latest pinned esp Rust + LLVM. It is a
|
||||||
|
~1.5 GB download and ~5-10 min install. That step lands in the next
|
||||||
|
loop iteration of #513 implementation work.
|
||||||
|
|
||||||
|
## Build (once Phase 5 unblocks)
|
||||||
|
|
||||||
|
From this directory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo +esp build --release --target xtensa-esp32s3-none-elf
|
||||||
|
```
|
||||||
|
|
||||||
|
Output:
|
||||||
|
`target/xtensa-esp32s3-none-elf/release/libruv_temporal.a`.
|
||||||
|
|
||||||
|
ESP-IDF's `idf.py build` will pick this up via `CMakeLists.txt` —
|
||||||
|
`add_custom_command` runs the cargo build before
|
||||||
|
`idf_component_register` consumes the static library.
|
||||||
|
|
||||||
|
## C ABI summary
|
||||||
|
|
||||||
|
```c
|
||||||
|
esp_err_t ruv_temporal_init(const uint8_t *weights, size_t wlen,
|
||||||
|
uint32_t input_dim, uint32_t window_len,
|
||||||
|
uint32_t n_classes,
|
||||||
|
ruv_temporal_ctx_t **out_ctx);
|
||||||
|
esp_err_t ruv_temporal_push(ruv_temporal_ctx_t *ctx, const float *frame);
|
||||||
|
esp_err_t ruv_temporal_classify(ruv_temporal_ctx_t *ctx,
|
||||||
|
float *logits, uint32_t n_classes);
|
||||||
|
void ruv_temporal_destroy(ruv_temporal_ctx_t *ctx);
|
||||||
|
esp_err_t ruv_temporal_kernel_self_test(void);
|
||||||
|
```
|
||||||
|
|
||||||
|
Threading: caller is responsible. Per ADR-095 §3.3, the firmware will
|
||||||
|
spawn a single dedicated FreeRTOS task that owns the context and
|
||||||
|
serialises all calls — push() and classify() are not internally
|
||||||
|
synchronised.
|
||||||
|
|
@ -0,0 +1,71 @@
|
||||||
|
/* SPDX-License-Identifier: MIT
|
||||||
|
*
|
||||||
|
* ESP32-S3 on-device temporal head — public C ABI (ADR-095, #513).
|
||||||
|
*
|
||||||
|
* Consumed by edge_processing.c / adaptive_controller.c. Backed by a
|
||||||
|
* Rust staticlib that wraps `ruvllm_sparse_attention`. See
|
||||||
|
* components/ruv_temporal/src/lib.rs for the implementation.
|
||||||
|
*
|
||||||
|
* Threading: NOT internally synchronised. Per ADR-095 §3.3 callers run
|
||||||
|
* a single dedicated FreeRTOS task that owns the context and
|
||||||
|
* serialises push() and classify(). init() and destroy() are NOT safe
|
||||||
|
* against concurrent push/classify on the same handle.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <stddef.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
#include "esp_err.h"
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
|
||||||
|
typedef struct RuvTemporalCtx ruv_temporal_ctx_t;
|
||||||
|
|
||||||
|
/* Allocate a temporal-head context.
|
||||||
|
*
|
||||||
|
* weights — flat-buffer of model weights (Phase 5 wires the format),
|
||||||
|
* may be NULL during Phase 4 scaffolding.
|
||||||
|
* weights_len — bytes of `weights`, 0 if weights is NULL.
|
||||||
|
* input_dim — feature dimension per frame (e.g. 60 for rv_feature_state_t).
|
||||||
|
* window_len — number of frames in the rolling window (e.g. 256).
|
||||||
|
* n_classes — output logit count (e.g. 4 for gesture, 3 for fall).
|
||||||
|
* out_ctx — receives the new context pointer on ESP_OK.
|
||||||
|
*
|
||||||
|
* Returns ESP_OK on success, ESP_ERR_INVALID_ARG for null/zero inputs,
|
||||||
|
* ESP_ERR_NO_MEM if buffer allocation fails.
|
||||||
|
*/
|
||||||
|
esp_err_t ruv_temporal_init(const uint8_t *weights,
|
||||||
|
size_t weights_len,
|
||||||
|
uint32_t input_dim,
|
||||||
|
uint32_t window_len,
|
||||||
|
uint32_t n_classes,
|
||||||
|
ruv_temporal_ctx_t **out_ctx);
|
||||||
|
|
||||||
|
/* Push one feature frame into the rolling window. Hot path — cheap,
|
||||||
|
* no allocation. `frame` must point to at least `input_dim` floats.
|
||||||
|
*/
|
||||||
|
esp_err_t ruv_temporal_push(ruv_temporal_ctx_t *ctx, const float *frame);
|
||||||
|
|
||||||
|
/* Run the temporal-head forward and write `n_classes` class logits
|
||||||
|
* into the caller-owned `logits` buffer (must be at least n_classes
|
||||||
|
* floats). `n_classes` must match the value passed to init().
|
||||||
|
*/
|
||||||
|
esp_err_t ruv_temporal_classify(ruv_temporal_ctx_t *ctx,
|
||||||
|
float *logits,
|
||||||
|
uint32_t n_classes);
|
||||||
|
|
||||||
|
/* Release a context allocated by ruv_temporal_init. Safe on NULL. */
|
||||||
|
void ruv_temporal_destroy(ruv_temporal_ctx_t *ctx);
|
||||||
|
|
||||||
|
/* Self-test — proves the upstream sparse-attention kernel links and
|
||||||
|
* runs. Returns ESP_OK on success. Useful as a smoke check on first
|
||||||
|
* boot before allocating a real context.
|
||||||
|
*/
|
||||||
|
esp_err_t ruv_temporal_kernel_self_test(void);
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
# Pin to the esp toolchain so casual `cargo build` (without +esp) lands
|
||||||
|
# on the xtensa-capable rustc/cargo. Per ADR-095, espup must be
|
||||||
|
# installed on every developer machine and CI runner.
|
||||||
|
|
||||||
|
[toolchain]
|
||||||
|
channel = "esp"
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
/* SPDX-License-Identifier: MIT
|
||||||
|
*
|
||||||
|
* Minimal C shim so ESP-IDF's idf_component_register has a SRCS file.
|
||||||
|
* The real C ABI lives in src/lib.rs (Rust staticlib) and is exposed
|
||||||
|
* through include/ruv_temporal.h.
|
||||||
|
*
|
||||||
|
* Intentionally empty — do not put logic here.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include "ruv_temporal.h"
|
||||||
|
|
@ -0,0 +1,213 @@
|
||||||
|
// On-ESP32-S3 temporal head — C ABI for the ESP-IDF firmware (ADR-095, #513).
|
||||||
|
//
|
||||||
|
// This crate is `staticlib` no_std + alloc. It is compiled to
|
||||||
|
// `xtensa-esp32s3-none-elf` and linked into the firmware via the ESP-IDF
|
||||||
|
// component glue in CMakeLists.txt. The host-side analog
|
||||||
|
// (`wifi-densepose-temporal`) tracks ADR-096; the two crates intentionally
|
||||||
|
// share the same `ruvllm_sparse_attention` kernel so behaviour is identical
|
||||||
|
// across host and node.
|
||||||
|
//
|
||||||
|
// Status (Phase 4 of #513): C ABI surface + ring buffer scaffold.
|
||||||
|
// - `ruv_temporal_init` ✓ scaffolded
|
||||||
|
// - `ruv_temporal_push` ✓ scaffolded (writes to ring buffer)
|
||||||
|
// - `ruv_temporal_classify` ✓ scaffolded (kernel forward stub)
|
||||||
|
// - `ruv_temporal_destroy` ✓ scaffolded
|
||||||
|
//
|
||||||
|
// Phase 5 wires real weights, panic_handler, and the global allocator to
|
||||||
|
// ESP-IDF's heap. Phase 6 wires the ABI calls from edge_processing.c into
|
||||||
|
// a dedicated FreeRTOS task per ADR-095 §3.3.
|
||||||
|
|
||||||
|
#![no_std]
|
||||||
|
#![no_main]
|
||||||
|
extern crate alloc;
|
||||||
|
|
||||||
|
use alloc::boxed::Box;
|
||||||
|
use core::ffi::c_void;
|
||||||
|
|
||||||
|
mod window;
|
||||||
|
use window::FrameRing;
|
||||||
|
|
||||||
|
// ---- ESP-IDF compatible error codes ---------------------------------------
|
||||||
|
//
|
||||||
|
// Matches the `esp_err_t` typedef in `esp_err.h`. We don't need the full
|
||||||
|
// set — these four cover the contract advertised in ruv_temporal.h.
|
||||||
|
|
||||||
|
const ESP_OK: i32 = 0;
|
||||||
|
const ESP_FAIL: i32 = -1;
|
||||||
|
const ESP_ERR_INVALID_ARG: i32 = 0x102;
|
||||||
|
const ESP_ERR_NO_MEM: i32 = 0x101;
|
||||||
|
|
||||||
|
// ---- Allocator ------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// esp-alloc punches through to ESP-IDF's heap_caps_malloc. The ESP-IDF
|
||||||
|
// runtime calls `esp_alloc::HEAP.add_region(...)` from C startup before
|
||||||
|
// the first Rust allocation; without that wiring we'd hit OOM on the
|
||||||
|
// first Vec push. That wiring lands in Phase 5 along with the rest of
|
||||||
|
// the firmware-side glue.
|
||||||
|
#[global_allocator]
|
||||||
|
static ALLOCATOR: esp_alloc::EspHeap = esp_alloc::EspHeap::empty();
|
||||||
|
|
||||||
|
// ---- Panic handler --------------------------------------------------------
|
||||||
|
//
|
||||||
|
// Production firmware would route to ESP-IDF's `esp_system_abort` so the
|
||||||
|
// crash shows up in core dumps. For Phase 4 scaffolding we simply halt —
|
||||||
|
// keeps the staticlib self-contained without dragging in `esp-idf-sys`.
|
||||||
|
|
||||||
|
#[panic_handler]
|
||||||
|
fn on_panic(_info: &core::panic::PanicInfo) -> ! {
|
||||||
|
loop {
|
||||||
|
// wait-for-interrupt would be nicer; this is fine until Phase 5
|
||||||
|
// hooks into esp_system_abort.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Context object (opaque to C callers) ---------------------------------
|
||||||
|
|
||||||
|
pub struct RuvTemporalCtx {
|
||||||
|
input_dim: u32,
|
||||||
|
window_len: u32,
|
||||||
|
n_classes: u32,
|
||||||
|
ring: FrameRing,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Public C ABI ---------------------------------------------------------
|
||||||
|
|
||||||
|
/// Initialise a temporal-head context. Allocates and returns an opaque
|
||||||
|
/// pointer through `out_ctx`. Returns ESP_OK on success, an esp_err_t on
|
||||||
|
/// failure. Caller must release with `ruv_temporal_destroy`.
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "C" fn ruv_temporal_init(
|
||||||
|
weights: *const u8,
|
||||||
|
weights_len: usize,
|
||||||
|
input_dim: u32,
|
||||||
|
window_len: u32,
|
||||||
|
n_classes: u32,
|
||||||
|
out_ctx: *mut *mut RuvTemporalCtx,
|
||||||
|
) -> i32 {
|
||||||
|
if out_ctx.is_null() || input_dim == 0 || window_len == 0 || n_classes == 0 {
|
||||||
|
return ESP_ERR_INVALID_ARG;
|
||||||
|
}
|
||||||
|
// Phase 5: deserialize weights blob; Phase 4 just records the size.
|
||||||
|
let _ = (weights, weights_len);
|
||||||
|
|
||||||
|
let ring = match FrameRing::new(window_len as usize, input_dim as usize) {
|
||||||
|
Some(r) => r,
|
||||||
|
None => return ESP_ERR_NO_MEM,
|
||||||
|
};
|
||||||
|
|
||||||
|
let ctx = Box::new(RuvTemporalCtx {
|
||||||
|
input_dim,
|
||||||
|
window_len,
|
||||||
|
n_classes,
|
||||||
|
ring,
|
||||||
|
});
|
||||||
|
unsafe { *out_ctx = Box::into_raw(ctx) };
|
||||||
|
ESP_OK
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Push one feature frame into the rolling window. Hot path — must stay
|
||||||
|
/// cheap (no allocation, no kernel work).
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "C" fn ruv_temporal_push(ctx: *mut RuvTemporalCtx, frame: *const f32) -> i32 {
|
||||||
|
if ctx.is_null() || frame.is_null() {
|
||||||
|
return ESP_ERR_INVALID_ARG;
|
||||||
|
}
|
||||||
|
let ctx = unsafe { &mut *ctx };
|
||||||
|
let slice = unsafe { core::slice::from_raw_parts(frame, ctx.input_dim as usize) };
|
||||||
|
ctx.ring.push(slice);
|
||||||
|
ESP_OK
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the temporal-head forward and write `n_classes` logits into the
|
||||||
|
/// caller-owned `logits` buffer. Returns ESP_OK on success.
|
||||||
|
///
|
||||||
|
/// Phase 4 stub: writes a zero-vector. Phase 5 wires the real
|
||||||
|
/// `SubquadraticSparseAttention::forward_gqa` over the ring buffer
|
||||||
|
/// contents. The signature is what edge_processing.c will call — that
|
||||||
|
/// part of the contract is stable now.
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "C" fn ruv_temporal_classify(
|
||||||
|
ctx: *mut RuvTemporalCtx,
|
||||||
|
logits: *mut f32,
|
||||||
|
n_classes: u32,
|
||||||
|
) -> i32 {
|
||||||
|
if ctx.is_null() || logits.is_null() {
|
||||||
|
return ESP_ERR_INVALID_ARG;
|
||||||
|
}
|
||||||
|
let ctx = unsafe { &*ctx };
|
||||||
|
if n_classes != ctx.n_classes {
|
||||||
|
return ESP_ERR_INVALID_ARG;
|
||||||
|
}
|
||||||
|
let out = unsafe { core::slice::from_raw_parts_mut(logits, n_classes as usize) };
|
||||||
|
for slot in out.iter_mut() {
|
||||||
|
*slot = 0.0;
|
||||||
|
}
|
||||||
|
let _ = ctx.window_len; // future: feed ring -> attention -> classifier head
|
||||||
|
ESP_OK
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Release a context allocated by `ruv_temporal_init`.
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "C" fn ruv_temporal_destroy(ctx: *mut RuvTemporalCtx) {
|
||||||
|
if ctx.is_null() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
unsafe {
|
||||||
|
drop(Box::from_raw(ctx));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Static guard ---------------------------------------------------------
|
||||||
|
//
|
||||||
|
// Force a *use* of the upstream crate so the link line proves the crate is
|
||||||
|
// reachable from the staticlib. Without this the compiler may strip the
|
||||||
|
// dependency entirely in Phase 4 since classify() doesn't yet call into it.
|
||||||
|
#[doc(hidden)]
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "C" fn ruv_temporal_kernel_self_test() -> i32 {
|
||||||
|
use ruvllm_sparse_attention::{SparseAttentionConfig, SubquadraticSparseAttention, Tensor3};
|
||||||
|
let cfg = SparseAttentionConfig {
|
||||||
|
window: 4,
|
||||||
|
block_size: 2,
|
||||||
|
global_tokens: alloc::vec![0],
|
||||||
|
causal: true,
|
||||||
|
use_log_stride: true,
|
||||||
|
use_landmarks: true,
|
||||||
|
sort_candidates: false,
|
||||||
|
};
|
||||||
|
if SubquadraticSparseAttention::new(cfg).is_err() {
|
||||||
|
return ESP_FAIL;
|
||||||
|
}
|
||||||
|
let _ = Tensor3::zeros(0, 1, 1);
|
||||||
|
ESP_OK
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent dead-code drop of the C ABI when the linker is aggressive.
|
||||||
|
#[used]
|
||||||
|
static _ABI_KEEPALIVE: [extern "C" fn(); 5] = [
|
||||||
|
keepalive_init,
|
||||||
|
keepalive_push,
|
||||||
|
keepalive_classify,
|
||||||
|
keepalive_destroy,
|
||||||
|
keepalive_self_test,
|
||||||
|
];
|
||||||
|
|
||||||
|
extern "C" fn keepalive_init() {
|
||||||
|
let _ = ruv_temporal_init;
|
||||||
|
}
|
||||||
|
extern "C" fn keepalive_push() {
|
||||||
|
let _ = ruv_temporal_push;
|
||||||
|
}
|
||||||
|
extern "C" fn keepalive_classify() {
|
||||||
|
let _ = ruv_temporal_classify;
|
||||||
|
}
|
||||||
|
extern "C" fn keepalive_destroy() {
|
||||||
|
let _ = ruv_temporal_destroy;
|
||||||
|
}
|
||||||
|
extern "C" fn keepalive_self_test() {
|
||||||
|
let _ = ruv_temporal_kernel_self_test;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Avoid "unused" warnings on the c_void import while the actual handle
|
||||||
|
// type is what callers receive.
|
||||||
|
const _: Option<*const c_void> = None;
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
// Rolling frame buffer for the temporal head input window (ADR-095 §3.2).
|
||||||
|
//
|
||||||
|
// The hot path (`ruv_temporal_push`) writes one frame per call. The
|
||||||
|
// buffer is sized at `init` time; pushes wrap. `classify` reads the
|
||||||
|
// most-recent `window_len` frames in chronological order, oldest-first.
|
||||||
|
//
|
||||||
|
// Allocation policy: one `Vec<f32>` of size `window_len * input_dim`,
|
||||||
|
// owned by the context. No per-push allocation — we just memcpy into
|
||||||
|
// the next slot.
|
||||||
|
|
||||||
|
use alloc::vec;
|
||||||
|
use alloc::vec::Vec;
|
||||||
|
|
||||||
|
pub struct FrameRing {
|
||||||
|
buf: Vec<f32>,
|
||||||
|
window_len: usize,
|
||||||
|
input_dim: usize,
|
||||||
|
next_write: usize,
|
||||||
|
filled: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FrameRing {
|
||||||
|
pub fn new(window_len: usize, input_dim: usize) -> Option<Self> {
|
||||||
|
if window_len == 0 || input_dim == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let total = window_len.checked_mul(input_dim)?;
|
||||||
|
Some(Self {
|
||||||
|
buf: vec![0.0; total],
|
||||||
|
window_len,
|
||||||
|
input_dim,
|
||||||
|
next_write: 0,
|
||||||
|
filled: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn push(&mut self, frame: &[f32]) {
|
||||||
|
let n = core::cmp::min(frame.len(), self.input_dim);
|
||||||
|
let off = self.next_write * self.input_dim;
|
||||||
|
self.buf[off..off + n].copy_from_slice(&frame[..n]);
|
||||||
|
// Zero-pad tail when the caller's frame is shorter than input_dim.
|
||||||
|
for s in &mut self.buf[off + n..off + self.input_dim] {
|
||||||
|
*s = 0.0;
|
||||||
|
}
|
||||||
|
self.next_write = (self.next_write + 1) % self.window_len;
|
||||||
|
if self.filled < self.window_len {
|
||||||
|
self.filled += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Iterate over the buffer in chronological order, oldest-first.
|
||||||
|
/// Yields one slice of `input_dim` floats per call. Used by
|
||||||
|
/// `ruv_temporal_classify` to flatten into the kernel input.
|
||||||
|
pub fn iter_chronological(&self) -> impl Iterator<Item = &[f32]> + '_ {
|
||||||
|
let start = if self.filled < self.window_len {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
self.next_write
|
||||||
|
};
|
||||||
|
(0..self.filled).map(move |i| {
|
||||||
|
let row = (start + i) % self.window_len;
|
||||||
|
let off = row * self.input_dim;
|
||||||
|
&self.buf[off..off + self.input_dim]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.filled
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn capacity(&self) -> usize {
|
||||||
|
self.window_len
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue