fix(csi-ingest): real HE20 CSI no longer dropped or replaced with simulated data (#1009, #1004)

Two ingest bugs caused real ESP32-C6 HE20 CSI to be silently discarded or
never received — the "real data silently lost" failure class. Each fix is
pinned by a test that fails on the old code.

#1009 §1b — HE20 baseline recorder trimmed 256->242 bins by sequential index.
ESP-IDF v5.5.2 delivers all 256 FFT bins for an HE20 frame, but
CalibrationConfig::he20() carried num_active: 242, so the recorder (no HE20
tone map — extract_first_stream takes the first num_active columns
sequentially) kept bins 0..242 = the lower guard band + DC, NOT the 242 active
tones, silently corrupting the empty-room baseline. Now num_active: 256 records
every delivered bin, aligned 1:1 with the live deviation() path. The exact-242
tone map stays only in cir.rs (HE20_ACTIVE), where the Phi sensing matrix needs
it. HE20 synthetic/bench fixtures updated to feed 256-bin frames.

#1009 §1a/§1c — u8->u16 n_subcarriers truncation, regression-pinned.
The ADR-018 wire format carries n_subcarriers as u16 LE at bytes 6-7; a 256-bin
HE20 frame (byte6=0x00) read as one byte decodes to 0 subcarriers -> every
frame skipped. The CLI parser and the sensing-server parse_esp32_frame were
already corrected to u16 under #1005/ADR-110; added regression tests that fail
on the old single-byte read so the truncation cannot silently return.

#1004 — --source auto latched on simulate forever, never binding UDP :5005.
A one-shot boot probe resolved the source once; with no CSI flowing at boot
(the normal firmware/server startup race) it served simulated poses for the
whole process and ignored real CSI arriving seconds later (the prior #937 fix
hard-exited instead — equally wrong). New plan_source() state machine: in auto
mode ALWAYS bind the UDP receiver and serve simulated only until the first real
frame, then udp_receiver_task promotes source -> esp32 (mirroring the existing
esp32 -> esp32:offline reversion). simulated_data_task self-suspends once
promoted. Explicit --source simulated stays a hard, UDP-free offline override.

Validation: 3-crate tests 1118 passed / 0 failed; workspace 3166 passed /
0 failed; Python proof VERDICT: PASS (bit-exact, unaffected). cir.rs untouched.

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
ruv
2026-06-12 16:37:15 -04:00
parent 3d7530f08d
commit a27ee6f6cd
6 changed files with 381 additions and 57 deletions
+4
View File
@@ -42,6 +42,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Live trust path: sensing-server routes real frames through the governed `StreamingEngine` (parallel governed path with partial output gating).** Previously the live server ran only the *bare* `MultistaticFuser` (fused amplitudes, no trust control plane), while the privacy/provenance/witness engine (ADR-135..146) ran only on synthetic in-test frames — the gap called out in ADR-136 §8 and the beyond-SOTA system review. New `engine_bridge` module drives `StreamingEngine::process_cycle` from the server's live `NodeState` map (reusing the existing `NodeState → MultiBandCsiFrame` conversion), lazily wiring each node as a WorldGraph sensor and bounding belief growth via the retention cap; every *governed belief* carries evidence + model + calibration + privacy decision and a deterministic witness. **Honest scope:** the engine runs alongside (not instead of) the bare fusion path that feeds the live `SensingUpdate`. What its decision gates on the wire today: a cycle emitted at class `Restricted` (base mode or contradiction/mesh-risk demotion) suppresses the per-node raw amplitude vectors from the live publish — the same field mapping `wifi-densepose-bfld`'s privacy gate applies at `Restricted`; gating the remaining derived outputs (person count, classification, signal field) is tracked as a follow-up. Trust state is no longer write-only: the latest witness, effective privacy class, demotion flag, recalibration recommendation, and an engine-error counter are readable on `GET /api/v1/status`, and engine errors are counted + rate-limit logged instead of silently swallowed (`EngineBridge::observe_cycle`). Adds `wifi-densepose-engine/-worldgraph/-bfld/-geo` deps. Bridge tests cover witnessed belief with provenance, determinism, idempotent node registration, retention bound, privacy-mode propagation, trust-state recording, the error-counter path, and Restricted-class raw-output suppression.
### Fixed
- **Real HE20 CSI no longer silently dropped or replaced with simulated data (fixes #1009, #1004).** Two ingest bugs caused real ESP32-C6 HE20 frames to be discarded or never received — the exact "real data silently lost" failure class the project fights. Each fix is pinned by a test that fails on the old code.
- **#1009 §1b — HE20 baseline recorder trimmed 256 → 242 bins by sequential index (`wifi-densepose-signal/src/ruvsense/calibration.rs`).** ESP-IDF v5.5.2 delivers all 256 FFT bins for an HE20 frame; `CalibrationConfig::he20()` carried `num_active: 242`, so the recorder (which has no HE20 tone map — `extract_first_stream` takes the first `num_active` columns *sequentially*) kept bins 0..242 of the 256-bin grid. Those are the lower guard band + DC, **not** the 242 active tones, silently corrupting the empty-room baseline. Now `num_active: 256` records every delivered bin, staying aligned 1:1 with the live `deviation()` path. The exact-242 tone map deliberately stays only in `cir.rs` (`HE20_ACTIVE`), where the Φ sensing matrix genuinely needs it. Test `he20_records_all_256_bins_not_trimmed_to_242` asserts the finalized baseline covers all 256 bins (was 242). HE20 synthetic/bench fixtures updated to feed 256-bin frames (the real wire format).
- **#1009 §1a/§1c — already-fixed u8→u16 `n_subcarriers` truncation, now regression-pinned.** The ADR-018 wire format carries `n_subcarriers` as u16 LE at bytes 67. A 256-bin HE20 frame (byte6=0x00, byte7=0x01) read as a single byte decodes to **0 subcarriers** → every frame skipped (invisible until HE20: ESP32-S3's ≤192 bins fit in one byte). The CLI parser (`wifi-densepose-cli/calibrate.rs`) and the sensing-server template parser (`wifi-densepose-sensing-server` `parse_esp32_frame`) were already corrected to u16 under #1005/ADR-110; added regression tests (`parse_esp32_frame_he20_256_bins_not_truncated`, CLI `test_parse_csi_packet_he_su_256_bins`) that fail on the old single-byte read so the truncation cannot silently return.
- **#1004`--source auto` latched on `simulate` forever, never binding UDP :5005 (`wifi-densepose-sensing-server/src/main.rs`).** A one-shot boot probe resolved the source once; with no CSI flowing at boot (the normal firmware/server startup race) it served simulated poses for the whole process and ignored real CSI that arrived seconds later (the prior #937 fix hard-exited instead — equally wrong, the server could never pick up late-starting CSI). New `plan_source()` state machine: in `auto` mode **always bind the UDP receiver** and serve simulated data only until the first real frame, at which point `udp_receiver_task` promotes `source``esp32` (mirroring the existing `esp32 → esp32:offline` reversion in `effective_source()`); `simulated_data_task` self-suspends once promoted so it never clobbers live CSI. Explicit `--source simulated` stays a hard, UDP-free override for offline demos. 6 unit tests pin the resolution/promotion machine (`auto_with_no_boot_source_still_binds_udp_and_simulates`, etc.); the auto-binds-UDP assertion fails on the old behavior.
- **`wifi-densepose-mat` standalone `--no-default-features` build (101 errors → 0).** `pub mod api` was unconditional while its only dependency, serde, is optional behind the `api` feature — so any build without default features failed with unresolved serde imports (masked in `--workspace` runs by feature unification). The `api` module and its `create_router`/`AppState` re-export are now `#[cfg(feature = "api")]`-gated (with docsrs annotations). All feature combos compile: bare `--no-default-features`, `--no-default-features --features api`, and full default (177 tests pass).
- **WorldGraph no longer grows unboundedly under the live loop.** `StreamingEngine::process_cycle` appended one `SemanticState` belief per cycle with no eviction — ~1.7M nodes/day at 20 Hz (identified in `docs/research/ruview-beyond-sota/04-optimization-roadmap.md`). Added `WorldGraph::prune_semantic_states(max)` — deterministic eviction of the oldest beliefs by `(valid_from_unix_ms, id)`, structural nodes (rooms/zones/sensors/anchors/tracks/events) never eligible — and wired it into the engine after each belief append (`StreamingEngine::DEFAULT_SEMANTIC_RETENTION` = 7,200 ≈ 6 min at 20 Hz; tunable via `set_semantic_retention`). The WorldGraph holds *current* beliefs; durable history is the recorder's job, so no audit data is lost. 3 new tests (bounded growth end-to-end, oldest-only eviction, deterministic tie-break).
- **ESP32 edge heart rate no longer stuck at ~45 BPM / dropping wildly — #987.** The on-device HR estimator (`edge_processing.c`, `0xC5110002`) reported ~45 BPM regardless of true heart rate (Apple-Watch ground truth 87 BPM read as ~45) and swung frame-to-frame. Two root causes: (1) a hardcoded `sample_rate = 10.0f` that became wrong after #985's self-ping raised the CSI callback rate to a variable ~1319 Hz — BPM scales as `assumed/actual × true`, so 87 read ~45 and the reading swung as CSI yield fluctuated; (2) the zero-crossing estimator locked onto a breathing harmonic (a 0.25 Hz breathing fundamental puts its 3rd harmonic at ~0.74 Hz ≈ 44 BPM inside the HR band). Fix: measure the real sample rate from inter-frame timestamps (used for BPM conversion + biquad re-tuning on >15% drift); replace the HR zero-crossing with an autocorrelation estimator that rejects breathing harmonics (driven by a robust autocorr breathing period); median-13 smooth the output. Hardware A/B (fixed vs unmodified control board, both `edge_tier=2`): control pegged 4049 BPM; fixed reaches the true 8891 BPM (vs 87 GT) and holds a stable physiological value (spread 59→0 for a steady subject). Known limitation: heavy subject motion still degrades the estimate (motion gating is a follow-up).