From 22d47a71e3c303e6951053b7c2da72e4a90658fc Mon Sep 17 00:00:00 2001 From: ruv Date: Fri, 8 May 2026 09:44:01 -0400 Subject: [PATCH] feat(firmware): scaffold ruv_temporal ESP-IDF Rust component (ADR-095 Phase 4, #513) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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_temporal/.cargo/config.toml | 10 + .../components/ruv_temporal/CMakeLists.txt | 41 ++++ .../components/ruv_temporal/Cargo.lock | 218 ++++++++++++++++++ .../components/ruv_temporal/Cargo.toml | 35 +++ .../components/ruv_temporal/README.md | 76 ++++++ .../ruv_temporal/include/ruv_temporal.h | 71 ++++++ .../ruv_temporal/rust-toolchain.toml | 6 + .../components/ruv_temporal/shim.c | 10 + .../components/ruv_temporal/src/lib.rs | 213 +++++++++++++++++ .../components/ruv_temporal/src/window.rs | 74 ++++++ 10 files changed, 754 insertions(+) create mode 100644 firmware/esp32-csi-node/components/ruv_temporal/.cargo/config.toml create mode 100644 firmware/esp32-csi-node/components/ruv_temporal/CMakeLists.txt create mode 100644 firmware/esp32-csi-node/components/ruv_temporal/Cargo.lock create mode 100644 firmware/esp32-csi-node/components/ruv_temporal/Cargo.toml create mode 100644 firmware/esp32-csi-node/components/ruv_temporal/README.md create mode 100644 firmware/esp32-csi-node/components/ruv_temporal/include/ruv_temporal.h create mode 100644 firmware/esp32-csi-node/components/ruv_temporal/rust-toolchain.toml create mode 100644 firmware/esp32-csi-node/components/ruv_temporal/shim.c create mode 100644 firmware/esp32-csi-node/components/ruv_temporal/src/lib.rs create mode 100644 firmware/esp32-csi-node/components/ruv_temporal/src/window.rs diff --git a/firmware/esp32-csi-node/components/ruv_temporal/.cargo/config.toml b/firmware/esp32-csi-node/components/ruv_temporal/.cargo/config.toml new file mode 100644 index 00000000..54405a49 --- /dev/null +++ b/firmware/esp32-csi-node/components/ruv_temporal/.cargo/config.toml @@ -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. diff --git a/firmware/esp32-csi-node/components/ruv_temporal/CMakeLists.txt b/firmware/esp32-csi-node/components/ruv_temporal/CMakeLists.txt new file mode 100644 index 00000000..8a5edcb6 --- /dev/null +++ b/firmware/esp32-csi-node/components/ruv_temporal/CMakeLists.txt @@ -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}") diff --git a/firmware/esp32-csi-node/components/ruv_temporal/Cargo.lock b/firmware/esp32-csi-node/components/ruv_temporal/Cargo.lock new file mode 100644 index 00000000..f40a33c0 --- /dev/null +++ b/firmware/esp32-csi-node/components/ruv_temporal/Cargo.lock @@ -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", +] diff --git a/firmware/esp32-csi-node/components/ruv_temporal/Cargo.toml b/firmware/esp32-csi-node/components/ruv_temporal/Cargo.toml new file mode 100644 index 00000000..b8fff9af --- /dev/null +++ b/firmware/esp32-csi-node/components/ruv_temporal/Cargo.toml @@ -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" diff --git a/firmware/esp32-csi-node/components/ruv_temporal/README.md b/firmware/esp32-csi-node/components/ruv_temporal/README.md new file mode 100644 index 00000000..0488757c --- /dev/null +++ b/firmware/esp32-csi-node/components/ruv_temporal/README.md @@ -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. diff --git a/firmware/esp32-csi-node/components/ruv_temporal/include/ruv_temporal.h b/firmware/esp32-csi-node/components/ruv_temporal/include/ruv_temporal.h new file mode 100644 index 00000000..7a33e2af --- /dev/null +++ b/firmware/esp32-csi-node/components/ruv_temporal/include/ruv_temporal.h @@ -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 +#include +#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 diff --git a/firmware/esp32-csi-node/components/ruv_temporal/rust-toolchain.toml b/firmware/esp32-csi-node/components/ruv_temporal/rust-toolchain.toml new file mode 100644 index 00000000..407dc656 --- /dev/null +++ b/firmware/esp32-csi-node/components/ruv_temporal/rust-toolchain.toml @@ -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" diff --git a/firmware/esp32-csi-node/components/ruv_temporal/shim.c b/firmware/esp32-csi-node/components/ruv_temporal/shim.c new file mode 100644 index 00000000..66ebef08 --- /dev/null +++ b/firmware/esp32-csi-node/components/ruv_temporal/shim.c @@ -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" diff --git a/firmware/esp32-csi-node/components/ruv_temporal/src/lib.rs b/firmware/esp32-csi-node/components/ruv_temporal/src/lib.rs new file mode 100644 index 00000000..24fd6a07 --- /dev/null +++ b/firmware/esp32-csi-node/components/ruv_temporal/src/lib.rs @@ -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; diff --git a/firmware/esp32-csi-node/components/ruv_temporal/src/window.rs b/firmware/esp32-csi-node/components/ruv_temporal/src/window.rs new file mode 100644 index 00000000..322ba39a --- /dev/null +++ b/firmware/esp32-csi-node/components/ruv_temporal/src/window.rs @@ -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` 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, + window_len: usize, + input_dim: usize, + next_write: usize, + filled: usize, +} + +impl FrameRing { + pub fn new(window_len: usize, input_dim: usize) -> Option { + 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 + '_ { + 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 + } +}