mirror of
https://github.com/ruvnet/RuView
synced 2026-06-12 10:43:19 +00:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 29de574e63 | |||
| d0e27e652e | |||
| 2a307138f2 | |||
| 992c2b25cb | |||
| 5789351b78 | |||
| b6420ac9ba | |||
| c353255672 | |||
| 872d7593bb | |||
| 2c136aca74 | |||
| 69e61e3437 |
@@ -7,13 +7,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
- **Mesh partition risk now demotes the privacy class and is witnessed (ADR-032).** The dynamic min-cut guard's `at_risk` signal was advisory-only (it fed the recalibration advisor). It now also contributes to the ADR-141 privacy demotion alongside fusion- and array-level contradictions: a mesh close to partitioning makes the fused belief less trustworthy, so the cycle emits at a more restricted class (monotonic — information only removed). Because `effective_class` feeds the BLAKE3 witness, a fragmenting array now shifts the witness — partition risk is auditable, not just logged. The mesh computation moved ahead of the demotion step in `process_cycle`; new `mesh_guard_mut()` exposes risk-threshold tuning. Test proves a forced-risk 3-node cycle demotes PrivateHome Anonymous→Restricted and shifts the witness vs a clean *same-topology* baseline (the only delta between the two cycles is the forced risk).
|
||||
|
||||
### Added
|
||||
- **Dynamic min-cut mesh partition guard in the streaming engine (`mesh_guard`).** Maintains a `ruvector-mincut` exact min-cut over the live mesh coupling graph (nodes = sensing nodes, coupling = product of fusion attention weights), surfacing per cycle: the global **cut value** (how close the array is to splitting — a structural measure per-node heuristics miss), the **weak side** (which specific nodes would partition: failure/jamming triage feeding ADR-032 posture), and an **at-risk flag** that counts as a structural event for the drift→recalibration advisor. Surfaced as `TrustedOutput::mesh`. **Measured cost policy** (criterion, 12-node mesh): weights are quantized (1/64; a *nonzero* coupling below one quantum saturates to quantum 1 so quantization never erases a live coupling — without the floor, balanced meshes of ≥ 65 nodes had every ~1/n coupling erased and sat permanently "at risk") and updates change-gated, so the steady-state cycle does zero graph work (~7.3 µs, ~23× cheaper than building); on any real change a full exact rebuild (~171 µs) is used because one `DynamicMinCut` delete+insert measured ~240 µs — the incremental machinery's overhead targets much larger graphs, so rebuild-on-change is the measured optimum at mesh scale (one-edge case −28% after the policy switch). Degenerate cases fail toward risk: a node with zero coupling is reported as already partitioned (cut 0). 9 mesh-guard tests + an engine-level wiring test; full `process_cycle` with the guard: ~33 µs for 4 nodes (50 ms budget).
|
||||
- **Opt-in FFT operator for the CIR ISTA solver (8–14× measured).** Φ is a sub-DFT, so each ISTA mat-vec can run as one length-G FFT (O(G log G)) instead of a dense O(K·G) product. New `CirConfig::fft_operator` (default **false** — the dense path stays the bit-exact witness default; the FFT evaluates the same sums in a different order, so enabling it shifts float results and requires regenerating any pinned witness). `FftOperator` (rustfft, planned once at construction, scratch reused across the ISTA loop) dispatches inside `ista_solve`; warm-start/Lipschitz stay dense at construction. Measured (criterion, same run): ht20 2.22 ms → 265 µs (**8.4×**), ht40 10.26 ms → 717 µs (**14.3×**); the real HE40 grid (K=484, G=1452) scales further. 3 new tests: FFT↔dense matvec equivalence to float tolerance (ht20 + he40 grids), end-to-end dominant-tap agreement on a single-path frame, and all default configs keep FFT off. New `cir_estimate_fft` bench group.
|
||||
- **Per-room adapter provenance + drift→recalibration advisor in the streaming engine.** Closes the trust-chain gap where an ~11 KB per-room LoRA adapter (ADR-150 §3.4) could silently change inference without the witness noticing. `StreamingEngine::set_room_adapter(AdapterInfo)` pins the adapter's content-derived id into provenance `model_version` (`rfenc-v1+adapter:<id>`) — and therefore into the BLAKE3 witness — so swapping or clearing adapter weights always shifts the witness (engine test proves base → adapter → other-adapter → cleared all witness differently, and cleared == base). New `RecalibrationAdvisor` recommends re-running the ADR-135 baseline / refitting the adapter on sustained low fusion coherence (streak threshold, default 60 cycles ≈ 3 s at 20 Hz) or an ADR-142 change-point; surfaced as `TrustedOutput::recalibration_recommended` and recorded on the sensing-server's `EngineBridge` alongside the witness. Bridge plumbing: `EngineBridge::{set_room_adapter, clear_room_adapter}` + live-path test that the adapter id flows into the live witness. *Scope note: this is the deployable provenance/trigger half of the "retrained model" roadmap item — fitting the adapter itself runs in the existing external calibration service (`aether-arena/calibration/`), and a trained RF-encoder checkpoint still does not exist in-tree.*
|
||||
- **RuView beyond-SOTA research series** (`docs/research/ruview-beyond-sota/`, 6 docs) — research-swarm output defining the beyond-SOTA bar and the path to it: system capability audit (role→crate maturity matrix, gap analysis, risk register), web-verified 2026 SOTA landscape per capability axis (incl. ratified IEEE 802.11bf-2025), 8-pillar target architecture on the ADR-136 contract spine (no rewrite), 6-layer benchmark/validation methodology (all 15 criterion bench targets inventoried; ADR-149 statistical protocol), and a determinism-safe optimization roadmap. Includes session validation evidence: 2,797 workspace tests / 0 failed, Python proof PASS (bit-exact), paired pre/post criterion runs.
|
||||
|
||||
### Performance
|
||||
- **CIR estimator warm-start precompute** — the diagonal Tikhonov preconditioner `diag(Φ^H Φ)+λI` and its CSR matrix were rebuilt every frame although they depend only on Φ and λ (fixed at `CirEstimator::new`); now precomputed at construction (`ruvsense/cir.rs`). Bit-identical floats (summation order unchanged, witness chain unaffected). Measured: `cir_estimate/he40` −3.9% (p<0.01), multiband groups −1.2/−1.4%; smaller configs within container noise.
|
||||
- **RF tomography solver hoisting** — ISTA gradient buffer no longer allocated inside the 100-iteration loop, and the Frobenius Lipschitz bound moved from per-`reconstruct` to construction (`ruvsense/tomography.rs`). Bit-identical results.
|
||||
|
||||
### Added
|
||||
- **Falsifiable occupancy benchmark (`wifi-densepose-train::occupancy_bench`).** Makes the presence/person-count "beyond SOTA" claim falsifiable in code instead of aspirational (the unfalsifiability gap from the beyond-SOTA system review). Grades predictions vs ground truth and gates a SOTA claim behind one `claim_allowed` invariant requiring all of: `DataProvenance::Measured` (synthetic/mock is scorable but **never claimable** — anti-mock-contamination per the CLAUDE.md Kconfig-bug lesson), a leak-free `EvalSplit` (refuses any split where a subject *or* environment id appears in both train and test — subject leakage / per-environment overfitting), `n_test ≥ min`, a **non-degenerate test set** (both truth classes represented: present-rate ≥ `min_positive_rate` and ≥ 1 absent sample — an all-absent set plus an always-absent predictor cannot release a claim; vacuous F1 scores 0.0, never 1.0), presence-F1 **bootstrap-CI lower bound** (deterministic seeded splitmix64) clearing the threshold, and count MAE within threshold. The claim string is unreadable except through the gate (`NO_CLAIM` otherwise). What remains is data, not method: a frozen, SHA-pinned, subject/environment-disjoint measured replay set turns the claim into a passing/failing test. 12 tests cover each refusal path, including the point-above/CI-below case (claim withheld on the CI lower bound even when the point estimate clears the threshold).
|
||||
- **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
|
||||
- **`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 ~13–19 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 40–49 BPM; fixed reaches the true 88–91 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).
|
||||
- **Person count no longer leaks up to 10 in heuristic mode — addresses #894.** `field_bridge::occupancy_or_fallback` returned the eigenvalue-based `FieldModel::estimate_occupancy` count **unbounded** (its internal ceiling is 10), while the sibling estimators on the same single-link data — the perturbation-energy fallback right below it and `score_to_person_count` — both cap at 3 ("1-3 for single ESP32"). On noisy / under-calibrated CSI the eigenvalue count inflated, producing the "10 persons reported when 1 present" symptom (seen when `--model` fails to load and the server runs on heuristics). Bounded the eigenvalue path to the shared `MAX_SINGLE_LINK_OCCUPANCY` (3) so every estimator on one link agrees; genuine higher counts come from the multistatic fusion path, not a single-link covariance estimate.
|
||||
- **MQTT multi-node deployments now create one Home-Assistant device per node — closes #898.** After the #872 MQTT wiring landed, the JSON→`VitalsSnapshot` bridge hard-coded a single `node_id` (the MQTT client id) and the publisher used a single `OwnedDiscoveryBuilder`, so every physical node collapsed into one device (`identifiers:["wifi_densepose_wifi-densepose-1"]`), contradicting the "one device per node" docs. The bridge now emits one snapshot per node in the sensing update's `nodes[]` (each with its own `node_id` + RSSI, falling back to a single aggregate snapshot for wifi/simulate sources), and the publisher derives a per-node builder (`OwnedDiscoveryBuilder::for_node`) that publishes discovery + availability lazily on first sight of each `node_id` and routes state to per-node topics — yielding N distinct HA devices with per-node availability/LWT. Unit-tested (distinct nodes → distinct `wifi_densepose_<node>` identifiers); 71 MQTT tests pass.
|
||||
- **Person count no longer pinned to 1 — addresses #803.** The aggregate occupancy reported by the sensing server was derived from `smoothed_person_score`, an EMA-smoothed *activity* score (amplitude variance / motion / spectral energy). That score saturates near a single occupant — one moving person maxes it out — so it cannot discriminate occupancy *count* and stayed clamped at 1 across S3/C6 and the Python/Docker/Rust servers. Meanwhile the count-aware per-node estimates the ESP32 paths already compute (firmware `n_persons`, and the DynamicMinCut `corr_persons`) were stashed in `NodeState::prev_person_count` and then **discarded** by the aggregator (same dead-wiring class as #872). The aggregator now takes `max(activity_count, node_max)` via a unit-tested `aggregate_person_count` helper, so a node positively estimating 2–3 occupants is surfaced instead of overwritten. The fix can only ever *raise* the count when a node reports more people, so the single-occupant case is provably never inflated (regression-guarded by test). **Second half:** the pure-CSI per-node path itself clamped its own estimate — the DynamicMinCut occupancy (`estimate_persons_from_correlation`, 0–3) was mapped to a score via `corr_persons / 3.0`, putting 2 people at 0.667, *just under* the 0.70 up-threshold of `score_to_person_count`, so the per-node count never climbed past 1 (so `node_max` was also stuck at 1 for CSI-only nodes). Replaced it with a threshold-aligned `corr_persons_to_score` mapping (1→0.40, 2→0.74, 3→0.96) whose steady state round-trips back to the same count through the EMA + hysteresis, while still gating transient noise. A convergence test replays the exact EMA loop to prove min-cut=2 now reports 2 (and documents that the old `/3.0` mapping reported 1). Full multi-person accuracy still depends on the underlying estimator quality; this removes the two server-side clamps that masked it. 586 sensing-server tests pass.
|
||||
- **MQTT publisher now actually runs (`--mqtt`) — closes #872.** The `--mqtt*` flags were defined only in `cli::Args` (dead code, referenced nowhere) while the binary parses a *separate* `main::Args` with no mqtt fields, and `main.rs` never started the `mqtt::` publisher — so MQTT/Home-Assistant integration was completely unwired (`--mqtt` errored as an unexpected argument, and even with the Docker image's `--features mqtt` build the publisher never ran). Earlier attempts chased a Docker *rebuild*; the real cause was disconnected *code*. Extracted the flags into a shared `cli::MqttArgs` (`#[command(flatten)]` into both structs), spawn the publisher on `--mqtt`, and bridge the JSON sensing broadcast into the typed `VitalsSnapshot` stream with a defensive `serde_json::Value` mapping. Verified end-to-end against `mosquitto`: 20 HA auto-discovery entities + live state (presence/person-count/…). 577 (default) / 580 (`--features mqtt`) tests pass.
|
||||
- **Mass Casualty triage never reports a survivor with a heartbeat as Deceased (safety) — PR #926.** Both triage paths in `wifi-densepose-mat` — `TriageCalculator::calculate` (`combine_assessments(Absent, None) ⇒ Deceased`) and the detection path `EnsembleClassifier::determine_triage` (`!has_breathing && !has_movement ⇒ Deceased`) — ignored the `heartbeat` field. A survivor with a detectable **pulse** but no sensed breathing/movement (respiratory arrest — the most time-critical *savable* state, Immediate/Red) was therefore reported **Deceased (Black)** and deprioritized for rescue. The domain path was in fact only reachable *because* a heartbeat made `has_vitals()` true, so every "Deceased" was a live person. Both paths now escalate to **Immediate** when a heartbeat is present; total absence of breathing, movement *and* heartbeat is unchanged (domain → `Unknown`, ensemble → `Deceased`). 2 safety regression tests; full MAT suite (177) green.
|
||||
- **Per-node Home-Assistant devices now report each node's *own* presence/motion — PR #918.** After the one-device-per-node fan-out landed, the MQTT bridge still applied the *room-level aggregate* `classification` to every node, so in a multi-node deployment a node watching an empty corner inherited another node's "present" (and `motion_level: "absent"` was mis-mapped to full motion). Each node in the broadcast `nodes[]` already carries its own `classification`; the bridge now reads it per node (extracted into a testable `vitals_snapshots_from_sensing_json`), keeping vitals + person count room-level. 4 unit tests.
|
||||
- **`--model` gives an actionable diagnostic instead of a cryptic magic error — PR #919 (refs #894).** Passing a HuggingFace `ruvnet/wifi-densepose-pretrained` file (`model.safetensors` / `model-q4.bin` / `model.rvf.jsonl`) to `--model` produced `invalid magic at offset 0: … got 0x77455735`, then a silent fall back to heuristics. The load-failure path now detects the format (safetensors / quantized blob / JSONL manifest) and explains that those files are a different format **and** encoder architecture than the RVF binary container the progressive loader expects, pointing to #894. Pure `diagnose_model_load_error` + 4 tests.
|
||||
- **`--export-rvf` no longer silently produces a placeholder model — PR #920.** The `--export-rvf` handler ran *before* `--train`/`--pretrain` and unconditionally wrote placeholder sine-wave weights, so the documented `--train … --export-rvf <path>` workflow short-circuited to a fake model and never trained (while printing "exported successfully"). It now emits the placeholder **container-format demo** only standalone (with a clear warning), and falls through to real training when `--train`/`--pretrain` is set; docs point to `--save-rvf` for the real model. 3 guard tests.
|
||||
|
||||
### Added
|
||||
- **ADR-151 per-room calibration & specialist training — full `baseline → enroll → extract → train` pipeline (new `wifi-densepose-calibration` crate).** "Teach the room before you teach the model": a local-first pipeline that turns a few minutes of clean human anchors — layered on the ADR-135 empty-room baseline — into a versioned bank of small, room-calibrated specialists for **presence, posture, breathing, heartbeat, restlessness, and anomaly**. Stages: guided enrollment with an adaptive quality gate (event-sourced `EnrollmentSession`, re-prompts bad anchors); feature extraction (autocorrelation periodicity in breathing/HR bands + variance/motion); six small specialists (learned threshold / nearest-prototype / band-limited periodicity / novelty); a `SpecialistBank` with baseline-drift **STALE** invalidation; and a `MixtureOfSpecialists` runtime with presence short-circuit + anomaly veto + confidence gating. Specialists are statistical heads today (runnable + hardware-validated); the frozen ADR-150 HF RF Foundation Encoder backbone is the documented upgrade path.
|
||||
- **CLI:** `enroll` / `train-room` / `room-status` / `room-watch`, plus the Stage-1 `calibrate-serve` HTTP API (CORS-enabled: `POST /start`, `GET /status`, `POST /stop`, `GET /result`, `GET /baselines`, `GET /health`) and a firewall-free `scripts/csi-udp-relay.py` for local Windows ESP32 testing without admin.
|
||||
- **Multistatic fusion (ADR-029):** `MultiNodeMixture` fuses several co-located nodes (each with its own room-calibrated bank) into one room state — presence OR'd across nodes, posture/breathing/heartbeat from the highest-confidence node, a single implausible node vetoes the room's vitals. Driven via `room-watch --node-bank N:path` (repeatable), which groups live frames by `node_id` and fuses. Same-room only; cross-room is federation (ADR-105).
|
||||
- **Validated on live ESP32-S3 (COM8, `edge_tier=0` raw CSI):** baseline capture (120 frames → 52-subcarrier baseline); the real parser → feature-extraction → mixture runtime detecting breathing (~16–31 BPM); and the multistatic ingest grouping/fusing by node-id end-to-end. Full multi-anchor enrollment accuracy requires the operator to perform the poses; true 2-node fusion + phase-based breathing + RVF/HNSW storage are noted follow-ups. 54 tests pass (35 calibration + 19 CLI).
|
||||
- **WiFi-CSI pose: efficiency frontier + per-room calibration service** (ADR-150 §3.2–3.6). Two beyond-SOTA results on the MM-Fi benchmark, plus the deployment mechanism that resolves real-world generalization:
|
||||
- **Efficiency frontier** — a **75 K-param model beats published SOTA** (74.3% vs MultiFormer 72.25% torso-PCK@20); every config from `micro` up is Pareto-dominant (smaller *and* more accurate than prior work). Shipped a deployable **int4 edge model (~20 KB, verified 74.08%, 0.135 ms single-thread CPU)** — published at [`ruvnet/wifi-densepose-mmfi-pose/edge`](https://huggingface.co/ruvnet/wifi-densepose-mmfi-pose). See [`docs/benchmarks/wifi-pose-efficiency-frontier.md`](docs/benchmarks/wifi-pose-efficiency-frontier.md).
|
||||
- **Generalization solved by few-shot calibration** — zero-shot cross-subject (~64%) and cross-environment (~10%) are *not* closeable by algorithms (CORAL, DANN, instance-norm, contrastive foundation-pretraining all tested, all failed) or by more training subjects (saturates ~64%). But **~100–200 labeled in-room samples recover SOTA-level pose**: cross-subject 64→76%, **cross-environment 10→73% (60% from just 5 samples)** — deployable as a **~11 KB per-room LoRA adapter** on a frozen shared base. Full empirical chain in ADR-150 §3.2–3.6.
|
||||
@@ -33,6 +61,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Security
|
||||
- **ESP32 OTA upload now fails closed when no PSK is provisioned** (#596 audit finding — critical, **breaking change for unprovisioned nodes**). `ota_check_auth()` previously returned `true` when `s_ota_psk[0] == '\0'`, so a freshly-flashed node would accept attacker-controlled firmware over plain HTTP on port 8032 from any host on the WiFi. No Secure Boot V2, no signed-image verification — a single LAN call could brick or backdoor a node. The fix rejects every OTA upload until a PSK is written to NVS (the OTA HTTP server still starts so operators can run `provision.py --ota-psk <hex>` over USB-CDC without reflashing). **Operators affected**: any deployment that relied on the unauthenticated OTA endpoint working out of the box now needs to provision a PSK before subsequent OTA pushes will succeed. Boot-time `ESP_LOGW` makes the new posture visible.
|
||||
- **Bearer-token auth accepts the scheme case-insensitively (RFC 6750) — PR #929.** `require_bearer` parsed the `Authorization` header with a case-sensitive `strip_prefix("Bearer ")`, so a *correct* `RUVIEW_API_TOKEN` sent as `Authorization: bearer <token>` (or `BEARER`, or with extra whitespace) was rejected with a confusing 401 — needless friction when enabling auth. The scheme is now matched with `eq_ignore_ascii_case` (per RFC 6750 §2.1 / RFC 7235 §2.1); the token compare is unchanged — still exact and constant-time (`ct_eq`) — so a wrong token or a non-Bearer scheme (`Basic …`) still returns 401. Audited the surrounding code while here: `ct_eq` correctly rejects length mismatch (no prefix-auth bypass) and the middleware fails closed. New `accepts_case_insensitive_bearer_scheme` test.
|
||||
- **Path-traversal vulnerabilities patched in five sensing-server endpoints** (closes #615 — critical). New `wifi_densepose_sensing_server::path_safety::safe_id()` enforces `[A-Za-z0-9._-]` only (no leading `.`, max 64 chars) before any user-controlled identifier reaches a `format!()` building a filesystem path. Applied at:
|
||||
- `POST /api/v1/recording/start` (`recording.rs` — `session_name`)
|
||||
- `GET /api/v1/recording/download/:id` (`recording.rs` — `id`)
|
||||
|
||||
@@ -15,7 +15,8 @@ Dual codebase: Python v1 (`v1/`) and Rust port (`v2/`).
|
||||
| `wifi-densepose-hardware` | ESP32 aggregator, TDM protocol, channel hopping firmware |
|
||||
| `wifi-densepose-ruvector` | RuVector v2.0.4 integration + cross-viewpoint fusion (5 modules) |
|
||||
| `wifi-densepose-wasm` | WebAssembly bindings for browser deployment |
|
||||
| `wifi-densepose-cli` | CLI tool (`wifi-densepose` binary) |
|
||||
| `wifi-densepose-cli` | CLI tool (`wifi-densepose` binary) — `calibrate`/`calibrate-serve`/`enroll`/`train-room`/`room-watch` + MAT (MAT gated behind the `mat` feature; build `--no-default-features` for the aarch64/appliance calibration binary) |
|
||||
| `wifi-densepose-calibration` | ADR-151 per-room calibration & specialist training — `baseline → enroll → extract → train` → bank of small specialists (presence/posture/breathing/heartbeat/restlessness/anomaly) + multistatic fusion; pure Rust, edge-deployable |
|
||||
| `wifi-densepose-sensing-server` | Lightweight Axum server for WiFi sensing UI |
|
||||
| `wifi-densepose-wifiscan` | Multi-BSSID WiFi scanning (ADR-022) |
|
||||
| `wifi-densepose-vitals` | ESP32 CSI-grade vital sign extraction (ADR-021) |
|
||||
|
||||
@@ -221,11 +221,15 @@ class ESP32BinaryParser:
|
||||
|
||||
snr = float(rssi - noise_floor)
|
||||
frequency = float(freq_mhz) * 1e6
|
||||
bandwidth = 20e6 # default; could infer from n_subcarriers
|
||||
|
||||
if n_subcarriers <= 56:
|
||||
# Bandwidth inference (issue #1005): HE-LTF uses a 4x denser tone
|
||||
# grid than HT-LTF on the same channel width — an HE-SU frame with
|
||||
# 256 bins (242 active HE20 tones) is a *20 MHz* capture, not 160.
|
||||
if ppdu_byte in (1, 2, 3): # HE-SU / HE-MU / HE-TB
|
||||
bandwidth = 40e6 if (flags_byte & 0x01) or n_subcarriers > 256 else 20e6
|
||||
elif n_subcarriers <= 64: # ESP32 HT20 delivers the full 64-bin FFT
|
||||
bandwidth = 20e6
|
||||
elif n_subcarriers <= 114:
|
||||
elif n_subcarriers <= 128:
|
||||
bandwidth = 40e6
|
||||
elif n_subcarriers <= 242:
|
||||
bandwidth = 80e6
|
||||
|
||||
@@ -24,10 +24,13 @@ services:
|
||||
environment:
|
||||
- RUST_LOG=info
|
||||
# CSI_SOURCE controls the data source for the sensing server.
|
||||
# Options: auto (default) — probe for ESP32 UDP then fall back to simulation
|
||||
# Options: auto (default) — probe for ESP32 UDP then host WiFi; **fail
|
||||
# hard with exit 78 if neither is detected**.
|
||||
# Synthetic data is no longer a silent fallback
|
||||
# (issue #937 fix) — operators must opt in.
|
||||
# esp32 — receive real CSI frames from an ESP32 on UDP port 5005
|
||||
# wifi — use host Wi-Fi RSSI/scan data (Windows netsh)
|
||||
# simulated — generate synthetic CSI data (no hardware required)
|
||||
# simulated — explicitly generate synthetic CSI for demo mode
|
||||
- CSI_SOURCE=${CSI_SOURCE:-auto}
|
||||
# MODELS_DIR controls where the server scans for .rvf model files.
|
||||
# Mount a host directory and set this to make models visible:
|
||||
|
||||
@@ -11,10 +11,65 @@
|
||||
# docker run ruvnet/wifi-densepose:latest --model /app/models/my.rvf
|
||||
#
|
||||
# Environment variables:
|
||||
# CSI_SOURCE — data source: auto (default), esp32, wifi, simulated
|
||||
# CSI_SOURCE — data source. Valid values:
|
||||
# auto — try ESP32 then Windows WiFi, **fail-loud if no
|
||||
# real hardware is detected** (issue #937 fix:
|
||||
# the server no longer silently falls back to
|
||||
# synthetic data — that's now opt-in only).
|
||||
# esp32 — listen for UDP CSI on the configured port.
|
||||
# wifi — Windows-native WiFi capture.
|
||||
# simulated — explicit demo mode with synthetic CSI.
|
||||
# Default is `auto`. Set CSI_SOURCE=simulated when you want
|
||||
# fake data tagged as such; never set it implicitly.
|
||||
# MODELS_DIR — directory to scan for .rvf model files (default: data/models)
|
||||
set -e
|
||||
|
||||
# ── Issue #864: fail-closed on default posture ───────────────────────────────
|
||||
# The pre-fix default was: empty RUVIEW_API_TOKEN (auth off) + --bind-addr
|
||||
# 0.0.0.0 + docker-compose publishing :3000/:3001/:5005 → an unauthenticated
|
||||
# attacker on any reachable network segment could read /api/v1/sensing/latest
|
||||
# and the /ws/sensing live stream. That posture is unsafe on guest WiFi,
|
||||
# untrusted LANs, accidentally-port-forwarded hosts, or any reverse-proxied
|
||||
# deployment. Refuse to start with this combination.
|
||||
#
|
||||
# Escape hatches (operator must opt in explicitly):
|
||||
# * Set RUVIEW_API_TOKEN to a strong secret → auth enabled on /api/v1/*.
|
||||
# * Set RUVIEW_ALLOW_UNAUTHENTICATED=1 → preserves the pre-fix behaviour;
|
||||
# only safe on an isolated trust boundary.
|
||||
# * Set RUVIEW_BIND_ADDR to a loopback / private interface → unauth is fine
|
||||
# when the socket isn't reachable. The auto-bind nudges toward 127.0.0.1.
|
||||
#
|
||||
# This check runs only for the default sensing-server path (no args + flag-only
|
||||
# args). The `cog-ha-matter` / `homecore` routes below are excluded because
|
||||
# they own their own auth lifecycle.
|
||||
case "${1:-}" in
|
||||
cog-ha-matter|ha-matter|homecore|homecore-server) ;;
|
||||
*)
|
||||
if [ -z "${RUVIEW_API_TOKEN:-}" ] && [ "${RUVIEW_ALLOW_UNAUTHENTICATED:-}" != "1" ]; then
|
||||
# If the operator hasn't overridden the bind, refuse outright on
|
||||
# the default 0.0.0.0. If they've nailed it to loopback (or a
|
||||
# specific private address they trust), let it run.
|
||||
__bind_default="${RUVIEW_BIND_ADDR:-0.0.0.0}"
|
||||
case "$__bind_default" in
|
||||
127.*|localhost|::1)
|
||||
: ;; # loopback bind is safe even without a token
|
||||
*)
|
||||
echo "[entrypoint] ERROR: refusing to start sensing-server with default" >&2
|
||||
echo "[entrypoint] posture: RUVIEW_API_TOKEN is unset AND bind is" >&2
|
||||
echo "[entrypoint] ${__bind_default}. /ws/sensing streams live sensing" >&2
|
||||
echo "[entrypoint] frames; that data would be readable by anyone who" >&2
|
||||
echo "[entrypoint] can reach this host. Pick one:" >&2
|
||||
echo "[entrypoint] docker run -e RUVIEW_API_TOKEN=\$(openssl rand -hex 32) ..." >&2
|
||||
echo "[entrypoint] docker run -e RUVIEW_BIND_ADDR=127.0.0.1 ..." >&2
|
||||
echo "[entrypoint] docker run -e RUVIEW_ALLOW_UNAUTHENTICATED=1 ... # only on trusted network" >&2
|
||||
echo "[entrypoint] See https://github.com/ruvnet/RuView/issues/864" >&2
|
||||
exit 64
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
# Route to cog-ha-matter (ADR-116) when invoked as:
|
||||
# docker run <image> cog-ha-matter [--flags]
|
||||
# or via the short alias `ha-matter`. Strips the keyword and execs the
|
||||
@@ -48,7 +103,7 @@ if [ "${1#-}" != "$1" ] || [ -z "$1" ]; then
|
||||
--ui-path /app/ui \
|
||||
--http-port 3000 \
|
||||
--ws-port 3001 \
|
||||
--bind-addr 0.0.0.0 \
|
||||
--bind-addr "${RUVIEW_BIND_ADDR:-0.0.0.0}" \
|
||||
"$@"
|
||||
fi
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ This witness separates what was **empirically observed on real silicon today** f
|
||||
|
||||
| # | Claim | Why it's not verified |
|
||||
|---|---|---|
|
||||
| **B1** | "Wi-Fi 6 HE-LTF: 242 subcarriers per HE20 frame" | The only AP in range (`ruv.net`) is 11n-only. Every captured frame is 128 bytes = 64 subcarriers (HT-LTF, `ppdu_type=0`). No HE-SU/HE-MU/HE-TB observed. Even if an 11ax AP were available, **whether ESP-IDF v5.4's CSI callback exposes HE-LTF subcarriers via `wifi_csi_info_t.buf` is an open question** — the public API was designed for HT-LTF, and the driver may quietly downconvert. **Validate by capturing CSI against an 11ax AP and comparing `info->len` between HT and HE frames.** |
|
||||
| **B1** | "Wi-Fi 6 HE-LTF: 242 subcarriers per HE20 frame" | The only AP in range (`ruv.net`) is 11n-only. Every captured frame is 128 bytes = 64 subcarriers (HT-LTF, `ppdu_type=0`). No HE-SU/HE-MU/HE-TB observed. Even if an 11ax AP were available, **whether ESP-IDF v5.4's CSI callback exposes HE-LTF subcarriers via `wifi_csi_info_t.buf` is an open question** — the public API was designed for HT-LTF, and the driver may quietly downconvert. **Validate by capturing CSI against an 11ax AP and comparing `info->len` between HT and HE frames.**<br><br>**RESOLVED WITH MEASUREMENT (2026-06-11, external — issue #1005, production deployment by @stuinfla):** the open question is answered in both directions. **IDF v5.4's driver blob downconverts** (148 B / 64-subcarrier HT frames, PPDU byte 0x00, on a confirmed-HE link); **IDF v5.5.2 delivers true HE-LTF** — 532 B frames = 256 bins (242 active HE20 tones), PPDU byte 0x01 (HE-SU), ~90% of frames, same board/AP/link. Setup: XIAO ESP32-C6 → hostapd on Intel AX210, 2.4 GHz ch 6, `ieee80211ax=1`. No firmware change required (`acquire_csi_su=1` was already set); the gate was purely the IDF driver version. Three C6 nodes ran this mode simultaneously with ADR-110 ESP-NOW sync. Requires the issue-#1005 version-guard fix in `c6_sync_espnow.c` to build on v5.5.x. |<br><br>**REPLICATED IN-HOUSE (2026-06-11):** same source + fix, fresh IDF v5.5.2 toolchain, original COM12 board (`20:6e:f1:17:00:84`), AP `ruv.net` (11ax 2.4 GHz): **84% of 1,525 captured frames at 532 B / PPDU 0x01 (HE-SU)**, HT minority 148 B / 0x00. Evidence grade: MEASURED (two independent rigs). |
|
||||
| **B2** | "TWT-bounded deterministic CSI cadence (10 ms wake)" | No 11ax AP in range. The TWT setup *call* was exercised live and the graceful fallback path is now correct (A9), but the agreement itself was never accepted. **Validate by associating with an 11ax AP that has TWT Responder=1, then capturing the timestamped CSI cadence vs the wall clock.** |
|
||||
| **B3** | "±100 µs cross-node alignment over 802.15.4" | 3 boards initialized their radios with correct EUIs (A4/A5), but **none stepped down from candidate-leader to follower** during repeated 35-second multi-board captures. <br><br>**Coex hypothesis REJECTED**: rebuilt + reflashed all 3 boards with `CONFIG_C6_TIMESYNC_CHANNEL=26` (2480 MHz, non-overlapping with WiFi ch 5 at 2432 MHz). Result identical: 3× candidate, 0× "stepping down". So 2.4 GHz radio coex was NOT the cause. <br><br>**Current leading hypothesis**: OpenThread (CONFIG_OPENTHREAD_ENABLED=y) owns the 802.15.4 radio when its stack is initialized — our weak-symbol overrides of `esp_ieee802154_receive_done` / `_transmit_done` may never be called because OpenThread registers strong handlers. Validation in progress: rebuilding with `CONFIG_OPENTHREAD_ENABLED=n` (raw 802.15.4 only, our beacon protocol is private — no need for the Thread stack). If leader election fires under raw-15.4-only, hypothesis confirmed. <br><br>If raw-only also fails, next move is to dump the actual PHY frame bytes via the IEEE 802.15.4 sniffer mode on a 4th board and diagnose at the frame level. |
|
||||
| **B4** | "~5 µA hibernation for battery seed nodes" | No INA / Joulescope current measurement available on this bench. The shipped code uses `esp_deep_sleep_enable_gpio_wakeup` (ext1 path, ESP-IDF default ~10 µA), not a true LP-core polling program. The 5 µA number is the C6 datasheet figure for ULP-level hibernation, not a measured value. **Validate by hooking an INA219/INA226 between the dev board's 3V3 rail and the regulator output, then averaging current over a 60-second cycle with the LP-core armed.** |
|
||||
|
||||
@@ -19,7 +19,7 @@ The production CSI node firmware (`firmware/esp32-csi-node`) was built around th
|
||||
|
||||
| C6 capability | What it enables for sensing | Why we can't get it on S3 |
|
||||
|---|---|---|
|
||||
| **802.11ax (Wi-Fi 6) HE-LTF CSI** | 242 subcarriers per HE20 frame (vs 52 for HT-LTF), HE-MU/HE-TB PPDU types, OFDMA-aware channel sounding | S3 radio is HT-only (n) |
|
||||
| **802.11ax (Wi-Fi 6) HE-LTF CSI** | 242 subcarriers per HE20 frame (vs 52 for HT-LTF), HE-MU/HE-TB PPDU types, OFDMA-aware channel sounding. **Hardware-confirmed 2026-06-11** (issue #1005, external production deployment): requires **ESP-IDF ≥ 5.5** — the v5.4 driver blob silently downconverts to 64-subcarrier HT even on a confirmed-HE link; v5.5.2 delivers 532 B frames = 256 bins (242 active tones), PPDU 0x01 (HE-SU). See WITNESS-LOG-110 §B1 (resolved). | S3 radio is HT-only (n) |
|
||||
| **802.15.4 (Thread / Zigbee)** | Cross-node time-sync over a separate radio — frees Wi-Fi airtime for CSI, ±100 µs alignment possible without coordination traffic on the sensing channel | S3 has no 802.15.4 |
|
||||
| **TWT (Target Wake Time)** | Sensor negotiates a deterministic wake slot with the AP; CSI cadence becomes scheduler-bounded instead of opportunistic | Requires 802.11ax — S3 can't speak it |
|
||||
| **LP-core + hibernation (~5 µA)** | Always-on motion gate runs on a separate RISC-V LP core in deep sleep; HP core stays off until a real event | S3 ULP is FSM-only, ~10 µA floor |
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
# ADR-151: RuView Per-Room Calibration & Specialized Model Training System
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Accepted — Stages 1–5 implemented (statistical specialists); HF-backbone distillation pending |
|
||||
| **Date** | 2026-06-09 |
|
||||
| **Deciders** | ruv |
|
||||
| **Codebase target** | New `wifi-densepose-calibration` crate (orchestration); `wifi-densepose-train` (`rapid_adapt.rs`, `signal_features.rs`, `trainer.rs`); `wifi-densepose-ruvector` (RVF specialist storage); `wifi-densepose-signal/ruvsense/*` (feature extractors); `wifi-densepose-cli` (`enroll`, `train-room`, `room-status` subcommands) |
|
||||
| **Relates to** | ADR-135 (Empty-Room Baseline Calibration), ADR-030 (Persistent Field Model), ADR-134 (CIR), ADR-024 (Contrastive CSI Embedding / AETHER), ADR-027 (Cross-Environment Domain Generalization / MERIDIAN), ADR-070 (Self-Supervised Pretraining), ADR-105 (Federated CSI Training), ADR-149 (AetherArena / Hugging Face), ADR-150 (RF Foundation Encoder) |
|
||||
|
||||
---
|
||||
|
||||
## 1. Context
|
||||
|
||||
### 1.1 The thesis — teach the room before you teach the model
|
||||
|
||||
RuView's deployment frontier is not a better generic model. ADR-150 documents the wall directly: an MM-Fi pose head scores **81.63% torso-PCK@20 in-domain but ~11.6% leakage-free cross-subject**, and bigger capacity *hurts* cross-subject (transformer 24.8% < conv 27.3%). A single oversized model that "understands the world" overfits the rooms and bodies it has seen. The lever is the opposite of scale: **a small model that understands *one* room and *one* person**, calibrated in minutes, run locally, and specialised per biological signal.
|
||||
|
||||
This positions RuView between the two incumbents in ambient sensing:
|
||||
|
||||
- **Wearables** — high fidelity, but people forget to wear them, and they only measure the wearer.
|
||||
- **Cameras** — powerful, but invasive, store identifiable video, and fail in the dark / under covers.
|
||||
|
||||
RuView sits in the middle: it learns the *space*, learns the *person*, and tracks biological rhythm (breathing, heartbeat, restlessness, posture, presence) without seeing skin or storing video. Heartbeat and breathing are not visual problems — they are tiny, repeating disturbances in the RF field. Capturing them well is a *calibration* problem, not a *model-size* problem.
|
||||
|
||||
### 1.2 What already exists (and what is missing)
|
||||
|
||||
The pieces of a calibration→training pipeline exist as disconnected modules. There is no system that runs them end to end and emits a per-room model bank.
|
||||
|
||||
| Capability | Status today | Gap |
|
||||
|------------|--------------|-----|
|
||||
| Empty-room baseline (environmental fingerprint) | ADR-135 `BaselineCalibration` (Proposed): per-subcarrier amplitude + circular-phase stats, `ruvcal` NVS namespace | Captures the *room*, but there is no step that captures *guided human anchors* on top of it |
|
||||
| Field eigenstructure | ADR-030 `field_model.rs` (SVD room eigenmodes) | Consumes calibration; not wired to a training trigger |
|
||||
| Shared invariant backbone | ADR-150 RF Foundation Encoder (pose-preserving, subject/room/device-invariant) | Defined as a *foundation* embedding; nothing distills it into per-room specialists |
|
||||
| Few-shot adaptation | `train/src/rapid_adapt.rs` — test-time training → LoRA weight deltas (MERIDIAN P5) | Produces a *single* pose-adaptation delta, not a bank of per-modality specialists |
|
||||
| Feature extractors | `ruvsense/{bvp,longitudinal,intention,gesture,pose_tracker,adversarial}.rs`, `train/src/signal_features.rs` | Each emits a signal; none is packaged as a labelled training source for enrollment |
|
||||
| Small-model storage | `wifi-densepose-ruvector` (RVF cognitive containers, HNSW, sketch) | No schema for "a bank of specialist models scoped to a room_id" |
|
||||
| HF publishing | ADR-149 AetherArena (Hugging Face Space + signed scorer), `sensing-server` `from_pretrained` path | Publishes/評価s a *global* model; no notion of a published *base* + private *local* heads |
|
||||
|
||||
**The missing system is the connective tissue**: a guided enrollment protocol, a feature-extraction-to-label bridge, a specialist-bank trainer that reuses the frozen HF backbone, and a runtime that fuses the specialists with confidence gating. This ADR defines that system.
|
||||
|
||||
### 1.3 The four-step user model (and where each step lands)
|
||||
|
||||
The system is deliberately presented to operators as four plain steps. Each maps to existing or new code:
|
||||
|
||||
1. **Capture a quiet baseline** — no people, just room/router/reflections/noise/drift → the *environmental fingerprint*. → **Reuse ADR-135** `BaselineCalibration` + **ADR-030** field eigenmodes. No new capture code; the calibration crate calls it.
|
||||
2. **Capture guided samples** — stand, sit, lie down, slow vs normal breathing, small movement, sleep posture. Clean anchors, not hours of data. → **NEW** `EnrollmentProtocol` (Section 2.2).
|
||||
3. **Extract the useful signal** — CSI phase, amplitude, Doppler shift, micro-motion, periodicity, variance, timing. → **Reuse** `signal_features.rs` + ruvsense extractors, packaged as labelled `AnchorFeature` records (Section 2.3).
|
||||
4. **Compress patterns into small ruVector models** — *specialised* per signal: breathing, heartbeat, sleep restlessness, posture, presence, anomaly. → **NEW** `SpecialistBank` trained via `rapid_adapt` LoRA heads over the frozen ADR-150 backbone, stored as RVF (Section 2.4).
|
||||
|
||||
---
|
||||
|
||||
## 2. Decision
|
||||
|
||||
**Build the RuView Per-Room Calibration & Specialized Model Training System: a four-stage, local-first pipeline (`baseline → enroll → extract → train`) that produces a versioned *bank of small specialised ruVector models* scoped to one `room_id`, each a lightweight head distilled/adapted from the frozen, Hugging-Face-published RF Foundation Encoder (ADR-150).** Big model understands the world; small ruVector models understand *your room*.
|
||||
|
||||
Two invariants govern every design choice below:
|
||||
|
||||
> **(A) Specialisation over scale.** One small model per biological signal, not one large model for all of them. Each specialist is faster, cheaper, more private, and — because it is calibrated to the room's actual fingerprint — often *more accurate* than a general model.
|
||||
>
|
||||
> **(B) Local-first, base-shared.** The frozen room/subject/device-invariant backbone is the only artifact published to Hugging Face. Per-room baselines and per-specialist heads never leave the device unless the operator opts into federation (ADR-105).
|
||||
|
||||
### 2.1 System architecture
|
||||
|
||||
```
|
||||
HUGGING FACE HUB (public, room-agnostic)
|
||||
┌───────────────────────────────────────┐
|
||||
│ RF Foundation Encoder (ADR-150) │
|
||||
│ pose-preserving · subject/room/device │
|
||||
│ -invariant · frozen · safetensors │
|
||||
└───────────────┬───────────────────────┘
|
||||
│ from_pretrained() once, cached on device
|
||||
▼
|
||||
STAGE 1 baseline STAGE 2 enroll STAGE 3 extract STAGE 4 train (per room_id)
|
||||
┌──────────────┐ ┌──────────────┐ ┌────────────────┐ ┌─────────────────────────┐
|
||||
│ ADR-135 │ │ Enrollment │ │ signal_features│ │ SpecialistBank │
|
||||
│ Baseline- │──fp──► │ Protocol │─clip►│ + ruvsense │─AF──►│ frozen backbone │
|
||||
│ Calibration │ │ guided │ │ extractors │ │ │ ┌────────────────┐ │
|
||||
│ (env finger- │ │ anchors: │ │ → AnchorFeature│ │ ├─►│ breathing head │ │
|
||||
│ print) │ │ stand/sit/ │ │ (phase, amp, │ │ ├─►│ heartbeat head │ │
|
||||
│ ADR-030 │ │ lie/breathe/ │ │ doppler, │ │ ├─►│ restless head │ │
|
||||
│ field eigen │ │ move/sleep │ │ micromotion, │ │ ├─►│ posture head │ │
|
||||
└──────────────┘ └──────────────┘ │ periodicity, │ │ ├─►│ presence head │ │
|
||||
│ │ variance, │ │ └─►│ anomaly head │ │
|
||||
│ baseline drift > τ → invalidate bank │ timing) │ │ (LoRA / ruVector │
|
||||
└───────────────────────────────────────┴────────────────┴──────┤ small models) │
|
||||
└───────────┬─────────────┘
|
||||
│ RVF container
|
||||
▼
|
||||
RUNTIME: Mixture-of-Specialists
|
||||
each head emits {value, confidence};
|
||||
coherence_gate (ADR-135) + anomaly
|
||||
head veto → fused RoomState
|
||||
```
|
||||
|
||||
The shared backbone is loaded **once per device** and frozen. Every specialist is a small head over its embedding — so the marginal cost of a sixth specialist is kilobytes of LoRA weights, not another full model.
|
||||
|
||||
### 2.2 Stage 2 — the guided enrollment protocol (NEW)
|
||||
|
||||
`EnrollmentProtocol` is a CLI-driven state machine that walks the operator through a fixed sequence of labelled **anchors**. The design rule from the user vision is explicit: *clean anchors, not hours of data.* Each anchor is a short (default 20 s @ 20 Hz = 400 frames) labelled clip captured against the already-recorded baseline.
|
||||
|
||||
| Anchor | Label | Duration | Primary signal taught | Feature emphasis |
|
||||
|--------|-------|----------|-----------------------|------------------|
|
||||
| `empty` | presence=0 | (reuse ADR-135 baseline) | absence reference | amplitude variance floor |
|
||||
| `stand_still` | posture=standing, presence=1 | 20 s | static human load | amplitude mean shift, eigenmode delta |
|
||||
| `sit` | posture=sitting | 20 s | lower static load | amplitude profile |
|
||||
| `lie_down` | posture=lying | 20 s | sleep-position load | amplitude profile, low Doppler |
|
||||
| `breathe_slow` | resp≈0.1–0.15 Hz | 30 s | slow respiration | periodicity, micro-Doppler |
|
||||
| `breathe_normal` | resp≈0.2–0.3 Hz | 30 s | normal respiration | periodicity, BVP phase |
|
||||
| `small_move` | motion=1 | 20 s | limb micro-motion | Doppler spread, variance |
|
||||
| `sleep_posture` | posture=lying, restless=0 | 30 s | quiescent sleep baseline | long-window variance, timing |
|
||||
|
||||
The protocol is **adaptive**: an anchor is only accepted when its captured features pass a quality gate (coherence ≥ threshold from `coherence_gate.rs`, sufficient SNR vs baseline, no saturation). A failed anchor is re-prompted rather than silently kept — bad anchors poison small models far more than large ones. Total guided enrollment is ~4 minutes of wall-clock, producing 8 clean anchors. This is intentionally far below the "hours of data" that a from-scratch model needs, because the backbone already carries world knowledge; enrollment only teaches *this* room's offsets.
|
||||
|
||||
Anchors are persisted as an append-only `EnrollmentSession` (event-sourced, per CLAUDE.md state rules) under `room_id`, so re-enrollment is incremental and auditable.
|
||||
|
||||
### 2.3 Stage 3 — feature extraction to labelled records (REUSE + bridge)
|
||||
|
||||
Each accepted anchor clip is run through the existing extractor stack, baseline-subtracted per ADR-135, and packaged into an `AnchorFeature` record. No new DSP is invented — this stage is a *bridge*, not a new algorithm.
|
||||
|
||||
| Feature group | Source module | Used by specialists |
|
||||
|---------------|---------------|---------------------|
|
||||
| CSI amplitude mean/variance | ADR-135 baseline subtraction + `signal_features.rs` | presence, posture |
|
||||
| CSI phase (sanitised, LO-aligned) | `phase_sanitizer` → `phase_align` | posture, heartbeat |
|
||||
| Doppler shift / micro-Doppler | `ruvsense/bvp.rs`, `breathing` path | breathing, small-move |
|
||||
| Micro-motion / intention lead | `ruvsense/intention.rs` | restlessness, anomaly |
|
||||
| Periodicity / spectral peaks | `bvp.rs` autocorrelation + FFT | breathing, heartbeat |
|
||||
| Long-window variance / drift | `ruvsense/longitudinal.rs` (Welford) | restlessness, presence |
|
||||
| Timing / inter-frame epoch | `c6_timesync` epoch, frame Δt | all (rhythm alignment) |
|
||||
| Field eigenmode coefficients | ADR-030 `field_model.rs` | posture, presence |
|
||||
|
||||
`AnchorFeature` = `{ room_id, anchor_label, t_epoch_us, embedding: [f32; D] (backbone output), aux: { resp_hz?, doppler_spread, variance, periodicity_score, eigen_coeffs } }`. The backbone embedding is the *shared* representation; `aux` carries the cheap hand-features that let small heads specialise without re-learning DSP.
|
||||
|
||||
### 2.4 Stage 4 — the specialist bank (NEW, the core contribution)
|
||||
|
||||
A **`SpecialistBank`** is a versioned collection of small models scoped to one `room_id`, persisted as a single RVF cognitive container (`wifi-densepose-ruvector`). Each specialist is a *head* over the frozen backbone embedding, trained from the labelled `AnchorFeature` records via the existing `rapid_adapt.rs` LoRA machinery (test-time/few-shot training, contrastive + entropy losses), **not** a from-scratch network.
|
||||
|
||||
| Specialist | Model type | Params (typ.) | Label source | Output |
|
||||
|------------|-----------|---------------|--------------|--------|
|
||||
| **breathing** | 1-D temporal head + periodicity regressor | ~8 KB LoRA + aux | `breathe_slow`/`breathe_normal` | resp rate (Hz) + confidence |
|
||||
| **heartbeat** | narrowband phase head (harmonic-aware) | ~12 KB | quiescent anchors + periodicity | HR (bpm) + confidence |
|
||||
| **sleep restlessness** | variance/drift classifier | ~4 KB | `sleep_posture` vs `small_move` | restlessness score [0,1] |
|
||||
| **posture** | k-way prototype classifier (HNSW NN) | prototypes only | `stand/sit/lie` anchors | posture class + margin |
|
||||
| **presence** | binary energy/eigenmode gate | ~2 KB | `empty` vs occupied anchors | presence prob |
|
||||
| **anomaly** | one-class / physically-impossible detector (`adversarial.rs`) | ~6 KB | baseline + all anchors (novelty) | anomaly score + veto flag |
|
||||
|
||||
Design properties that follow from invariant (A):
|
||||
|
||||
- **Independently versioned & swappable.** Re-enrolling breathing does not retrain posture. A specialist carries its own `{trained_at, anchor_set_hash, baseline_hash, backbone_rev}`.
|
||||
- **HNSW prototype storage for the classifiers.** Posture and presence are nearest-prototype lookups in the RVF index — no inference engine, microsecond latency, and new postures are added by inserting a prototype, not retraining.
|
||||
- **SONA online adaptation.** Each specialist may carry a SONA/MicroLoRA online-adaptation slot (`ruvllm_sona_*` / `microlora` primitives) so it tracks slow drift (furniture moved, seasonal RF change) between full re-enrollments, gated by ADR-135 baseline drift.
|
||||
- **Teacher–student distillation (optional, offline).** Where a labelled public corpus exists (MM-Fi, Wi-Pose), the ADR-150 backbone acts as teacher to pre-shape a head before per-room fine-tuning, improving cold-start. The *teacher* is global/HF; the *student head* is local.
|
||||
|
||||
**Invalidation contract.** The bank stores the `baseline_id` (the baseline UUID) it was trained against. **As implemented**, the runtime marks the bank `STALE` whenever the *current* baseline id differs from the trained one — a conservative trigger that catches re-calibration (room rearranged, AP moved, band changed) because any of those produces a new baseline. A finer **drift-threshold** trigger (mark STALE when ADR-135's per-subcarrier deviation exceeds τ *without* a full re-baseline) is a planned refinement (P6). Either way the runtime prompts re-enrollment rather than emitting silently wrong vitals — the calibration analogue of the #954 `DEGRADED` honesty rule: never report confident numbers from an invalid model.
|
||||
|
||||
### 2.5 Runtime — mixture of specialists with confidence gating
|
||||
|
||||
At inference, the frozen backbone embeds each CSI window once; every specialist consumes that shared embedding and emits `{value, confidence}`. Fusion rules:
|
||||
|
||||
- The **anomaly** specialist holds a **veto**: a high anomaly score (physically-impossible signal per `adversarial.rs`, or a coherence-gate `Reject`) suppresses positive vitals/posture output and raises a flag, rather than propagating a hallucinated reading.
|
||||
- **presence=0** short-circuits breathing/heartbeat/posture to `null` (you cannot have a respiration rate in an empty room).
|
||||
- Each emitted reading is tagged with the specialist's confidence and the `baseline_hash`/`backbone_rev` provenance, so downstream consumers (sensing-server, MQTT, Home Assistant) can gate on quality — consistent with ADR-135 coherence-gate semantics.
|
||||
|
||||
### 2.6 Crate & module layout
|
||||
|
||||
New bounded-context crate `wifi-densepose-calibration` (orchestration only; files < 500 lines, typed public APIs, event-sourced sessions — per CLAUDE.md):
|
||||
|
||||
```
|
||||
wifi-densepose-calibration/
|
||||
src/
|
||||
lib.rs # public API: CalibrationSystem facade
|
||||
enrollment.rs # EnrollmentProtocol state machine (Stage 2)
|
||||
anchor.rs # Anchor, EnrollmentSession (event-sourced)
|
||||
extract.rs # AnchorFeature bridge over signal_features + ruvsense (Stage 3)
|
||||
specialist.rs # Specialist trait, SpecialistKind enum
|
||||
bank.rs # SpecialistBank (RVF container, versioning, invalidation)
|
||||
runtime.rs # MixtureOfSpecialists fusion + veto (Stage 5)
|
||||
backbone.rs # frozen ADR-150 encoder loader (hf_hub from_pretrained, cached)
|
||||
error.rs
|
||||
```
|
||||
|
||||
Dependencies (no duplication — orchestrates existing crates): `wifi-densepose-signal` (ruvsense extractors, ADR-135 baseline), `wifi-densepose-train` (`rapid_adapt`, `signal_features`, `trainer`), `wifi-densepose-ruvector` (RVF, HNSW), `wifi-densepose-nn` (backbone inference). The `wifi-densepose-cli` gains `enroll`, `train-room`, and `room-status` subcommands, sequenced after the existing ADR-135 `calibrate`.
|
||||
|
||||
### 2.7 CLI flow (operator-facing)
|
||||
|
||||
```bash
|
||||
# Stage 1 — environmental fingerprint (ADR-135, existing)
|
||||
wifi-densepose calibrate --room living-room --duration 60s # empty room
|
||||
|
||||
# Stage 2+3 — guided enrollment (NEW); prompts through 8 anchors, ~4 min
|
||||
wifi-densepose enroll --room living-room
|
||||
# → "Stand still in view of the sensor…" [✓ anchor accepted: coherence 0.91]
|
||||
# → "Sit down…" [✗ low SNR, retrying]
|
||||
# ...
|
||||
|
||||
# Stage 4 — train the specialist bank (NEW); reuses cached HF backbone
|
||||
wifi-densepose train-room --room living-room \
|
||||
--specialists breathing,heartbeat,restlessness,posture,presence,anomaly
|
||||
|
||||
# Status / invalidation
|
||||
wifi-densepose room-status --room living-room
|
||||
# baseline: fresh (drift 0.04 < 0.20) · backbone: rf-foundation@1.2.0
|
||||
# breathing ✓ trained 2026-06-09 conf p50 0.88
|
||||
# heartbeat ✓ trained 2026-06-09 conf p50 0.71
|
||||
# posture ✓ 3 prototypes (stand/sit/lie)
|
||||
# anomaly ✓ · presence ✓ · restlessness ✓
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Consequences
|
||||
|
||||
### 3.1 Positive
|
||||
|
||||
- **Fidelity through specialisation.** Six small calibrated heads beat one oversized general model on the cross-room/cross-subject frontier that ADR-150 quantified — and each runs in microseconds-to-milliseconds, on-device.
|
||||
- **Privacy by construction.** Only the room-agnostic backbone is public (HF). The environmental fingerprint and the person-specific heads stay local; no video, no skin, no cloud round-trip. This is the core differentiator vs cameras and the convenience differentiator vs wearables.
|
||||
- **Minutes, not hours.** Because the backbone carries world knowledge, ~4 minutes of clean anchors calibrates a room. Re-enrollment is incremental.
|
||||
- **Honest degradation.** The `baseline_hash` invalidation + anomaly veto mean an out-of-calibration room reports `STALE`/flagged rather than confidently wrong — the same honesty principle as the firmware `DEGRADED` flag.
|
||||
- **Composable & cheap to extend.** A new biological signal = a new small head over the same embedding, not a new model.
|
||||
|
||||
### 3.2 Negative / risks
|
||||
|
||||
- **Backbone dependency.** Every specialist rides on ADR-150's encoder; its quality and revision compatibility (`backbone_rev`) are a single point of leverage. Mitigation: pin `backbone_rev` in each specialist; distillation cold-start reduces sensitivity.
|
||||
- **Enrollment burden.** 4 minutes is small but non-zero, and anchor quality depends on the operator following prompts. Mitigation: adaptive re-prompting + quality gates; ship sane defaults so a partial bank (presence+posture) works after just the static anchors.
|
||||
- **Heartbeat is hard.** Sub-mm chest displacement at HR frequencies is near the ESP32-S3 noise floor; the heartbeat specialist will have lower and more variable confidence than breathing. The confidence-gated runtime surfaces this rather than faking it.
|
||||
- **Per-room storage proliferation.** A bank per room per person; needs a clear RVF lifecycle (list/prune/export) — handled by `bank.rs` versioning and the `room-status` CLI.
|
||||
|
||||
### 3.3 Alternatives considered
|
||||
|
||||
| Alternative | Verdict | Reason |
|
||||
|-------------|---------|--------|
|
||||
| One large general model for all signals | **Rejected** | The ADR-150 evidence: scale overfits rooms/subjects and collapses cross-domain; also slower, costlier, less private. Directly contradicts invariant (A). |
|
||||
| Cloud training of per-room models | **Rejected** | Violates invariant (B): would ship raw CSI of a person's home/sleep to a server. Local-first is the privacy promise. Federation (ADR-105) is the *opt-in* path for shared improvement, exchanging gradients/deltas, never raw CSI. |
|
||||
| Skip the backbone; train each specialist from scratch | **Rejected** | Reintroduces the "hours of data" requirement the user vision explicitly rejects, and loses cross-room priors. |
|
||||
| Fold this into ADR-135 | **Rejected** | ADR-135 is *room* calibration (no humans). This ADR is *human-anchor* enrollment + model training on top of it. Distinct lifecycles, distinct invalidation; kept as separate bounded contexts. |
|
||||
|
||||
---
|
||||
|
||||
## 4. Implementation phases
|
||||
|
||||
| Phase | Scope | Exit criterion | Status |
|
||||
|-------|-------|----------------|--------|
|
||||
| **P1** | Scaffold `wifi-densepose-calibration` crate; `AnchorFeature` schema; (backbone via `hf_hub` deferred) | Crate + schema; unit tests | ✅ Done (crate + Stage-1 baseline via `calibrate`/`calibrate-serve`; HF backbone deferred) |
|
||||
| **P2** | `EnrollmentProtocol` + `anchor.rs` (event-sourced sessions) + CLI `enroll` with quality gates | 8-anchor enrollment; bad anchors re-prompt | ✅ Done (`anchor.rs`, `enrollment.rs`, CLI `enroll`) |
|
||||
| **P3** | `extract.rs` bridge → labelled records; baseline subtraction (ADR-135) | `AnchorFeature` records persisted per `room_id` | ✅ Done (`extract.rs`; autocorr periodicity + variance/motion) |
|
||||
| **P4** | `SpecialistBank` + presence/posture (prototype) + breathing (periodicity); persistence + versioning | `train-room` produces a bank; `room-status` reads it back | ✅ Done (`specialist.rs`, `bank.rs`, CLI `train-room`/`room-status`; JSON persistence — RVF/HNSW = future) |
|
||||
| **P5** | heartbeat + restlessness + anomaly specialists; `runtime.rs` mixture + veto + confidence gating | End-to-end RoomState on hardware; anomaly veto verified | ✅ Done (`runtime.rs`, CLI `room-watch`; breathing read live on COM8 ESP32) |
|
||||
| **P6** | Baseline-drift `STALE` invalidation; SONA online adaptation; optional ADR-105 federation; HF teacher–student distillation | Drift marks bank STALE; AetherArena entry | ◐ Partial (STALE done; SONA/federation/HF-backbone = follow-ups) |
|
||||
|
||||
**Current status (2026-06-10):** Stages 1–5 implemented with *statistical* specialists (threshold/prototype/autocorrelation). 55 tests (35 unit incl. multistatic + 1 full-loop integration + 19 CLI), all passing under qemu-aarch64. **Validation scope is precise:** baseline capture + HTTP API + auth are proven on real CSI (Pi-5 nexmon, 6,813 frames; and an ESP32-S3). The complete `baseline → enroll → train-room → infer` loop is now **proven in-process** on deterministic synthetic CSI (`tests/full_loop.rs`: clean baseline with zero motion flags, 8/8 anchors through the quality gate, 6 specialists trained, JSON bank round-trip, trained-bank inference 18±2 BPM positive / absent negative / foreign-baseline STALE; seed-robust). The one live runtime signal (breathing ~16–31 BPM via `room-watch`) used the *stateless* breathing head, **not** a trained bank; the clean empty-room loop has **not** yet run on-target — the remaining gap is strictly the hardware session (empty room + operator anchors). The four behavioral findings from the full-loop test (z-band squeeze, variance-only presence, ungated hz embedding, heart-band lag-floor leakage) are FIXED and regression-guarded — see the integration doc §7. SOTA-intake decisions affecting this system (geometry conditioning, checkerboard alignment) are recorded in ADR-152. Open refinements: `--source-format adr018v6` (drive from the Pi's own nexmon), phase-based breathing carrier, RVF/HNSW storage, and the ADR-150 frozen HF backbone the specialists would distill from.
|
||||
|
||||
Validation per CLAUDE.md: `cargo test --workspace --no-default-features` green; hardware verification on the ESP32-S3 (currently COM8) before any release; witness bundle regenerated if the proof surface changes.
|
||||
|
||||
---
|
||||
|
||||
## 5. Summary
|
||||
|
||||
> Big models understand the world. Small ruVector models understand *your room*.
|
||||
|
||||
ADR-151 makes that operational: a local-first `baseline → enroll → extract → train` pipeline that turns ~4 minutes of clean human anchors — layered on ADR-135's empty-room fingerprint and ADR-150's Hugging-Face-published invariant backbone — into a versioned bank of tiny, specialised, privacy-preserving models for breathing, heartbeat, restlessness, posture, presence, and anomaly. Specialisation over scale; local heads over a shared base; honest `STALE` degradation over confident error.
|
||||
@@ -0,0 +1,98 @@
|
||||
# ADR-152: WiFi-Pose SOTA 2026 Intake — Geometry-Conditioned Calibration, External Benchmarks, and the Foundation-Encoder Training Recipe
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Proposed |
|
||||
| **Date** | 2026-06-10 |
|
||||
| **Deciders** | ruv |
|
||||
| **Codebase target** | `wifi-densepose-calibration` (geometry conditioning, ADR-151 Stage 2), `wifi-densepose-train` (camera-supervised path, MAE recipe), `wifi-densepose-cli` (benchmark harness), docs |
|
||||
| **Relates to** | ADR-151 (Per-Room Calibration), ADR-150 (RF Foundation Encoder), ADR-135 (Empty-Room Baseline), ADR-079 (Camera-Supervised Pose), ADR-027 (MERIDIAN), ADR-024 (AETHER), ADR-149 (AetherArena), ADR-029 (Multistatic) |
|
||||
| **Research provenance** | Deep-research run 2026-06-10: 22 sources fetched, 110 claims extracted, 25 adversarially verified (3-vote), 24 confirmed / 1 refuted. Evidence grades per source below. |
|
||||
|
||||
---
|
||||
|
||||
## 1. Context
|
||||
|
||||
A structured survey of the 2025–2026 WiFi human-sensing state of the art was run on 2026-06-10 to answer: *what should RuView integrate next, and does anything published invalidate our current direction?* Every claim below was verified against the primary source by independent adversarial reviewers; **evidence grades distinguish what the papers measured from what they merely claim**. Almost all performance numbers are author-self-reported preprint results — treated here as CLAIMED until reproduced on our hardware.
|
||||
|
||||
### 1.1 The five verified findings
|
||||
|
||||
**(F1) "Coordinate overfitting" is a named, diagnosed failure mode of camera-supervised WiFi pose — and our ADR-079 pipeline has the exact shape of it.**
|
||||
PerceptAlign (arXiv [2601.12252](https://arxiv.org/abs/2601.12252), accepted ACM MobiCom 2026) shows that models regressing CSI directly to camera-frame coordinates memorize the deployment-specific transceiver layout; SOTA baselines degrade to >600 mm MPJPE in unseen scenes. Their fix is cheap: a <5-minute calibration using two checkerboards and a few photos to align WiFi and vision in one shared 3D frame, plus **fusing transceiver-position embeddings with CSI features**. Claimed: −12.3% in-domain error, −60%+ cross-domain error. They release the claimed-largest cross-domain 3D WiFi pose dataset (21 subjects, 5 scenes, 18 actions, **7 device layouts**). *Evidence: improvements CLAIMED (preprint w/ MobiCom acceptance); the failure mode itself is corroborated across the cross-domain literature — and independently by our own ADR-150 data (81.63% in-domain vs ~11.6% leakage-free cross-subject torso-PCK).*
|
||||
|
||||
**(F2) An external model named "WiFlow" claims 97.25% PCK@20 with 2.23M params and ships everything.**
|
||||
arXiv [2602.08661](https://arxiv.org/abs/2602.08661) (Apr 2026) — spatio-temporal-decoupled CSI pose, 97.25% PCK@20 / 99.48% PCK@50 / 0.007 m MPJPE, 2.23M parameters (~2.2 MB int8). Code, pretrained weights, and a 360k-sample CSI-pose dataset are public under Apache-2.0 ([repo](https://github.com/DY2434/WiFlow-WiFi-Pose-Estimation-with-Spatio-Temporal-Decoupling), Kaggle dataset). *Evidence: artifact availability MEASURED (verified by direct repo inspection); PCK numbers CLAIMED (5-subject, in-domain, self-collected dataset; hardware unspecified; 15 keypoints vs our 17).* ⚠️ **Name collision:** this is unrelated to RuView's internal WiFlow model. In all RuView docs the external model is referred to as **WiFlow-STD (DY2434)**.
|
||||
|
||||
**(F3) For CSI foundation encoders, data scale — not model capacity — is the bottleneck, and the tokenization recipe is now known.**
|
||||
UNSW's MAE pretraining study (arXiv [2511.18792](https://arxiv.org/abs/2511.18792), Nov 2025) — the largest heterogeneous CSI pretraining run to date (1,320,892 samples, 14 public datasets incl. MM-Fi, Widar 3.0, Person-in-WiFi 3D; 4 devices; 2.4/5/6 GHz; 20–160 MHz) — reports zero-shot cross-domain gains of 2.2–15.7% over supervised baselines, with unseen-domain performance scaling **log-linearly with pretraining data, unsaturated at 1.3M samples**, while ViT-Base adds only 0.4–0.9% over ViT-Small. Optimal recipe: **80% masking ratio, small (30,3) patches** (+4.7% over (40,5) by preserving fine temporal dynamics). *Evidence: MEASURED within-study (ablations verified in body text) but preprint; downstream tasks are classification, NOT pose — pose transfer is a hypothesis. Independently corroborates ADR-150's finding that capacity hurts cross-subject.*
|
||||
|
||||
**(F4) Hardware/standards: 802.11bf is finished; Espressif ships official sensing; Wi-Fi 6 AP CSI is reachable.**
|
||||
- **IEEE 802.11bf-2025** published **2025-09-26** (verified against the IEEE SA record) — sensing standardization is complete for both sub-7 GHz and >45 GHz, with formal sensing setup/feedback procedures. No ESP32 silicon implements it yet. *Evidence: MEASURED (standards-body record).*
|
||||
- **Espressif `esp_wifi_sensing`** (Apache-2.0, v0.1.x, ESP Component Registry): official CSI presence/motion FSM; esp-csi actively maintained (commit 2026-04-22, verified), CSI confirmed across ESP32/S2/C3/S3/C5/C6/C61. *Evidence: MEASURED (vendor pages + commit log).* ⚠️ A stronger "drop-in compatible with RuView nodes" claim was **REFUTED 0-3** — WiFi-6 parts use a different CSI acquisition config struct.
|
||||
- **ZTECSITool** (arXiv [2506.16957](https://arxiv.org/abs/2506.16957), [code](https://github.com/WiFiZTE2025/ZTE_WiFi_Sensing)): CSI from commercial Wi-Fi 6 APs at up to 160 MHz / 512 subcarriers (~5–10× ESP32 subcarrier count; the gain is aperture, not per-Hz granularity). Firmware is gated behind a ZTE serial-number approval. *Evidence: capability CLAIMED by the vendor-authored tool paper; code artifact MEASURED.*
|
||||
|
||||
**(F5) Nothing in 2025–2026 does full DensePose UV regression from commodity WiFi.** Keypoint pose remains the field's frontier. Three "wireless foundation model" papers were screened out by full-text inspection (HeterCSI = simulated cellular channels only; the NeurIPS-2025 FMCW pilot = mmWave radar, presence-only; arXiv 2509.15258 = survey, no artifacts). *Evidence: MEASURED (absence verified by full-text inspection of the candidates that surfaced; absence of evidence across the whole literature is necessarily weaker).*
|
||||
|
||||
### 1.2 What this means for the ADR-151 calibration system
|
||||
|
||||
ADR-151's enrollment protocol captures guided human anchors but does **not** record or condition on transceiver geometry. F1 says that omission is precisely the thing that makes camera-supervised (and, plausibly, anchor-supervised) heads layout-brittle. ADR-151's per-room thesis ("teach the room before you teach the model") is *strengthened* by F1 — PerceptAlign is independent evidence that layout must be modeled explicitly — and the fix composes naturally with our Stage-2 enrollment.
|
||||
|
||||
ADR-150's masked-CSI-encoder design is *validated* by F3, which also hands us the hyperparameters and the priority call: **collect/aggregate more heterogeneous CSI before scaling the encoder.**
|
||||
|
||||
## 2. Decision
|
||||
|
||||
Adopt four changes, ordered by effort-vs-gain:
|
||||
|
||||
### 2.1 Geometry-condition the calibration system (extends ADR-151 Stage 2) — ACCEPTED
|
||||
|
||||
1. **Record transceiver geometry at enrollment.** `EnrollmentProtocol` gains an optional `NodeGeometry` record per node (position estimate, antenna orientation, inter-node distances where known). Stored alongside the room baseline in the bank; schema-versioned so existing banks remain readable.
|
||||
2. **Fuse geometry embeddings into specialist training.** Where a specialist head consumes the (future, ADR-150) backbone embedding, concatenate a small learned embedding of `NodeGeometry` — the PerceptAlign mechanism, transplanted to our per-room banks. Statistical specialists (current) ignore it; LoRA heads (ADR-151 P6) consume it.
|
||||
3. **Adopt the two-checkerboard alignment for the camera-supervised path (ADR-079).** When MediaPipe supervision is used, calibrate camera↔WiFi into one shared 3D frame before regression (<5 min, two checkerboards, a few photos). This is the direct defense against F1 for our 92.9%-PCK@20 pipeline.
|
||||
4. **Evaluate on the PerceptAlign cross-domain dataset** (21 subjects / 7 layouts) as the MERIDIAN cross-layout benchmark — *gated on confirming its license and downloadability* (open question; repo per paper: github.com/Trymore-lab/PerceptAlign).
|
||||
|
||||
### 2.2 Benchmark against WiFlow-STD (DY2434) — ACCEPTED
|
||||
|
||||
Pull the Apache-2.0 weights + 360k-sample dataset; run three measurements: (a) their model on their data (reproduce 97.25% claim), (b) their model fine-tuned on our ESP32 17-keypoint eval set, (c) our internal WiFlow on their dataset (15-keypoint subset mapping). Until (a)–(c) are measured, **no RuView doc may cite 97.25% as a comparable number** — different dataset, subjects, keypoints.
|
||||
|
||||
### 2.3 Apply the UNSW recipe to the ADR-150 encoder — ACCEPTED (amends ADR-150 §2.3)
|
||||
|
||||
- Pretraining corpus: start from the same 14 public datasets (1.3M samples) + our home/MM-Fi frames; data aggregation takes priority over architecture work.
|
||||
- Tokenization: 80% masking, (30,3)-class small patches; encoder stays ViT-Small-class (~15M params) — F3 and our own DANN/transformer results agree that capacity does not pay.
|
||||
- The published log-linear scaling (unsaturated) sets the expectation: more heterogeneous CSI in, better zero-shot out.
|
||||
|
||||
### 2.4 Hardware watch items — ACCEPTED (no code now)
|
||||
|
||||
- **802.11bf**: track silicon/certification; revisit when any commodity chipset exposes standardized sensing measurements. Our opportunistic CSI extraction remains the mechanism until then.
|
||||
- **esp_wifi_sensing**: benchmark our presence pipeline against the vendor FSM (one afternoon; useful external baseline). Do **not** treat as drop-in (refuted claim).
|
||||
- **ZTECSITool AP**: optional high-resolution anchor node for the ADR-029 multistatic mesh — procurement-gated; only pursue if a 160 MHz anchor materially helps tomography.
|
||||
|
||||
### 2.5 Explicitly NOT adopted
|
||||
|
||||
- No pivot toward "wireless foundation model" papers that don't ship WiFi-CSI artifacts (HeterCSI, FMCW pilot, surveys).
|
||||
- No DensePose-UV work item: the field has not demonstrated UV regression from commodity WiFi; keypoints remain our supervised target (F5).
|
||||
|
||||
## 3. Consequences
|
||||
|
||||
**Positive:** the calibration system gains the one mechanism (geometry conditioning) the 2026 literature identifies as the difference between layout-brittle and layout-robust supervised WiFi pose; ADR-150 gets a measured training recipe instead of a guessed one; we acquire two external benchmarks (WiFlow-STD, PerceptAlign dataset) to keep our claims honest.
|
||||
|
||||
**Negative / risks:** geometry records add schema surface to banks (mitigated: optional + versioned); every adopted number is preprint-grade until our own benchmark runs land (mitigated by §2.2's no-citation rule); PerceptAlign dataset license is unconfirmed (gated); name collision risk in docs (mitigated: "WiFlow-STD (DY2434)" naming rule).
|
||||
|
||||
**Re-check by 2026-12:** 802.11bf silicon, esp_wifi_sensing maturity (v0.1.x today), and the preprint field (newest source Apr 2026).
|
||||
|
||||
## 4. Open questions (carried from the research run)
|
||||
|
||||
1. Does WiFlow-STD retain accuracy when fine-tuned on ESP32-S3/C6 CSI (fewer subcarriers, lower SNR), scored on our 17-keypoint set? (§2.2 answers this.)
|
||||
2. Is the PerceptAlign dataset downloadable under a usable license, and does the two-checkerboard procedure work with ESP32 transceiver geometry? (§2.1.4 gate.)
|
||||
3. Will esp_wifi_sensing evolve toward 802.11bf compliance, replacing opportunistic CSI extraction?
|
||||
|
||||
## 5. Source register (evidence-graded)
|
||||
|
||||
| Source | Type | Used for | Grade |
|
||||
|---|---|---|---|
|
||||
| arXiv 2601.12252 (PerceptAlign, MobiCom'26) | preprint+acceptance | F1, §2.1 | CLAIMED numbers; failure mode corroborated |
|
||||
| arXiv 2602.08661 + DY2434 repo (WiFlow-STD) | preprint + code | F2, §2.2 | numbers CLAIMED; artifacts MEASURED |
|
||||
| arXiv 2511.18792 (UNSW MAE) | preprint | F3, §2.3 | ablations MEASURED in-study; pose transfer hypothesis |
|
||||
| IEEE SA 802.11bf-2025 record | standards body | F4, §2.4 | MEASURED |
|
||||
| Espressif component registry + esp-csi repo | vendor | F4, §2.4 | MEASURED; "drop-in" REFUTED 0-3 |
|
||||
| arXiv 2506.16957 + ZTE repo (ZTECSITool) | vendor preprint + code | F4, §2.4 | capability CLAIMED; code MEASURED |
|
||||
| arXiv 2601.18200 (HeterCSI), OpenReview LMufK3vzE5 (FMCW pilot), arXiv 2509.15258 (survey) | preprints | F5, §2.5 (screened out) | MEASURED (full-text inspection) |
|
||||
@@ -79,6 +79,10 @@ Statuses: **Proposed** (under discussion), **Accepted** (approved and/or impleme
|
||||
| [ADR-023](ADR-023-trained-densepose-model-ruvector-pipeline.md) | Trained DensePose Model with RuVector Pipeline | Proposed |
|
||||
| [ADR-024](ADR-024-contrastive-csi-embedding-model.md) | Project AETHER: Contrastive CSI Embeddings | Required |
|
||||
| [ADR-027](ADR-027-cross-environment-domain-generalization.md) | Project MERIDIAN: Cross-Environment Generalization | Proposed |
|
||||
| [ADR-149](ADR-149-public-community-leaderboard-huggingface.md) | AetherArena: public spatial-intelligence benchmark on Hugging Face | Proposed |
|
||||
| [ADR-150](ADR-150-rf-foundation-encoder.md) | RF Foundation Encoder: pose-preserving, subject/room/device-invariant CSI embedding | Proposed |
|
||||
| [ADR-151](ADR-151-room-calibration-specialist-training.md) | Per-Room Calibration & Specialized Model Training (room-first → bank of small ruVector specialists) | Proposed |
|
||||
| [ADR-152](ADR-152-wifi-pose-sota-2026-intake.md) | WiFi-Pose SOTA 2026 Intake: geometry-conditioned calibration, external benchmarks, foundation-encoder recipe | Proposed |
|
||||
|
||||
### Platform and UI
|
||||
|
||||
@@ -93,6 +97,8 @@ Statuses: **Proposed** (under discussion), **Accepted** (approved and/or impleme
|
||||
| [ADR-036](ADR-036-rvf-training-pipeline-ui.md) | Training Pipeline UI Integration | Proposed |
|
||||
| [ADR-043](ADR-043-sensing-server-ui-api-completion.md) | Sensing Server UI API Completion (14 endpoints) | Accepted |
|
||||
| [ADR-115](ADR-115-home-assistant-integration.md) | Home Assistant integration via MQTT auto-discovery + Matter bridge (HA-DISCO + HA-FABRIC + HA-MIND) | Accepted (MQTT track) / Proposed (Matter SDK P8b) |
|
||||
| [ADR-147](ADR-147-adam-mode-light-theme.md) | adam-mode — light theme toggle for the three.js realtime demo | Proposed |
|
||||
| [ADR-148](ADR-148-yoga-mode-pose-system.md) | yoga-mode — yoga pose detection, classification, and scoring for the three.js realtime demo | Proposed |
|
||||
|
||||
### Architecture and infrastructure
|
||||
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
# Per-Room Calibration — Integration Overview (for `cognitum-one/v0-appliance`)
|
||||
|
||||
**Audience:** integrators wiring the RuView per-room calibration system (ADR-151) into the
|
||||
Cognitum V0 appliance (`cognitum-v0`, Pi 5 + Hailo). This document is the contract +
|
||||
deployment spec: data formats, API surface, crate API, and the appliance integration plan.
|
||||
|
||||
**Source of truth:** crate `v2/crates/wifi-densepose-calibration` + CLI `v2/crates/wifi-densepose-cli`
|
||||
(`calibrate`, `calibrate-serve`, `enroll`, `train-room`, `room-status`, `room-watch`) on this PR's branch.
|
||||
|
||||
---
|
||||
|
||||
## 1. What it is
|
||||
|
||||
"Teach the room before you teach the model." A local-first pipeline that turns a few minutes of
|
||||
clean human anchors — layered on an empty-room baseline — into a versioned **bank of small,
|
||||
room-calibrated specialists** for presence, posture, breathing, heartbeat, restlessness, and anomaly.
|
||||
|
||||
```
|
||||
baseline (ADR-135) → enroll (anchors + quality gate) → extract (features) → train (specialist bank) → runtime (mixture + veto)
|
||||
environmental stand/sit/lie/breathe/move periodicity/variance 6 small models RoomState per window
|
||||
fingerprint (re-prompts bad captures) + STALE invalidation (+ multistatic fusion)
|
||||
```
|
||||
|
||||
**Design invariants (carry these into the appliance):**
|
||||
- **Specialisation over scale** — six tiny models (threshold / nearest-prototype / autocorrelation), not one big model. They run in microseconds on a Pi CPU; **they do not need the Hailo HAT**.
|
||||
- **Local-first** — baselines + per-room banks stay on the device. Cross-room sharing is *model deltas* (federation, ADR-105), **never raw CSI**.
|
||||
- **Honest degradation** — baseline drift marks a bank `STALE`; a physically-implausible window is vetoed rather than emitting a hallucinated reading.
|
||||
|
||||
---
|
||||
|
||||
## 2. Tiering on the Pi 5 + Hailo (what runs where)
|
||||
|
||||
| Tier | Runs on | What | Status |
|
||||
|------|---------|------|--------|
|
||||
| **CSI source** | ESP32-S3/C6 nodes (`edge_tier=0` raw CSI) | `0xC5110001` frames over UDP | shipping (v0.7.1-esp32) |
|
||||
| **Calibration service** | **Pi 5 CPU** (aarch64) | this crate: baseline/enroll/train/runtime + HTTP API | **this PR** |
|
||||
| **Shared backbone (optional)** | **Hailo HAT (HAILO10H)** | ADR-150 RF Foundation Encoder + neural pose head as HEF | future (ADR-150) |
|
||||
|
||||
> The appliance's WiFi (`wlan0`) is `managed` with no nexmon — **the Pi is a CSI *processor*, not a CSI radio.** CSI arrives from the ESP32 nodes (the existing `ruview-vitals-worker:50054` already receives it). Calibration *consumes* that stream; it does not sense directly.
|
||||
|
||||
---
|
||||
|
||||
## 3. Data contracts (the integration surface)
|
||||
|
||||
### 3.1 CSI ingest — ESP32 `0xC5110001` (UDP, little-endian)
|
||||
|
||||
```
|
||||
Offset Size Field
|
||||
0 4 magic = 0xC511_0001 (LE u32)
|
||||
4 1 node_id (u8) ← group multistatic nodes by this
|
||||
5 1 n_antennas (u8)
|
||||
6 1 n_subcarriers (u8) ← 52/64 (HT20), 114 (HT40), 242 (HE20)
|
||||
7 1 reserved
|
||||
8 2 freq_mhz (LE u16)
|
||||
10 4 sequence (LE u32)
|
||||
14 1 rssi (i8)
|
||||
15 1 noise_floor (i8)
|
||||
16 4 reserved
|
||||
20 2·n_antennas·n_subcarriers IQ pairs: i (i8), q (i8)
|
||||
```
|
||||
Parser reference: `wifi-densepose-cli/src/calibrate.rs::parse_csi_packet`. The appliance can reuse the
|
||||
ESP32 stream the vitals worker already receives, or tee it to the calibration UDP port.
|
||||
|
||||
### 3.2 Baseline (ADR-135) — binary, magic `0xCA1B_0001`
|
||||
|
||||
```
|
||||
Header (16 B LE): magic(4)=0xCA1B0001, version(1)=1, tier(1) {0=HT20,1=HT40,2=HE20,3=HE40},
|
||||
reserved(2), captured_at_unix_s(8, i64)
|
||||
Body: frame_count(8,u64), num_subcarriers(4,u32),
|
||||
per subcarrier: amp_mean(f32), amp_variance(f32), phase_mean(f32), phase_dispersion(f32)
|
||||
```
|
||||
Produced by `calibrate` / `calibrate-serve`; `BaselineCalibration::{to_bytes,from_bytes}`. A baseline's
|
||||
UUID (`calibration_uuid()`) is the `baseline_id` referenced by enrollments and banks for STALE checks.
|
||||
|
||||
### 3.3 Enrollment output — JSON (`enroll` → `train-room`)
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"room_id": "living-room",
|
||||
"baseline_id": "<uuid>",
|
||||
"fs_hz": 15.0,
|
||||
"anchors": [
|
||||
{ "room_id": "living-room", "label": "stand_still",
|
||||
"features": { "mean": f32, "variance": f32, "motion": f32,
|
||||
"breathing_score": f32, "breathing_hz": f32,
|
||||
"heart_score": f32, "heart_hz": f32 } }
|
||||
],
|
||||
"session": { "room_id": "...", "baseline_id": "...", "events": [ /* event-sourced audit log */ ] }
|
||||
}
|
||||
```
|
||||
Anchor labels (fixed sequence, **JSON wire = snake_case**, test-enforced): `empty, stand_still, sit, lie_down, breathe_slow, breathe_normal, small_move, sleep_posture`.
|
||||
|
||||
### 3.4 Specialist bank — JSON (`train-room` → `room-watch` / runtime)
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"room_id": "living-room",
|
||||
"baseline_id": "<uuid>", // drift vs current → STALE
|
||||
"trained_at_unix_s": 0,
|
||||
"anchor_count": 6,
|
||||
"presence": { "threshold": f32, "occupied_var": f32 } | null,
|
||||
"posture": { "prototypes": [ ["Standing", [f32;5]], ... ] } | null,
|
||||
"breathing": { "min_score": f32 },
|
||||
"heartbeat": { "min_score": f32 },
|
||||
"restlessness": { "calm_motion": f32, "active_motion": f32 } | null,
|
||||
"anomaly": { "prototypes": [ [f32;5], ... ], "scale": f32 } | null
|
||||
}
|
||||
```
|
||||
`SpecialistBank::{to_json,from_json}`. A *partial* bank is valid (missing-anchor specialists are `null`).
|
||||
|
||||
### 3.5 Runtime output — `RoomState` JSON (per window)
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"presence": { "kind":"Presence", "value":0|1, "confidence":f32, "label":"present|absent" } | null,
|
||||
"posture": { "kind":"Posture", "value":f32, "confidence":f32, "label":"standing|sitting|lying" } | null,
|
||||
"breathing": { "kind":"Breathing", "value": <BPM>, "confidence":f32, "label":null } | null,
|
||||
"heartbeat": { "kind":"Heartbeat", "value": <BPM>, "confidence":f32, "label":null } | null,
|
||||
"restlessness": { "kind":"Restlessness", "value": 0.0..1.0, "confidence":f32 } | null,
|
||||
"anomaly": { "kind":"Anomaly", "value": 0.0..1.0, "confidence":f32, "label":"normal|anomalous" } | null,
|
||||
"vetoed": bool, // anomaly veto fired → vitals/posture suppressed
|
||||
"stale": bool // bank trained against a different baseline
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. HTTP API — `calibrate-serve` (CORS-enabled; this is what a UI/appliance drives)
|
||||
|
||||
| Method | Path | Body / returns |
|
||||
|--------|------|----------------|
|
||||
| GET | `/api/v1/calibration/health` | `{ udp_port, frames_seen, last_frame_age_ms, streaming, default_tier, output_dir, session_active }` |
|
||||
| POST | `/api/v1/calibration/start` | `{ tier?, duration_s?, room_id?, min_frames? }` → `202` session snapshot |
|
||||
| GET | `/api/v1/calibration/status` | live `{ state, frames_recorded, target_frames, progress, z_median, eta_s, ... }` |
|
||||
| POST | `/api/v1/calibration/stop` | finalize early → result summary |
|
||||
| GET | `/api/v1/calibration/result` | last finalized baseline summary |
|
||||
| GET | `/api/v1/calibration/baselines` | list persisted `.bin` baselines |
|
||||
| GET | `/api/v1/room/state?bank=<name>` | **live RoomState** (mixture-of-specialists over the CSI window; bank resolved as a sanitized name under `output_dir`) |
|
||||
| POST | `/api/v1/room/train` | `{ room_id, baseline_id, anchors[]? }` → train + persist a specialist bank as `<output_dir>/<room_id>.json` (anchors[] optional if enrolled via `/enroll/anchor`; read back via `/room/state?bank=<room_id>`) |
|
||||
| POST | `/api/v1/enroll/anchor` | `{ room_id, baseline, label, duration_s? }` → capture one guided anchor against a baseline (blocks for the capture); returns the gate verdict + progress |
|
||||
| GET | `/api/v1/enroll/status?room=<id>` | enrollment progress (accepted anchors, next, complete) |
|
||||
|
||||
A single background task owns the UDP socket + recorder (handlers talk to it over an mpsc channel +
|
||||
shared status snapshot), so the API is non-blocking. **The full pipeline is now drivable over HTTP** — baseline (`start`/`stop`) → `enroll/anchor` (×8) → `room/train` → `room/state` — so the appliance UI needs no CLI. (The CLI `enroll`/`train-room`/`room-watch` remain for scripted/headless use.)
|
||||
|
||||
---
|
||||
|
||||
## 5. Public crate API (`wifi-densepose-calibration`)
|
||||
|
||||
```rust
|
||||
// Stage 2 — enrollment
|
||||
anchor::{AnchorLabel, Anchor, AnchorQuality, EnrollmentEvent, EnrollmentSession, Posture}
|
||||
enrollment::{AnchorQualityGate, AnchorRecorder}
|
||||
// Stage 3 — features
|
||||
extract::{Features, AnchorFeature, autocorr_dominant}
|
||||
// Stage 4 — specialists + bank
|
||||
specialist::{Specialist, SpecialistKind, SpecialistReading,
|
||||
PresenceSpecialist, PostureSpecialist, BreathingSpecialist,
|
||||
HeartbeatSpecialist, RestlessnessSpecialist, AnomalySpecialist}
|
||||
bank::SpecialistBank
|
||||
// Stage 5 — runtime
|
||||
runtime::{MixtureOfSpecialists, RoomState}
|
||||
multistatic::MultiNodeMixture // fuse co-located nodes (ADR-029)
|
||||
```
|
||||
Pure Rust; deps are `wifi-densepose-core` + `wifi-densepose-signal` (default-features off) + serde/uuid.
|
||||
**No GPU / no system BLAS** in the calibration path → builds cleanly on aarch64.
|
||||
|
||||
---
|
||||
|
||||
## 6. Appliance integration plan (`cognitum-one/v0-appliance`)
|
||||
|
||||
Verified on `cognitum-v0`: aarch64, `cargo 1.96.0`, Hailo `HAILO10H`, `ruview-vitals-worker:50054`.
|
||||
|
||||
**Step 1 — vendor / depend on the crate.** Add `wifi-densepose-calibration` (path or published crate)
|
||||
to the appliance workspace. It builds natively on aarch64 — no BLAS/GPU, **and no ONNX/OpenSSL**:
|
||||
the CLI's `mat`→`nn`→`ort`(ONNX)→`openssl-sys` chain is now feature-gated out of the calibration build.
|
||||
|
||||
```bash
|
||||
# Pi/appliance calibration binary — cross-compiles clean (no ort/openssl):
|
||||
cargo build -p wifi-densepose-cli --no-default-features --release
|
||||
# (omit `--no-default-features` only if you also need the MAT subcommands)
|
||||
```
|
||||
Verified: `cargo tree -p wifi-densepose-cli --no-default-features` shows **0** `ort`/`openssl-sys` deps;
|
||||
`cross test --target aarch64-unknown-linux-gnu` passes the calibration suite under qemu.
|
||||
|
||||
**Step 2 — wire the CSI source.** Two options:
|
||||
- (a) Tee the ESP32 UDP stream the vitals worker already receives into the calibration ingest, or
|
||||
- (b) point ESP32 nodes (`edge_tier=0`) at the appliance's calibration UDP port directly.
|
||||
Reuse `parse_csi_packet` (or the rvCSI `CsiFrame` schema if you normalise upstream).
|
||||
|
||||
**Step 3 — run the calibration service.** Either embed the crate (call `CalibrationRecorder` /
|
||||
`MixtureOfSpecialists` in-process from a worker like `ruview-vitals-worker`), or run the
|
||||
`calibrate-serve` binary as a sidecar (systemd unit, bind `127.0.0.1` + reverse-proxy through the
|
||||
appliance gateway on `:9000`). Persist baselines/banks under the appliance data dir, keyed by `room_id`.
|
||||
|
||||
**Step 4 — expose to the dashboard.** Surface the `/api/v1/calibration/*` endpoints (and add
|
||||
`enroll`/`train`/`room-state` endpoints — small additive work) behind the appliance's bearer-token
|
||||
auth + the existing `Seeds`/`Edge` nav. `RoomState` (§3.5) is the live readout payload.
|
||||
|
||||
**Step 5 — (optional) Hailo backbone tier.** Compile the ADR-150 RF Foundation Encoder + neural pose
|
||||
head to Hailo HEF, serve via `ruvector-hailo-worker:50051`; the small specialists become heads over its
|
||||
embedding. This is the ADR-150 follow-on — *not required* for the calibration service to run.
|
||||
|
||||
**Privacy / security:** keep baselines + banks local; if federating across appliances (ADR-105),
|
||||
exchange bank/model deltas, never raw CSI. Hardening already in place:
|
||||
- **`--token <T>`** (or `CALIBRATE_TOKEN` env) requires `Authorization: Bearer <T>` on every route; the
|
||||
server warns loudly if bound to a non-loopback address without a token.
|
||||
- **`room_id` is sanitized** to `[A-Za-z0-9_-]` (≤64 chars) before it touches the baseline write path —
|
||||
no `../` / absolute-path traversal.
|
||||
- CORS is permissive for dev — in production bind to loopback and reverse-proxy through the appliance
|
||||
gateway (which already enforces bearer auth).
|
||||
|
||||
---
|
||||
|
||||
## 7. Status & validation
|
||||
|
||||
- **Implemented:** all 5 stages + multistatic fusion; CLI + Stage-1 HTTP API (auth + path-traversal hardened). **55 tests** (35 calibration unit + 1 full-loop integration + 19 CLI), all passing under qemu-aarch64.
|
||||
|
||||
**Precise validation matrix (don't overstate this — no clean full calibration has run on-target yet):**
|
||||
|
||||
| Stage | Pi-5 (real nexmon→`0xC5110001`, 6,813 frames) | ESP32-S3 (COM8, `edge_tier=0`) | qemu / unit / integration |
|
||||
|---|---|---|---|
|
||||
| baseline capture + HTTP API + **auth gate** | ✅ | ✅ (120-frame) | full-loop ✅ |
|
||||
| **clean** empty-room baseline | ❌ `motion_flagged` (artifact) | ❌ (occupied) | full-loop ✅ (synthetic, zero motion flags) |
|
||||
| enroll → train-room | ❌ | ❌ (needs operator poses) | full-loop ✅ (8/8 anchors, 6 specialists, JSON round-trip) |
|
||||
| runtime infer | ❌ on-target | ◐ single-node breathing ~16–31 BPM via the **stateless** head (not a trained bank) + node-id fusion | full-loop ✅ (trained bank: 18±2 BPM positive, absent negative, foreign-baseline STALE) |
|
||||
|
||||
The complete `baseline → enroll → train-room → infer` loop is now **proven in-process** on deterministic synthetic CSI (`wifi-densepose-calibration/tests/full_loop.rs` — drives the CLI's exact stage order through the public API, seed-robust across 5 seeds, runs with and without default features). Capture + API + auth are proven on real CSI (both boxes). What remains is strictly the **on-target** run: real CSI, a physically empty room for baseline, and an operator performing the 8 guided anchors — that hardware session is the last open item.
|
||||
|
||||
- **Known follow-ups (appliance backlog):** `--source-format adr018v6` to drive calibration from the Pi's own nexmon (no ESP32/transcoder); the on-target clean-room enroll→train→infer session (above); phase-based (vs mean-amplitude) breathing carrier; RVF/HNSW persistence (currently JSON); enroll/train HTTP endpoints (live `/room/state` already added); ADR-150 Hailo backbone; true 2-node multistatic; ADR-105 federation.
|
||||
- **Behavioral findings from the full-loop test — all four FIXED pre-hardware-session:** (1) *z-band squeeze* — anchor motion is now measured from frame-to-frame deltas of the deviation series (`|Δz| > 0.5 ∨ |Δφ| > π/6`), not from the absolute `motion_flagged` (which conflated presence strength with motion); a strongly-reflecting still person (z = 3.0, every frame flagged by the old heuristic) now enrolls — regression-guarded in the full-loop test's `StandStill` anchor and `enrollment::tests`. (2) *Variance-only presence* — `PresenceSpecialist` gained a mean-shift channel (|mean − empty mean| vs a trained threshold); a motionless person is detected via the mean even at empty-level variance — regression-guarded in the full-loop motionless-person case; old persisted banks deserialize with the channel inert (variance-only behavior preserved). (3) *Ungated hz embedding* — `Features::embedding()` zeroes `breathing_hz`/`heart_hz` below `EMBED_MIN_SCORE` (0.25), keeping noise-window random frequencies out of the prototype space. (4) *Heart-band leakage* (found while fixing 3): a strong breathing rhythm's autocorrelation leaks into the HR band as a high-score lag-floor edge value (e.g. score 0.67 at 3.33 Hz from a pure 0.30 Hz breath); `autocorr_dominant` now requires the winning lag to be an interior local maximum, rejecting band-edge leakage while preserving true in-band peaks.
|
||||
|
||||
**Reference:** ADR-151 (`docs/adr/ADR-151-room-calibration-specialist-training.md`), ADR-135 (baseline),
|
||||
ADR-029 (multistatic), ADR-150 (RF Foundation Encoder), ADR-105 (federation), ADR-147 (OccWorld/Hailo).
|
||||
@@ -0,0 +1,165 @@
|
||||
# RuView System Review — Capability Audit (Beyond-SOTA Series, Doc 00)
|
||||
|
||||
**Date:** 2026-06-09
|
||||
**Scope:** The RuView product surface (ADR-031) and the 38-crate Rust workspace under `v2/crates/` that implements it, plus the ADR corpus (`docs/adr/`, 150 numbered ADRs) and the prior research corpus (`docs/research/sota-2026-05-22/`).
|
||||
**Method:** Direct reads of `lib.rs`/`mod.rs` and key ADRs; static test counts via `grep -r '#[test]'` / `#[tokio::test]` per crate (counts are *static occurrences in source*, not CI pass counts). No metrics in this document are estimated — everything cited was read or measured in the working tree.
|
||||
|
||||
---
|
||||
|
||||
## 1. Executive Summary — What RuView IS Today
|
||||
|
||||
RuView is **not a crate**. Per ADR-136 §2.1 (`docs/adr/ADR-136-ruview-streaming-engine-frame-contracts.md`), RuView is the sensing-first *product surface and brand* (ADR-031, status: Proposed) layered on the existing `wifi-densepose-*` / `homecore*` / `cog-*` workspace. ADR-136 explicitly **rejects** a `ruview_*` crate rename and pins a normative ten-role mapping (ingest / signal / fusion / world / models / privacy / store / api / eval / observe) onto the existing crates.
|
||||
|
||||
What concretely exists:
|
||||
|
||||
1. **A deep, heavily-tested signal-processing layer.** `wifi-densepose-signal` contains 473 static `#[test]` occurrences, including a 22-file `ruvsense/` bounded context (`v2/crates/wifi-densepose-signal/src/ruvsense/`) implementing the ADR-029 six-stage multistatic pipeline plus ADR-030/032a/134/135/137/138/142/143 extensions (~14,000 lines, 330 in-module tests measured by per-file grep).
|
||||
2. **A trust-traceable composition root.** `wifi-densepose-engine` (`src/lib.rs`, 752 lines, 11 tests) wires fusion quality (ADR-137), array coordination (ADR-138), evolution change-points (ADR-142), RF-SLAM anchors (ADR-143), the WorldGraph (ADR-139), and the BFLD privacy control plane (ADR-141) into one `StreamingEngine::process_cycle` (`lib.rs:285`) that emits a `TrustedOutput` (`lib.rs:80`) carrying evidence + model version + calibration version + privacy decision + a BLAKE3 witness (`lib.rs:437`).
|
||||
3. **A privacy layer with structural invariants.** `wifi-densepose-bfld` (20 modules, 369 tests) implements ADR-118–123/141: raw BFI never exits the node (I1), identity embeddings are RAM-only (I2), cross-site identity correlation is cryptographically impossible (I3) — stated at `wifi-densepose-bfld/src/lib.rs:7-11`.
|
||||
4. **A Home-Assistant-class world/state layer.** `homecore` + 9 sibling crates (state machine, event bus, plugins, automation, REST/WS API, recorder, HAP bridge, assist) — explicitly a "P1 scaffold" per `homecore/src/lib.rs:7` with deferred items listed at `lib.rs:24-31`.
|
||||
5. **A drone-swarm extension.** `ruview-swarm` (17 modules, ~9,000 lines in subdirectories, 115 + 19 async tests), ADR-148 self-reports ~98% complete with the remaining 15% of M3 gated on real ESP32-S3 hardware (`ADR-148:940-953`).
|
||||
6. **A large prior research corpus.** The 2026-05-22 autonomous SOTA loop: 41 ticks, 19 research threads (R1–R20), 22 numpy reference implementations, 7 ADRs, and a 6-tier production roadmap (`docs/research/sota-2026-05-22/00-summary.md`, `PRODUCTION-ROADMAP.md`).
|
||||
|
||||
The critical caveat, stated by the project itself: the ADR-136–146 series is *"a skeleton and nervous system, not a shipping product… Most of the series is not yet wired into the live 20 Hz pipeline"* (ADR-136 §8). The engine crate's own docs confirm what is absent: *"the live 20 Hz I/O loop (sensing-server), UWB hardware (ADR-144), and model training (ADR-146)"* (`wifi-densepose-engine/src/lib.rs:27-29`).
|
||||
|
||||
---
|
||||
|
||||
## 2. Capability Matrix — Pipeline Role → Crates → Maturity
|
||||
|
||||
Role mapping is normative per ADR-136 §2.1; maturity is this review's judgment from code + ADR status. Test counts: static `#[test]` + `#[tokio::test]` greps (2026-06-09).
|
||||
|
||||
| Role | Crate(s) | Key modules | Tests (sync+async) | Maturity | Evidence |
|
||||
|---|---|---|---|---|---|
|
||||
| **ingest** | `wifi-densepose-sensing-server`, `wifi-densepose-hardware`, `wifi-densepose-wifiscan` | `csi.rs`, `multistatic_bridge.rs`, `tracker_bridge.rs`, ESP32 TDM | 557+14, 137, 150 | **Production** (hardware-validated per ADR-028/039) | `sensing-server/src/` has 30+ modules incl. MQTT, Matter, RVF pipeline |
|
||||
| **signal** | `wifi-densepose-signal` (incl. `ruvsense/`) | 6-stage pipeline (`ruvsense/mod.rs:9-23`), `cir.rs`, `calibration.rs`, `hampel.rs`, `fresnel.rs`, `phase_sanitizer.rs` | 473 | **Production** (unit level); live multistatic wiring **beta** | §3 below; ADR-014 Accepted, ADR-029 Proposed |
|
||||
| **fusion** | `ruvsense/multistatic.rs`, `ruvsense/fusion_quality.rs`, `wifi-densepose-ruvector/src/viewpoint/` | `MultistaticFuser`, `QualityScore`, `CrossViewpointAttention`, GDI/Cramér-Rao (`viewpoint/geometry.rs`) | 20 (multistatic.rs), 3 (fusion_quality.rs), 136 (ruvector crate) | **Beta** — tested building blocks, composed only in `wifi-densepose-engine` tests | `viewpoint/mod.rs:1-30`; engine `lib.rs:317-319` |
|
||||
| **world** | `homecore`, `wifi-densepose-worldgraph`, `wifi-densepose-geo`, `wifi-densepose-worldmodel` | `StateMachine`, `EventBus`, `WorldGraph` (rooms/sensors/person-tracks/semantic states), ENU geo registration | 9+11, 7, 16+1, 12+1 | **Beta** — homecore is explicit "P1 scaffold"; persistence/service dispatch deferred to P2 | `homecore/src/lib.rs:7, 24-31`; ADR-127 Proposed |
|
||||
| **models** | `cog-pose-estimation`, `cog-person-count`, `wifi-densepose-nn`, `wifi-densepose-train`, `wifi-densepose-occworld-candle` | ONNX/Candle inference, training pipeline, OccWorld bridge | 7, 15, 30+1, 312, 12 | **Experimental** — no trained RF foundation encoder exists; ADR-147 benchmarked OccWorld with **random weights** | `ADR-147-benchmark-proof.md` ("random weights — pre-domain-fine-tuning baseline"); ADR-146/150 Proposed |
|
||||
| **privacy** | `wifi-densepose-bfld` | `privacy_gate.rs`, `privacy_mode.rs` (mode registry + hash-chained attestation), `identity_risk.rs`, `signature_hasher.rs`, `embedding_ring.rs` | 369 | **Beta** — strongest-tested layer, but lib header still says "Status: P1 in progress" (`lib.rs:12`, stale vs 20 implemented modules) | ADR-118–123, 141 all Proposed |
|
||||
| **store** | `homecore-recorder` | trajectory/event recording | 8+12 | **Experimental** | ADR-136 §2.1 |
|
||||
| **api** | `homecore-api`, `homecore-server`, `cog-ha-matter`, `homecore-hap` | REST/WS, HA discovery, Matter, HomeKit | 7+11, 0, 63+1, 15+2 | **Experimental→Beta** (`homecore-server` has zero tests) | ADR-130/125/115 Proposed |
|
||||
| **eval** | `wifi-densepose-train/src/ablation.rs`, `ruview-swarm/src/evals/` | ablation harness (ADR-145), swarm eval suite (ADR-149) | included in 312 / 115 | **Experimental** — ADR-145 self-labels "skeleton/scaffolding, mostly not yet on the live 20 Hz path" | `ablation.rs` exists; ADR-149 (swarm benchmarking) Accepted |
|
||||
| **observe** | `homecore-automation`, `homecore-assist` | automation engine, assistant/Ruflo bridge | 20+14, 3+20 | **Experimental** | ADR-129/133 Proposed |
|
||||
| **(integration root)** | `wifi-densepose-engine` | `StreamingEngine`, `TrustedOutput`, privacy demotion, witness | 11 | **Beta** — the only crate that proves cross-role composition; not on a live I/O path | `engine/src/lib.rs:1-29, 457-751` |
|
||||
| **(swarm)** | `ruview-swarm` | Raft/gossip topology, RRT-APF planning, Candle PPO MARL, CSI sensing payload, failsafe, Ruflo | 115+19 | **Experimental/simulation** — M3 needs real ESP32-S3 hardware | ADR-148:940-953 ("Overall ~98%", M3 85%) |
|
||||
| **(adjacent)** | `nvsim`, `nvsim-server`, `ruv-neural`, `wifi-densepose-wasm-edge`, `wifi-densepose-mat`, `wifi-densepose-vitals` | NV-diamond sim, neural lib, WASM edge, MAT disaster tool, vitals | 50, 0, 364, 643, 165+9, 52 | Mixed — `mat`/`vitals`/`wasm-edge` mature unit-wise | crate listing |
|
||||
|
||||
**Workspace totals (measured):** 3,890 `#[test]` + 121 `#[tokio::test]` static occurrences across `v2/crates/`. (CLAUDE.md's "1,031+ tests" figure refers to an earlier `cargo test --workspace` run count; this review did not execute the suite.)
|
||||
|
||||
External vendored runtimes also present: `vendor/rvcsi` (ADR-095/096 edge RF runtime, own repo), `vendor/ruvector`, `vendor/midstream`, `vendor/sublinear-time-solver`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Signal-Processing Capability Inventory — `ruvsense/`
|
||||
|
||||
Location: `v2/crates/wifi-densepose-signal/src/ruvsense/`. CLAUDE.md says "16 modules"; the directory now contains **22 `.rs` files** (21 modules + `mod.rs`) — the table below is the ground truth. Lines/tests measured per file (2026-06-09).
|
||||
|
||||
| Module | Lines | Tests | ADR | What it does |
|
||||
|---|---:|---:|---|---|
|
||||
| `mod.rs` | 510 | 14 | 029 | Pipeline shell, COCO-17 keypoint constants, `RuvSensePipeline` (concrete fields + `tick()`), re-exports |
|
||||
| `multiband.rs` | 442 | 14 | 029 | Channel-hopping CSI → wideband virtual snapshot per node (`MultiBandCsiFrame`) |
|
||||
| `phase_align.rs` | 460 | 13 | 029 | LO phase-offset estimation via circular mean + `ruvector-solver::NeumannSolver` |
|
||||
| `multistatic.rs` | 957 | 20 | 029 | Attention-weighted N-node fusion → `FusedSensingFrame`; timestamp-spread guards |
|
||||
| `coherence.rs` | 474 | 19 | 029 | Per-subcarrier z-score coherence vs rolling template; `DriftProfile` |
|
||||
| `coherence_gate.rs` | 380 | 17 | 029 | Accept / PredictOnly / Reject / Recalibrate gate decisions |
|
||||
| `pose_tracker.rs` | 1,577 | 38 | 029/026/082 | 17-keypoint Kalman tracker, lifecycle state machine, AETHER re-ID embeddings, skeleton constraints, temporal keypoint attention |
|
||||
| `field_model.rs` | 1,417 | 22 | 030 | SVD room eigenstructure (persistent field model), perturbation extraction |
|
||||
| `tomography.rs` | 751 | 12 | 030 | RF tomography, ISTA L1 voxel solver |
|
||||
| `longitudinal.rs` | 1,020 | 20 | 030 | Welford long-horizon stats, biomechanics drift detection |
|
||||
| `intention.rs` | 511 | 12 | 030 | Pre-movement lead signals (200–500 ms) |
|
||||
| `cross_room.rs` | 626 | 13 | 030 | Environment fingerprinting + room-transition graph |
|
||||
| `gesture.rs` | 579 | 14 | 030 | DTW template-matching gesture classifier |
|
||||
| `adversarial.rs` | 586 | 13 | 030/032 | Physically-impossible-signal detection, multi-link consistency |
|
||||
| `attractor_drift.rs` | 566 | 15 | 032a | Midstream-enhanced attractor drift detection |
|
||||
| `temporal_gesture.rs` | 540 | 15 | 032a | Midstream temporal gesture stream |
|
||||
| `cir.rs` | 1,025 | 10 | 134 | CSI→CIR via ISTA L1 sparse recovery, NeumannSolver warm-start, `Complex32` sub-DFT Φ |
|
||||
| `calibration.rs` | 717 | 8 | 135 | Empty-room baseline (Welford amplitude + von Mises phase), drift-triggered recalibration |
|
||||
| `fusion_quality.rs` | 188 | 3 | 137 | `QualityScore` with `EvidenceRef`s, `ContradictionFlag`s, `CalibrationId`, privacy-demotion predicate |
|
||||
| `array_coordinator.rs` | 343 | 5 | 138 | Clock-quality gating + `DirectionalEvidence` (geometric admission) |
|
||||
| `evolution.rs` | 406 | 7 | 142 | Cross-link change-point detection, Bayesian `TemporalVoxelMap` (privacy-gated) |
|
||||
| `rf_slam.rs` | 301 | 6 | 143 | Persistent reflector discovery → static anchor learning (Wall/Furniture/Mobile classes) |
|
||||
|
||||
Subtotal: ~14,400 lines, 310 tests inside `ruvsense/` alone. The non-ruvsense signal layer adds Hampel filtering, CSI-ratio, phase sanitisation, Fresnel modeling, BVP, spectrograms, subcarrier selection, and hardware normalisation (`signal/src/*.rs`).
|
||||
|
||||
**Cross-viewpoint fusion** (`wifi-densepose-ruvector/src/viewpoint/`, 5 files): scaled dot-product attention with geometric bias (`attention.rs`), Geometric Diversity Index + Cramér-Rao bounds (`geometry.rs`), phase-phasor coherence with hysteresis + clock-quality gate (`coherence.rs`), and the `MultistaticArray` aggregate root (`fusion.rs`). 136 tests crate-wide.
|
||||
|
||||
---
|
||||
|
||||
## 4. The Trust Chain — What Actually Composes Today
|
||||
|
||||
`wifi-densepose-engine/src/lib.rs` is the proof-of-composition. One `process_cycle` (`lib.rs:285-368`):
|
||||
|
||||
1. ADR-138 array coordination (only if every node's geometry is registered, `lib.rs:372-389`)
|
||||
2. ADR-137 `fuse_scored_calibrated` with **per-node calibration epochs** — mismatching `CalibrationId`s raise a contradiction (`lib.rs:304-319`)
|
||||
3. ADR-142 change-point → WorldGraph `Event` node (`lib.rs:393-430`)
|
||||
4. ADR-141 monotonic privacy demotion on any contradiction (`demote_one`, `lib.rs:452-455`)
|
||||
5. ADR-139/140 `SemanticState` with mandatory provenance (evidence ‖ model ‖ calibration ‖ privacy decision) (`lib.rs:336-352`)
|
||||
6. BLAKE3 witness over the trust decision (`witness_of`, `lib.rs:437-448`)
|
||||
|
||||
The 11 engine tests verify exactly the right invariants: full provenance flow (`cycle_carries_full_provenance`, `lib.rs:487`), contradiction→demotion (`lib.rs:517`), determinism (`lib.rs:535`), calibration-mismatch→Restricted+stable-witness (`lib.rs:648`), privacy-mode attestation chain (`lib.rs:741`), and persist→reload round-trip with **no raw RF in the snapshot** (`live_frame_to_reload_same_contents`, `lib.rs:696-736`).
|
||||
|
||||
This is genuinely strong design. But all inputs are synthetic `MultiBandCsiFrame`s constructed in the test module; no ingest crate calls `StreamingEngine` yet.
|
||||
|
||||
---
|
||||
|
||||
## 5. Strengths
|
||||
|
||||
1. **Deterministic witness chain, end to end in design.** ADR-028 proof (`archive/v1/data/proof/verify.py` + SHA-256), ADR-119 BLAKE3 frame witnesses (`bfld/src/signature_hasher.rs`), ADR-136 `CanonicalFrame`/`ComplexSample` LE contracts, and the engine's per-cycle trust witness form a coherent auditability story few sensing systems attempt.
|
||||
2. **Privacy as a control plane, not a feature.** BFLD's three structural invariants (`bfld/src/lib.rs:7-11`), hash-rotation (ADR-120), identity-risk scoring (ADR-121), mode registry with hash-chained attestations, and *monotonic* demotion wired to fusion contradictions (engine `lib.rs:327-328`) — uncertainty automatically reduces information release.
|
||||
3. **Multistatic fusion with physics-grounded quality.** Attention fusion + GDI + Cramér-Rao bounds + clock-quality gating means geometry and synchronisation deficits are first-class, measurable contradiction sources rather than silent failure modes.
|
||||
4. **Test density at the unit level.** 3,890 static test functions; the signal core (473), BFLD (369), and sensing-server (571) are the deepest. ruvsense files average ~14 tests/module.
|
||||
5. **Honest self-assessment culture.** ADR-136 §8's "skeleton, not a shipping product" framing, ADR-147's explicit "random weights" disclosure, and homecore's in-source TODO-P2 ledger (`homecore/src/lib.rs:24-31`) make the gap analysis below mostly a matter of reading what the project already admits.
|
||||
6. **A real prior research base with negative results.** The sota-2026-05-22 loop catalogued negatives by resolution path (missing-tool / architecture-error / physics-floor) and produced a ship-recipe (N=5 chest-centric placement, 100% coverage for 1–4 occupants) consolidated into ADR-113.
|
||||
7. **Hardware path exists and was audited.** ADR-028 (Accepted) and ADR-039 (Accepted, hardware-validated) anchor the ESP32-S3/C6 ingest tier; firmware release process includes real-CSI verification on COM ports.
|
||||
|
||||
---
|
||||
|
||||
## 6. Honest Gap Analysis — ADR vs Implemented vs Integrated
|
||||
|
||||
| Capability | ADR status | Code status | Integrated on live path? |
|
||||
|---|---|---|---|
|
||||
| Six-stage ruvsense pipeline | ADR-029 **Proposed** | Implemented + tested (310 tests) | Partially — sensing-server has `multistatic_bridge.rs`/`tracker_bridge.rs`, but `RuvSensePipeline` still holds concrete fields with `tick()` only (`mod.rs`); no uniform `Stage<I,O>` chain runs live |
|
||||
| Frame contracts (`ComplexSample`, provenance fields, `Stage` traits) | ADR-136 Proposed | Built + 9 acceptance tests (per ADR-136 §8, commit `11f89727f`) | **No** — AC6 600-frame replay witness key and AC7 cross-arch CI matrix not done; provenance fields not populated by live calibration/model stages |
|
||||
| Fusion quality / contradictions | ADR-137 Proposed | `fusion_quality.rs` (188 lines, 3 tests) + engine wiring | Engine-tests only |
|
||||
| WorldGraph digital twin | ADR-139 Proposed | `wifi-densepose-worldgraph` (4 files, 7 tests) | Engine-tests only; no recorder-backed persistence loop |
|
||||
| Privacy control plane | ADR-141 Proposed | `privacy_mode.rs` registry + attestation chain, tested | Engine-tests only; MQTT/HA exposure exists in BFLD but the *engine→BFLD sink* live path is unwired |
|
||||
| UWB range fusion | ADR-144 Proposed | **No hardware, no crate** — acknowledged absent (`engine/src/lib.rs:28`) | No |
|
||||
| Ablation/leakage eval harness | ADR-145 Proposed | `wifi-densepose-train/src/ablation.rs` exists | Self-labelled "skeleton/scaffolding" (ADR-145 §status) |
|
||||
| RF encoder multi-task heads | ADR-146 Proposed | Not trained; `model_id`/`model_version` registry unowned | No — engine stamps `rfenc-v1` as a placeholder string (`lib.rs:338`) |
|
||||
| RF foundation encoder | ADR-150 **Proposed** | ADR only | No |
|
||||
| World-model forecasting (OccWorld) | ADR-147 (benchmark doc) | Runs on RTX 5080, 72.39M params — **random weights**, no domain checkpoint | No |
|
||||
| HomeCore HA port | ADR-125–133 all Proposed | P1 scaffold + siblings; `homecore-server` has **0 tests**; persistence, service mpsc dispatch, device registry, witness integration all deferred (`homecore/src/lib.rs:24-31`) | Partially (API surfaces exist) |
|
||||
| BFLD capture path (Nexmon/ESP32 BFI) | ADR-123 Proposed | rvCSI vendored runtime exists for nexmon `.pcap`; BFI-specific capture unverified in this review | Unclear |
|
||||
| Drone swarm | ADR-148 In Progress | 17 modules, sim + Candle PPO complete per milestones | **Simulation only** — M3's 15% requires physical ESP32-S3 CSI capture (ADR-148:946) |
|
||||
| Federation / DP-SGD / PQC | ADR-105–109 Proposed | `ruview-fed` crate **does not exist** (roadmap Tier 2 item) | No |
|
||||
| Antenna-placement CLI (`plan-antennas`) | ADR-113 Proposed; Roadmap Tier 1.1 HIGH | numpy references only; not found as a Rust CLI subcommand | No |
|
||||
|
||||
**Pattern:** the unit layer is real and deep; the *integration* layer is one crate (`wifi-densepose-engine`) exercised solely by its own synthetic tests; the *model* layer (anything learned: RF encoder, pose model fine-tuned on CSI, OccWorld domain weights) is the emptiest tier. Nearly every ADR ≥118 carries status **Proposed** even where substantial tested code exists — ADR status hygiene lags implementation in both directions (BFLD code outruns its "P1 in progress" header; ADR-148's "98%" outruns its hardware evidence).
|
||||
|
||||
---
|
||||
|
||||
## 7. Risk Register
|
||||
|
||||
| # | Risk | Likelihood | Impact | Evidence / Notes |
|
||||
|---|---|---|---|---|
|
||||
| R1 | **Integration gap**: trust chain proven only against synthetic in-test frames; live 20 Hz ingest→engine→BFLD-sink path unwired, so the headline guarantee (auditable provenance on every emission) is unverified in production conditions | High | Critical | `engine/src/lib.rs:27-29`; ADR-136 §8 |
|
||||
| R2 | **No trained model**: every learned component (RF encoder ADR-146/150, OccWorld ADR-147) is random-weight or absent; sensing claims beyond coherence/occupancy heuristics cannot ship | High | Critical | ADR-147 "random weights"; ADR-146/150 Proposed |
|
||||
| R3 | **Synthetic-validation bias**: ruvsense/engine/swarm tests and the sota-loop results (e.g., R3 "100% (synthetic)", ADR-113 placement numbers) are simulation-derived; real-room domain gap unquantified | High | High | `00-summary.md:45`; PRODUCTION-ROADMAP 2.3 ("turns synthetic numbers into validated numbers") |
|
||||
| R4 | **Witness chain incomplete at frame level**: `CsiFrame.data` is still `serde(skip)` (ADR-136 Gap 2); AC6 replay-witness key and AC7 cross-architecture matrix not landed — deterministic replay is a design, not a property | Medium | High | ADR-136 §1.1, §8 |
|
||||
| R5 | **Float nondeterminism in fusion** across thread counts could silently break the witness/replay contract once wired | Medium | High | ADR-136 §3.3 risk table (project's own assessment) |
|
||||
| R6 | **Privacy bypass via unwired paths**: BFLD invariants are enforced per-module, but until the engine is the *only* route from ingest to API, a sensing-server endpoint can emit ungated state (sensing-server already has 30+ modules incl. pose/vitals APIs predating the control plane) | Medium | Critical | `sensing-server/src/` module list vs engine isolation |
|
||||
| R7 | **Hardware dependence + scale**: multistatic TDMA/channel-hopping timing validated on small ESP32 sets; ADR-148 M3 explicitly blocked on real hardware; clock-quality model in engine uses a hardcoded `ClockQualityScore` (`engine/src/lib.rs:384`) | Medium | High | ADR-148:946; hardcoded 50 µs stdev |
|
||||
| R8 | **ADR/doc/status drift**: 150 ADRs with near-universal "Proposed" status, stale in-source status headers (`bfld/src/lib.rs:12`), CLAUDE.md "16 ruvsense modules" vs 22 on disk, duplicate ADR numbers (two ADR-050s, two ADR-147s, two ADR-149s, ADR-052 ×2) — institutional-memory value degrades | High | Medium | `ls docs/adr/`; this review §3 |
|
||||
| R9 | **Workspace breadth vs maintenance capacity**: 38 workspace crates + 4 vendored subtrees + Python archive + firmware; several crates have 0 tests (`homecore-server`, `nvsim-server`, `wifi-densepose-wasm`, `homecore-plugin-example`); bus factor appears to be ~1 | High | Medium | crate test-count table §2 |
|
||||
| R10 | **Eval debt**: no end-to-end accuracy benchmark on real CSI with ground truth exists in-repo (ADR-145 harness is scaffolding; ADR-079 camera ground truth not exercised here) — "beyond SOTA" claims are currently unfalsifiable | High | High | ADR-145 status note; absence of ground-truth datasets in tree |
|
||||
|
||||
---
|
||||
|
||||
## 8. Measurement Appendix
|
||||
|
||||
- Test counts: `grep -r '#[test]'` / `#[tokio::test]` per crate directory, 2026-06-09. Workspace totals: 3,890 / 121. Top crates: `wasm-edge` 643, `sensing-server` 557+14, `signal` 473, `bfld` 369, `ruv-neural` 364, `train` 312, `mat` 165+9, `wifiscan` 150, `hardware` 137, `ruvector` 136, `ruview-swarm` 115+19.
|
||||
- ruvsense per-file lines/tests: `wc -l` + per-file `grep -c '#[test]'` (table in §3).
|
||||
- Crate inventory: `ls v2/crates/` → 38 directories.
|
||||
- ADR inventory: `ls docs/adr/` → 150 numbered files (with the duplicate numbers noted in R8); `docs/adr/README.md` self-reports "45 ADRs" (stale).
|
||||
- Caveats: static `#[test]` counts include `#[cfg(feature = ...)]`-gated and ignored tests; they are an upper bound on what `cargo test --workspace --no-default-features` runs. No cargo build/test was executed for this review.
|
||||
|
||||
*Next in series: 01+ documents should target the R1/R2/R10 axis — wiring the live path, training the RF encoder, and standing up a falsifiable real-CSI benchmark — before any "beyond SOTA" claim is made.*
|
||||
@@ -0,0 +1,191 @@
|
||||
# SOTA Landscape 2026 — The Bar a Beyond-SOTA RuView Must Clear
|
||||
|
||||
**Series**: ruview-beyond-sota (01)
|
||||
**Date**: 2026-06-09
|
||||
**Status**: Research survey / target definition
|
||||
**Builds on (does not duplicate)**: `docs/research/sota-2026-05-22/00-summary.md` (physics floors, placement, privacy chain), `docs/research/BFLD/01-sota-survey.md` (beamforming-feedback leakage SOTA), `docs/research/neural-decoding/21-sota-neural-decoding-landscape.md` (sensor-fidelity framing), `docs/research/rf-topological-sensing/00-rf-topological-sensing-index.md` (mincut/topology resolution limits), ADR-150 (RF foundation encoder + measured MM-Fi campaign), ADR-147 (OccWorld benchmark proof).
|
||||
|
||||
## 0. Evidence legend
|
||||
|
||||
Every claim in this document carries one of three tags. **No RuView benchmark number in this document is invented**; all RuView numbers come from repo-internal measured artifacts.
|
||||
|
||||
| Tag | Meaning |
|
||||
|-----|---------|
|
||||
| **[V]** | Verified in this session via web search (June 2026); source linked in §8 |
|
||||
| **[K]** | Training-knowledge claim (pre-2026 literature); plausible but **not re-verified** — treat as needing citation check before external publication |
|
||||
| **[I]** | Internal RuView measurement or artifact (ADR, issue, witness bundle) — measured, not literature |
|
||||
|
||||
---
|
||||
|
||||
## 1. SOTA reference table per capability axis
|
||||
|
||||
### 1.1 Pose estimation (WiFi CSI)
|
||||
|
||||
| Method | Year | Metric | Dataset / protocol | Tag |
|
||||
|--------|------|--------|--------------------|-----|
|
||||
| DensePose From WiFi (Geng, Huang, De la Torre) | 2023 | Dense-pose UV regions from CSI, "comparable to image-based approaches" (same-layout); commonly cited AP≈43.5 / AP@50≈87.2 | 3×3 antenna, single-layout lab | exact AP numbers **[K]**; paper existence **[V]** (arXiv 2301.00250) |
|
||||
| MetaFi++ (Zhou et al.) | 2023 | PCK@50 = **97.30%** same-domain real-world (MetaFi: 95.23%); drops to **81.7–86.5%** under stricter protocols | Own capture; protocol-sensitive | **[V]** |
|
||||
| Person-in-WiFi 3D (CVPR 2024) | 2024 | End-to-end multi-person 3D; 20.4 M params, **54 FPS**; MPJPE ≈ 90–100 mm on own dataset | Own multi-person dataset | FPS/params **[V]**; MPJPE range **[K]** |
|
||||
| GraphPose-Fi (arXiv 2511.19105) | 2025 | SOTA on MM-Fi random split: **MPJPE 160.6 mm**, best PCK at all thresholds | MM-Fi, random split (S1) | **[V]** |
|
||||
| CSDS (Electronics 14(4):756) | 2025 | Wi-Pose: PCK@5 = **0.6407**, PCK@50 = **0.8824** | Wi-Pose | **[V]** |
|
||||
| PerceptAlign (arXiv 2601.12252) | 2026 | Cross-layout 3D: MPJPE **222.4 mm** (Scene 4) / **317.1 mm** (Scene 5), >54% better than prior cross-layout SOTA; in easier settings MPJPE 181.5 mm, PCK@20/50 = 44.2/79.5 | Cross-layout protocol | **[V]** |
|
||||
| WiFlow (arXiv 2602.08661) | 2026 | Lightweight continuous HPE, spatio-temporal decoupling | — | **[V]** (existence; numbers not extracted) |
|
||||
| **RuView / AetherArena** | 2026 | **81.63% torso-PCK@20 in-domain (random split), beating MultiFormer's 72.25%** on metric/protocol-matched MM-Fi; **leakage-free cross-subject collapses to ~11.6% torso-PCK zero-shot**; official-split harness baseline ~63–65% PCK@20; **11 KB LoRA few-shot calibration → 72.5%** | MM-Fi (issue #876, ADR-150 §3) | **[I]** |
|
||||
|
||||
**The honest reading of the pose axis**: same-domain WiFi pose is "solved-looking" (PCK@50 in the 90s) and meaningless for deployment. The 2025–2026 literature has shifted to cross-layout/cross-subject protocols, where numbers collapse (PerceptAlign PCK@20 = 44.2 cross-layout **[V]**; RuView cross-subject zero-shot 11.6% **[I]**). ADR-150's measured finding — that the cross-subject gap is **subject-distribution shift, not an algorithmic gap**, and that **few-shot in-room calibration (5–200 frames) closes it** — is ahead of where the published literature is: no published WiFi-pose paper we found ships a per-room ~11 KB adapter calibration mechanism. **[I]**
|
||||
|
||||
### 1.2 Presence / person count
|
||||
|
||||
| Method | Year | Metric | Tag |
|
||||
|--------|------|--------|-----|
|
||||
| Large-scale commodity router deployment (>10 M routers) | 2025 | **92.6% motion-detection accuracy** across diverse homes | **[V]** (ISAC survey, arXiv 2510.14358) |
|
||||
| LeakyBeam (NDSS 2025) | 2025 | Occupancy through walls at 20 m from **plaintext BFI alone**: TPR 82.7%, TNR 96.7% | **[V]** (also in BFLD survey §4.2) |
|
||||
| Time-Selective RNN multi-room presence (arXiv 2304.13107) | 2023 | Device-free multi-room presence from CSI | **[V]** (existence) |
|
||||
| Academic person counting (0–5 occupants, lab) | 2020–2024 | typically 90–97% exact-count accuracy, degrading sharply >5 people | **[K]** |
|
||||
| **RuView** | 2026 | `cog-person-count` ships with calibrated uncertainty (`count_p95_low/high`); multistatic placement recipe with **100% coverage for 1–4 occupants at N=5 nodes (synthetic physics)** | **[I]** (sota-2026-05-22 R6.2.5, ADR-113) |
|
||||
|
||||
### 1.3 Vital signs (HR / BR)
|
||||
|
||||
| Method | Year | Metric | Tag |
|
||||
|--------|------|--------|-----|
|
||||
| PhaseBeat (ACM Health) | 2020 | HR median error **1.19 bpm**; BR median error **0.25 breaths/min** | **[V]** |
|
||||
| MDPI Sensors 24(7):2111 non-contact HR | 2024 | HR accuracy 96.8%, **median error 0.8 bpm** | **[V]** |
|
||||
| PulseFi (arXiv 2510.24744) | 2025 | Low-cost ML cardiopulmonary + **apnea** monitoring from CSI | **[V]** (existence; numbers not extracted) |
|
||||
| mmWave FMCW vitals (60 GHz class) | 2023–2026 | HR MAE typically 1–3 bpm at 1–3 m, single subject; age-balanced reference dataset published (Sci Data 2026) | dataset **[V]**; MAE range **[K]** |
|
||||
| Contactless blood pressure (WiFi-band) | — | **NEGATIVE** — below classical physics floor; recoverable only via quantum magnetometry path | **[I]** (R13/R20 arc, ADR-114) |
|
||||
| **RuView** | 2026 | `wifi-densepose-vitals` (ADR-021) extracts HR/BR from ESP32 CSI; chest-centric placement gives **+27 pp coverage** for vitals cogs (synthetic) | **[I]** — **no accuracy-vs-ECG validation number exists in-repo yet; do not claim one** |
|
||||
|
||||
**Bar**: published single-subject, line-of-sight, 1–3 m WiFi HR is ~0.8–1.2 bpm median error **[V]**. Nobody credibly publishes multi-person, through-wall, walking-subject HR at that accuracy — that is open territory.
|
||||
|
||||
### 1.4 Localization (ToA / CRLB)
|
||||
|
||||
| Method | Year | Metric | Tag |
|
||||
|--------|------|--------|-----|
|
||||
| 802.11mc FTM | shipped | 1–2 m typical accuracy | **[V]** (FTM survey, arXiv 2509.03901) |
|
||||
| 802.11az (+ 802.11bk) | released | **sub-1 m**, 160 MHz channels, secured ranging, HE-LTF repetitions | **[V]** |
|
||||
| AI single-link decimeter localization | 2025 | **0.63 m average error** single-link, beating Widar2.0 / Dynamic-MUSIC | **[V]** |
|
||||
| SpotFi / Chronos / Widar lineage | 2015–2021 | 0.4–1 m with multi-AP CSI AoA/ToF | **[K]** |
|
||||
| **RuView** | 2026 | CRLB / Fisher-information machinery in `ruvector/src/viewpoint/geometry.rs`; tomography ISTA voxel grid; **theoretical** limits derived internally: 30–60 cm at 16 nodes/1 m spacing, 8.8 cm information-theoretic dense limit | **[I]** (rf-topological-sensing doc 09 — synthetic derivations, no bench numbers) |
|
||||
|
||||
### 1.5 Through-wall
|
||||
|
||||
| Method | Year | Metric | Tag |
|
||||
|--------|------|--------|-----|
|
||||
| RF-Pose / RF-Pose3D (MIT, FMCW 5.4–7.2 GHz) | 2018 | Through-wall skeletal pose, ~specialized radar not commodity WiFi | **[K]** |
|
||||
| Commodity 2.4 GHz through-wall imaging (arXiv 1903.03895) | 2019 | Coarse imaging through walls with commodity WiFi | **[V]** (existence) |
|
||||
| Radio tomographic imaging (RTI) lineage | 2010–2013 | Through-wall tracking via RSS networks, ~0.5–1 m tracking error | **[V]** (papers) / error figure **[K]** |
|
||||
| LeakyBeam (NDSS 2025) | 2025 | Through-wall **occupancy** at 20 m, passive, commodity | **[V]** |
|
||||
| **RuView** | 2026 | RF tomography module (`tomography.rs`, ISTA L1 voxel solver) + CIR (ADR-134) exist as code; **PABS structure detection: 1,161× static / 9.36× dynamic intruder lift (synthetic)** | **[I]** |
|
||||
|
||||
Notably, the 2025–2026 web literature shows through-wall *pose* (not just presence) on commodity WiFi remains essentially where it was in 2019 — no verified commodity-WiFi through-wall pose benchmark surfaced in our searches. The frontier moved to privacy attacks (BFI) instead.
|
||||
|
||||
### 1.6 Identity / re-ID (capability and threat simultaneously)
|
||||
|
||||
| Method | Year | Metric | Tag |
|
||||
|--------|------|--------|-----|
|
||||
| BFId (KIT, ACM CCS 2025) | 2025 | **~99.5% (near-100%) re-ID across 197 subjects** from beamforming feedback alone, ≥5 s of BFI | **[V]** (also BFLD survey §4.1) |
|
||||
| Transformer CSI identification | 2025 | **99.82%** on stationary subjects | **[V]** |
|
||||
| WhoFi (arXiv 2507.12869) | 2025 | Deep person re-ID via WiFi channel encoding, ~95% rank-1 class results | existence **[V]**; exact number **[K]** |
|
||||
| Wi-Gait | 2023 | 92.9% over 10 subjects, robust to walking cofactors | **[V]** |
|
||||
| **RuView** | 2026 | AETHER contrastive re-ID embeddings (ADR-024) in pose tracker; **BFLD**: first *defensive* identity-leak detector (identity_risk_score) — the literature attacks, RuView audits | **[I]** |
|
||||
|
||||
### 1.7 Adjacent modality: mmWave radar (the accuracy ceiling WiFi is chasing)
|
||||
|
||||
| Method | Year | Metric | Tag |
|
||||
|--------|------|--------|-----|
|
||||
| mmChainPose | 2025 | **27.0 mm MPJPE** / 0.8706 OKS on MARS (mmWave point cloud) | **[V]** |
|
||||
| ProbRadarM3F (arXiv 2405.05164) | 2024–25 | SOTA AP across joints, probability-map fusion | **[V]** |
|
||||
| Seeed MR60BHA2-class 60 GHz FMCW | shipped | Commodity $15 HR/BR/presence module — already in RuView's hardware table | **[I]** |
|
||||
|
||||
mmWave is ~6× better than the best WiFi MPJPE (27 mm vs 160 mm) **[V]**. The strategic implication: WiFi will not beat mmWave on raw geometry; it wins on ubiquity, cost, through-wall propagation, and standardized waveforms (§2). RuView already hedges with the ESP32-C6 + MR60BHA2 fusion node. **[I]**
|
||||
|
||||
---
|
||||
|
||||
## 2. IEEE 802.11bf — status and implications
|
||||
|
||||
**Status (verified)**: IEEE **802.11bf-2025 is ratified and published** (IEEE SA lists the amendment; ratification late 2024 / publication 2025) **[V]**. It amends MAC/PHY of HE (Wi-Fi 6) and EHT (Wi-Fi 7) plus DMG/EDMG (60 GHz) to support WLAN sensing in 1–7.125 GHz and >45 GHz bands **[V]**. The Wi-Fi Alliance has Wi-Fi Sensing as an active certification work area built on 802.11bf (presence/proximity, gestures, vital signs) **[V]**. Market reports claim >47 chipset vendors with 802.11bf-compatible programs as of early 2026 — single weak source, treat as directional **[V, low confidence]**.
|
||||
|
||||
**What it implies for RuView**:
|
||||
|
||||
1. **Sounding-on-demand becomes standard.** 802.11bf defines a sensing-measurement procedure (sensing initiator/responder, trigger-based sounding, threshold-based reporting). Today RuView relies on Espressif's vendor CSI API and Nexmon firmware patches; post-bf, commodity Wi-Fi 7 silicon will expose scheduled sensing measurements without firmware hacks. The rvCSI normalized `CsiFrame` schema is the right abstraction layer to absorb a future bf adapter (`rvcsi-adapter-*`). **[I]**
|
||||
2. **The moat moves up the stack.** When every router can sense, raw CSI access stops being differentiating. Differentiators become: multistatic fusion, coherence gating / anti-hallucination, calibration mechanisms, witness-grade verification, and privacy auditing — exactly RuView's existing bets (ADR-029/135/150/028, BFLD). **[I]**
|
||||
3. **Privacy pressure intensifies.** 802.11bf standardizes the capability that BFId/LeakyBeam exploit. BFLD's identity-leak detection and the ADR-105–109 privacy/PQC chain become regulatory assets, not nice-to-haves. **[V]+[I]**
|
||||
4. **Threshold-based reporting** in bf (report only when channel changes exceed threshold) is architecturally the same idea as RuView's coherence gate — validation that the gate belongs at the protocol layer. **[K]** (bf reporting detail from training knowledge)
|
||||
|
||||
---
|
||||
|
||||
## 3. RF foundation model landscape ("GPT for RF")
|
||||
|
||||
Verified 2025–2026 attempts, all young, none dominant:
|
||||
|
||||
| Model | Approach | Downstream tasks | Tag |
|
||||
|-------|----------|------------------|-----|
|
||||
| **LWM (Large Wireless Model)** | Pretrained on large-scale CSI → general channel embeddings | LoS/NLoS, beats raw features in low-data regimes | **[V]** |
|
||||
| **LatentWave** (arXiv 2606.06373) | JEPA pretraining on wireless spectrograms + CSI | RF classification, 5G NR positioning, beam prediction, LoS/NLoS | **[V]** |
|
||||
| **WirelessJEPA** (arXiv 2601.20190) | Multi-antenna spatio-temporal latent prediction | Cross-task transfer | **[V]** |
|
||||
| **IQFM** | Contrastive SSL on raw I/Q | Modulation classification, beam prediction, RF fingerprinting, few-shot | **[V]** |
|
||||
| **Multimodal Wireless FMs** (arXiv 2511.15162), **WMFM** (arXiv 2512.23897), **SoM** (arXiv 2506.07647) | Vision + RF multimodal for 6G ISAC | Sensing-communication integration | **[V]** |
|
||||
| **DeepSig OmniSIG** | Commercial AI-native RF sensing, 500 MHz/GPU spectrum | Signal ID (LTE/5G/Wi-Fi) | **[V]** |
|
||||
|
||||
**Critical observation**: every verified RF foundation model targets *communication-side* tasks (beam prediction, LoS/NLoS, modulation, positioning). **None of them is a human-sensing foundation model** — none pretrains for pose/vitals/identity invariances. ADR-150's measured negative result is the sharpest data point in this space: pose-contrastive pretraining across subjects **failed on MM-Fi because the invariance is not in the data** (loss never left the ln(B) floor) **[I]**. The literature has not yet published this failure mode; the field's "GPT for RF sensing" narrative is ahead of its evidence. The defensible foundation-model objective (per ADR-150 §3.5–3.6) is **reduce few-shot calibration cost**, not zero-shot invariance. **[I]**
|
||||
|
||||
---
|
||||
|
||||
## 4. "Beyond SOTA" for RuView — precise definition
|
||||
|
||||
Targets below are **bar definitions**, not claims. RuView numbers in the "current" column are measured [I]; targets must be proven via the AetherArena witness protocol (ADR-149) before being asserted anywhere.
|
||||
|
||||
| Capability | Published SOTA (2026) | RuView measured today | RuView beyond-SOTA target | Key obstacle |
|
||||
|------------|----------------------|----------------------|---------------------------|--------------|
|
||||
| Pose, in-domain (MM-Fi) | GraphPose-Fi 160.6 mm MPJPE; MultiFormer 72.25% torso-PCK@20 **[V]** | **81.63% torso-PCK@20** (already > published) **[I]** | Hold #1 under leakage-free audit + per-joint tables published with witness rows | Protocol fragmentation; reviewers distrust WiFi-pose numbers |
|
||||
| Pose, cross-subject zero-shot | ~collapse everywhere; PerceptAlign PCK@20 44.2 cross-layout **[V]** | 11.6% torso zero-shot; 63–65% in-harness official split **[I]** | Stop chasing it (measured dead end); instead **few-shot frontier** below | Subject-distribution shift is in the data, not the model (ADR-150 §3.2) |
|
||||
| Pose, deployment calibration | **No published per-room adapter mechanism found** | **11 KB LoRA, 100–200 frames → 72.5%; cross-env K=5 → 60.1%** **[I]** | ≤20 frames → ≥70% PCK@20, adapter ≤11 KB, 30 s on-site; publish as the first calibration-service benchmark | Needs diverse-room capture fleet to validate beyond MM-Fi |
|
||||
| Presence/motion (commodity) | 92.6% across 10 M routers **[V]** | Synthetic placement recipe 100% coverage N=5 **[I]** | ≥99% presence with calibrated p95 bounds on $6–15 ESP32 mesh, bench-validated | All placement numbers are synthetic; Tier-2.3 bench validation outstanding |
|
||||
| Person count | ~90–97% lab, ≤5 people **[K]** | cog ships uncertainty intervals **[I]** | Exact count 1–6 people ≥95% with honest intervals, multistatic, real bench | Multi-person CSI superposition; no public multi-occupancy benchmark |
|
||||
| Vital signs HR | 0.8–1.2 bpm median, single subject, LoS, 1–3 m **[V]** | No in-repo ECG-validated number — **must not be claimed** | ≤1.5 bpm MAE vs ECG ground truth, *multi-person or through-wall*, witness-bundled | R13 physics floor: ~5 dB shortfall at distance; needs chest-centric placement + PABS |
|
||||
| Vital signs BP | NEGATIVE at WiFi band (matches internal R13) | nvsim quantum path only **[I]** | First validated quantum-classical fused bedside vitals (ADR-114) | NV-diamond hardware maturity, 2028+ |
|
||||
| Localization | 0.63 m single-link AI; sub-1 m 802.11az **[V]** | CRLB machinery, no bench number **[I]** | ≤30 cm multistatic on ESP32 mesh (internal theory says feasible at N=16) | ESP32 clock sync / phase offset (TDM protocol exists, unproven at this accuracy) |
|
||||
| Through-wall | Occupancy yes (LeakyBeam); commodity pose: nothing credible **[V]** | tomography + CIR code, PABS 9.36× lift (synthetic) **[I]** | First witnessed commodity-WiFi through-wall *person localization* (not pose) ≤1 m | Wall attenuation eats the R6.1 4.7 dB multi-scatterer budget |
|
||||
| Identity / re-ID | ~99.5% @ 197 subjects (attack) **[V]** | AETHER + **BFLD defensive auditing** (no published competitor) **[I]** | Ship the first identity-leak risk score with DP budget hook; keep re-ID opt-in only | Calibrating risk score at 802.11ax 4/2-bit quantization (BFLD open Q2) |
|
||||
| Verification | **Nothing comparable published** — no WiFi-sensing paper ships deterministic re-verification | ADR-028 witness bundles, SHA-256 proof, 7/7 self-verify, 1,031+ tests **[I]** | Make witness-grade reproduction the *expected* standard: every public claim = one-command verification | Community adoption, not technology |
|
||||
| Foundation encoder | Comms-task FMs only (LWM/JEPA family) **[V]** | Masked-CSI + coherence head planned; pose-contrastive refuted **[I]** | First *sensing* FM whose acceptance metric is calibration-sample reduction (frames-to-72% halved) | SSL must match production CSI pipeline (ADR-149 resampling risk) |
|
||||
|
||||
---
|
||||
|
||||
## 5. Where RuView already matches/exceeds published work
|
||||
|
||||
1. **In-domain MM-Fi pose** — 81.63% torso-PCK@20 vs MultiFormer 72.25%, metric- and protocol-matched (issue #876). **[I]**
|
||||
2. **Deployment-calibration mechanism** — the 11 KB LoRA per-room adapter with measured frames-to-accuracy curves (§3.4–3.6 of ADR-150) has no published equivalent; the literature is still arguing about zero-shot generalization that ADR-150 measured to be a data property.
|
||||
3. **Deterministic witness verification** — ADR-028's SHA-256 pipeline proof + self-verifying bundles exceeds the reproducibility practice of every WiFi-sensing paper surveyed (none ship deterministic re-verification).
|
||||
4. **Multistatic cost point** — $6–15/node ESP32 mesh with TDM sync, channel hopping, placement recipes (ADR-113) vs literature setups using Intel 5300/AX210 laptops or USRPs; ~$30/bed vs $3,000 clinical monitor framing (R16).
|
||||
5. **Defensive identity auditing (BFLD)** — the field publishes attacks (BFId, LeakyBeam, WhoFi); RuView is building the only detector/auditor, plus a PQC-hardened federation privacy chain (ADR-105–109) with no published counterpart.
|
||||
6. **Anti-hallucination coherence gating** — confidence gated by RF integrity (ADR-135, ADR-150 §2.4); WiFi-pose papers uniformly lack a "the model knows when the channel is bad" signal.
|
||||
7. **Negative-result discipline** — physics floors (R13 BP, R6.1 4.7 dB), refuted pose-contrastive pretraining — published SOTA papers do not report these, which inflates the apparent literature bar.
|
||||
|
||||
## 6. Where RuView lags
|
||||
|
||||
1. **Bench validation** — nearly all multistatic/placement/tomography numbers are synthetic-physics; the 92.6%-on-10M-routers deployment **[V]** is real-world evidence at a scale RuView cannot approach.
|
||||
2. **Vital-sign ground truth** — no in-repo ECG/respiration-belt validated HR/BR error; published work has 0.8 bpm median **[V]**. This is the most urgent claim gap.
|
||||
3. **Raw geometric accuracy** — mmWave (27 mm MPJPE **[V]**) and even best-WiFi MPJPE (160.6 mm **[V]**) have no RuView MPJPE counterpart published; AetherArena reports PCK only.
|
||||
4. **802.11bf-native capture** — RuView is on vendor CSI APIs and Nexmon patches; no bf sensing-procedure adapter exists yet in rvCSI.
|
||||
5. **Multi-person pose** — Person-in-WiFi-3D does end-to-end multi-person at 54 FPS **[V]**; RuView's pose path is effectively single-person (multi-person exists only in count/placement work).
|
||||
6. **Dataset scale and diversity** — MM-Fi only; ADR-150 §3.3 shows the binding constraint is room/device/protocol diversity, which requires the capture fleet that doesn't exist yet.
|
||||
|
||||
## 7. Strategic synthesis
|
||||
|
||||
The 2026 bar is bimodal: **lab in-domain numbers are saturated** (PCK@50 > 95%, HR < 1 bpm) and **deployment numbers are collapsed** (cross-layout PCK@20 ≈ 44, zero-shot cross-subject ≈ 11%). 802.11bf-2025 commoditizes raw sensing; foundation models commoditize comms-side embeddings. "Beyond SOTA" for RuView is therefore *not* a leaderboard delta — it is owning the three layers the field hasn't built: **(a)** witnessed, deterministic, leakage-audited evaluation; **(b)** the few-shot calibration service (11 KB adapters) as the deployment answer the zero-shot literature lacks; **(c)** the privacy/integrity layer (BFLD + coherence gate) that 802.11bf-era regulation will demand. Each row in §4's target table is gated on the AetherArena witness protocol — a target becomes a claim only when it ships with a one-command reproduction.
|
||||
|
||||
---
|
||||
|
||||
## 8. Verified sources (accessed 2026-06-09 via web search)
|
||||
|
||||
Pose: [GraphPose-Fi](https://arxiv.org/html/2511.19105v1) · [PerceptAlign / cross-layout](https://arxiv.org/html/2601.12252) · [CSDS](https://www.mdpi.com/2079-9292/14/4/756) · [Person-in-WiFi 3D](https://aiotgroup.github.io/Person-in-WiFi-3D/) · [DensePose From WiFi](https://arxiv.org/abs/2301.00250) · [MetaFi++](https://www.researchgate.net/publication/369644995_MetaFi_WiFi-Enabled_Transformer-based_Human_Pose_Estimation_for_Metaverse_Avatar_Simulation) · [WiFlow](https://arxiv.org/html/2602.08661v2)
|
||||
Vitals: [PhaseBeat](https://dl.acm.org/doi/abs/10.1145/3377165) · [Non-contact HR (Sensors 24:2111)](https://www.mdpi.com/1424-8220/24/7/2111) · [PulseFi](https://arxiv.org/pdf/2510.24744) · [mmWave vitals dataset (Sci Data)](https://www.nature.com/articles/s41597-026-07172-9)
|
||||
Localization: [FTM survey 802.11mc/az/bk](https://arxiv.org/abs/2509.03901) · [Decimeter single-link](https://www.ncbi.nlm.nih.gov/pmc/articles/PMC12846125/) · [SelfLoc 802.11az](https://www.mdpi.com/2079-9292/14/13/2675)
|
||||
802.11bf: [IEEE SA 802.11bf-2025](https://standards.ieee.org/ieee/802.11bf/11574/) · [TGbf](https://www.ieee802.org/11/Reports/tgbf_update.htm) · [NIST overview](https://www.nist.gov/publications/ieee-80211bf-enabling-widespread-adoption-wi-fi-sensing) · [Wi-Fi Alliance work areas](https://www.wi-fi.org/current-work-areas) · [ISAC survey (10M-router 92.6%)](https://arxiv.org/pdf/2510.14358)
|
||||
Identity: [BFId / KIT CCS 2025 coverage](https://www.gblock.app/articles/wifi-signal-person-identification-surveillance-study-may-2026) · [WhoFi](https://arxiv.org/html/2507.12869v1) · [Wi-Gait](https://www.sciencedirect.com/science/article/abs/pii/S1389128623001962) · [LeakyBeam NDSS 2025](https://www.ndss-symposium.org/ndss-paper/lend-me-your-beam-privacy-implications-of-plaintext-beamforming-feedback-in-wifi/)
|
||||
Through-wall: [RTI through-wall](https://ieeexplore.ieee.org/document/6214374/) · [Commodity 2.4 GHz imaging](https://arxiv.org/pdf/1903.03895) · [Multi-room presence](https://arxiv.org/pdf/2304.13107)
|
||||
Foundation models: [LatentWave](https://arxiv.org/html/2606.06373) · [WirelessJEPA](https://arxiv.org/pdf/2601.20190) · [Multimodal Wireless FMs](https://arxiv.org/pdf/2511.15162) · [WMFM](https://arxiv.org/html/2512.23897) · [SoM](https://arxiv.org/pdf/2506.07647) · [RF-native AI / LWM, IQFM, OmniSIG](https://aicompetence.org/rf-native-ai-models-for-the-invisible-spectrum/)
|
||||
mmWave: [mmChainPose](https://www.sciencedirect.com/science/article/abs/pii/S0925231225026918) · [ProbRadarM3F](https://arxiv.org/html/2405.05164v3)
|
||||
|
||||
Internal [I] sources: ADR-150 (§1, §3.2–3.6), ADR-147, ADR-028, ADR-113/114, issue #876, `docs/research/sota-2026-05-22/00-summary.md`, `docs/research/BFLD/01-sota-survey.md`, `docs/research/rf-topological-sensing/`.
|
||||
@@ -0,0 +1,282 @@
|
||||
# RuView Beyond-SOTA Target Architecture
|
||||
|
||||
**Series:** ruview-beyond-sota (02)
|
||||
**Date:** 2026-06-09
|
||||
**Status:** Research design — components marked **PROPOSED** do not exist yet; everything else cites real code.
|
||||
**Governing constraint:** ADR-136 §2.1 explicitly rejects renaming/rewriting the workspace. This document designs an **evolution** of the existing 38-crate `v2/` workspace (`v2/Cargo.toml`), not a new system. Every beyond-SOTA layer attaches to the ADR-136 `Stage<I,O>` / `FrameMeta` / `CanonicalFrame` contracts (`docs/adr/ADR-136-ruview-streaming-engine-frame-contracts.md` §2.2–2.5) and preserves the ADR-028 witness chain.
|
||||
|
||||
---
|
||||
|
||||
## 1. Where the system is today (grounding)
|
||||
|
||||
The ADR-136 ten-role pipeline (ingest → signal → fusion → world → models → privacy → store → api → eval → observe) is already mapped 1:1 onto existing crates (ADR-136 §2.1, normative table). The composition root exists: `v2/crates/wifi-densepose-engine/src/lib.rs` wires ADR-135..146 blocks into one `StreamingEngine::process_cycle` that emits a `TrustedOutput` carrying fusion `QualityScore`, privacy class, `SemanticProvenance`, RF-SLAM (`RfSlam` field), and a BLAKE3 `witness: [u8; 32]`.
|
||||
|
||||
Key existing substrate this design builds on:
|
||||
|
||||
| Substrate | Path | What it gives us |
|
||||
|---|---|---|
|
||||
| Frame contracts + witness | `v2/crates/wifi-densepose-core/src/types.rs` (`CsiFrame`, `CsiMetadata` + `calibration_id`/`model_id`/`model_version`), ADR-136 `ComplexSample`/`CanonicalFrame` | Deterministic LE bytes, BLAKE3 witness, provenance-append-only boundary rule |
|
||||
| Six-stage signal pipeline | `v2/crates/wifi-densepose-signal/src/ruvsense/mod.rs` (+22 modules incl. `cir.rs`, `calibration.rs`, `tomography.rs`, `rf_slam.rs`, `fusion_quality.rs`, `array_coordinator.rs`) | CSI→CIR, baseline calibration, multistatic fusion, coherence gating |
|
||||
| Fusion quality + evidence | ADR-137; `ruvsense/multistatic.rs`, `ruvsense/fusion_quality.rs`, `wifi-densepose-ruvector/src/viewpoint/fusion.rs` | `QualityScore` with `EvidenceRef`/`ContradictionFlag`, privacy demotion on contradiction |
|
||||
| Digital twin | `v2/crates/wifi-densepose-worldgraph/src/lib.rs` (typed `StableDiGraph`, mandatory `SemanticProvenance`) | Persistent room/sensor/track/belief graph |
|
||||
| World model bridge | `v2/crates/wifi-densepose-worldmodel/src/lib.rs` (`OccWorldBridge`, `TrajectoryPrior`, ADR-147) | Occupancy prediction priors into the Kalman tracker |
|
||||
| NN + training | `v2/crates/wifi-densepose-train/src/{model.rs,rapid_adapt.rs,ablation.rs,proof.rs,eval.rs,ruview_metrics.rs}`, `wifi-densepose-nn` | Shared backbone + 2 heads, `AdaptationLoss::ContrastiveTTT`, ADR-145 ablation matrix, seeded proof harness |
|
||||
| Swarm | `v2/crates/ruview-swarm/src/` (`sensing/{multiview.rs,payload.rs,occworld_bridge.rs}`, `marl/`, `topology.rs`) | Raft/hierarchical-mesh drone coordination with CSI payload (ADR-148) |
|
||||
| Edge WASM | `v2/crates/wifi-densepose-wasm-edge/src/lib.rs` (WASM3 on ESP32-S3, `on_frame` host ABI), `wifi-densepose-wasm` | Hot-loadable on-device sensing modules |
|
||||
| Quantum-adjacent sim | `v2/crates/nvsim/src/lib.rs` (deterministic NV-magnetometry forward pipeline, SHA-256 witness, WASM-ready) | Honest classical-quantum hybrid substrate (ADR-089) |
|
||||
| Semantic record + agents | ADR-140 (`wifi-densepose-sensing-server/src/semantic/`), `homecore-assist` | Provenance-bearing semantic states, Ruflo agent bridge |
|
||||
|
||||
---
|
||||
|
||||
## 2. Target architecture diagram
|
||||
|
||||
The beyond-SOTA layers (★ = new/PROPOSED, ☆ = exists-but-not-wired) wrap the ADR-136 pipeline; nothing replaces it.
|
||||
|
||||
```
|
||||
╔═══════════════════ BEYOND-SOTA CONTROL PLANE ═══════════════════╗
|
||||
║ P6 Continual adaptation loop (TTT + EWC★) P5 Swarm aperture ║
|
||||
║ rapid_adapt.rs → encoder LoRA deltas planner★ (Raft) ║
|
||||
╚════════════▲══════════════════════▲══════════════▲══════════════╝
|
||||
│ adaptation deltas │ quality │ tasking
|
||||
[ingest] [signal] │ [fusion] │ [world] │ [models]
|
||||
ESP32/Pi mesh ─► RuvSensePipeline ──────┴──► fuse_scored ──────┴─► WorldGraph ┴──► RF Foundation
|
||||
+ drone payload multiband→phase_align (ADR-137 (ADR-139 │ Encoder (P1)
|
||||
(ruview-swarm →calibration(135) QualityScore, twin) ◄───────┘ 7 heads + UQ
|
||||
sensing/payload) →cir(134)→multistatic EvidenceRef, ▲ │ (ADR-146/150)
|
||||
│ →coherence→gate Contradiction) │ ▼ │
|
||||
│ │ │ RF-SLAM(143)──OccWorld │
|
||||
▼ ▼ │ rf_slam.rs worldmodel ▼
|
||||
P7 WASM edge P2 Differentiable RF │ (P3 closed loop ☆) P4 cross-modal
|
||||
inference★ forward model★ │ distilled student★
|
||||
(wasm-edge, (tomography.rs + │ (camera-free deploy)
|
||||
deterministic cir.rs ISTA as seed) │
|
||||
replay) │ residuals feed fusion as EvidenceRef★
|
||||
│ ▼
|
||||
│ P8 NV-magnetometry fusion★ (nvsim forward model as a sensing node class)
|
||||
▼
|
||||
─────────────────────── ADR-136 CONTRACT SPINE (unchanged) ───────────────────────────────────
|
||||
CsiFrame{ComplexSample, FrameMeta{calibration_id, model_id, model_version}} → Stage<I,O>
|
||||
→ CanonicalFrame::witness_hash() at EVERY stage boundary (BLAKE3, LE-deterministic)
|
||||
───────────────────────────────────────────────────────────────────────────────────────────────
|
||||
│ │ │ │
|
||||
[privacy] [store] [api] [eval] [observe]
|
||||
wifi-densepose-bfld homecore-recorder homecore-api ADR-145 ablation homecore-
|
||||
gate + demotion + replay corpus★ /HA/Matter/HAP (train/ablation.rs automation,
|
||||
(ADR-141) + P1-P8 variants) Ruflo (ADR-140)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. The eight pillars
|
||||
|
||||
Each pillar: what / why beyond-SOTA / builds-on / contract sketch / feasibility. All trait sketches are **PROPOSED** unless a path is cited.
|
||||
|
||||
### P1 — RF Foundation Encoder with multitask uncertainty heads (ADR-146 + ADR-150)
|
||||
|
||||
**What.** One shared, self-supervised RF encoder (`wifi-densepose-nn`) with seven typed heads (pose, presence, count, activity, vitals, gait, identity-embedding), each emitting calibrated uncertainty via the ADR-136 `QualityScored` trait, trained with the ADR-150 pose-contrastive objective (same-pose-across-subjects = positive) plus a coherence head that exposes channel instability.
|
||||
|
||||
**Why beyond SOTA.** Published WiFi-pose systems (MultiFormer, GraphPose-Fi lineage) report in-domain accuracy and hallucinate under domain shift. ADR-150 documents the real measured frontier: 81.63% torso-PCK@20 in-domain on MM-Fi vs ~11.6% leakage-free cross-subject, and that DANN and bigger capacity both failed (ADR-150 §1). A foundation encoder whose loss stack explicitly separates pose / identity / room / device factors *and* emits an RF-integrity signal per prediction is not in the published literature as a deployed, auditable artifact. Target (not a claim): close the cross-subject gap materially while every head output carries `confidence_bounds()`.
|
||||
|
||||
**Builds on.** `v2/crates/wifi-densepose-train/src/model.rs` (`WiFiDensePoseModel`, `kp_head`/`dp_head`); `v2/crates/wifi-densepose-sensing-server/src/embedding.rs` (`ProjectionHead` + LoRA + `info_nce_loss` — the existing seventh head, ADR-146 §1.1); `v2/crates/wifi-densepose-train/src/rapid_adapt.rs` (ContrastiveTTT precedent); ADR-146 §1.4 head fan-out; ADR-150 §2 loss stack.
|
||||
|
||||
**Contract sketch** (lands in `wifi-densepose-nn`, per ADR-146 §1.3):
|
||||
```rust
|
||||
pub trait RfEncoder: Send + Sync {
|
||||
fn encode(&self, window: &CsiWindowTensor) -> Embedding; // z ∈ R^d_model
|
||||
fn model_id(&self) -> u16; // FrameMeta binding (ADR-136 §2.2)
|
||||
}
|
||||
pub trait TaskHead<O: QualityScored>: Send + Sync {
|
||||
fn name(&self) -> &'static str;
|
||||
fn forward(&self, z: &Embedding) -> O; // value + uncertainty bounds
|
||||
}
|
||||
pub struct MultiTaskOutput { /* per-head QualityScored outputs + coherence: f32 */ }
|
||||
```
|
||||
|
||||
**Feasibility: HIGH for the architecture, MEDIUM for the headline result.** The pure-Rust f32 ABI is proven (`embedding.rs`), the head taxonomy is specified (ADR-146), and the ablation harness to measure it exists (`wifi-densepose-train/src/ablation.rs`). The risk is scientific, not engineering: ADR-150's own data shows naive approaches fail; the pose-contrastive objective is plausible but unproven at scale. Mitigation: ADR-150 §3's frozen-decoder three-variant experiment gates promotion.
|
||||
|
||||
### P2 — Physics-informed differentiable RF forward model (PROPOSED)
|
||||
|
||||
**What.** A differentiable forward model `render(scene, link_geometry) -> predicted CSI/CIR` used three ways: (1) as a regularizer in encoder training (predictions must be consistent with a Born-approximation scattering model), (2) as an analysis-by-synthesis residual at inference (`|observed − rendered|` becomes an ADR-137 `EvidenceRef`), (3) as a synthetic-data generator complementing MM-Fi (ADR-015).
|
||||
|
||||
**Why beyond SOTA.** Published WiFi sensing is almost entirely discriminative; physics-informed neural fields exist for vision (NeRF) and acoustics but no deployed RF-human-sensing stack closes the loop *forward model → residual → fusion evidence → privacy decision*. Making physics disagreement a first-class, witnessed contradiction flag is novel system design, not just a model.
|
||||
|
||||
**Builds on.** The codebase already contains the seed of the forward model: `v2/crates/wifi-densepose-signal/src/ruvsense/tomography.rs` (`RfTomographer`, `LinkGeometry`, `OccupancyVolume` — a linear shadowing forward model inverted by ISTA), `ruvsense/cir.rs` (sub-DFT sensing matrix Φ, ISTA L1 — ADR-134), ADR-143 §1.3 (bistatic excess-delay geometry, the exact ray equations), and `nvsim` as the in-repo precedent for a *deterministic, witness-hashed forward physics pipeline* (`v2/crates/nvsim/src/{propagation.rs,pipeline.rs,proof.rs}`).
|
||||
|
||||
**Contract sketch** (new module `wifi-densepose-signal/src/ruvsense/forward_model.rs`, PROPOSED):
|
||||
```rust
|
||||
pub trait RfForwardModel: Versioned {
|
||||
/// Predict per-link CSI given a voxel scene + body primitive set.
|
||||
fn render(&self, scene: &OccupancyVolume, links: &[LinkGeometry]) -> Vec<PredictedCsi>;
|
||||
/// Physics residual in [0,1]; 0 = perfectly Maxwell/Born-consistent.
|
||||
fn residual(&self, observed: &CsiFrame, rendered: &PredictedCsi) -> PhysicsResidual; // → EvidenceRef
|
||||
}
|
||||
```
|
||||
|
||||
**Feasibility: MEDIUM, with one honest line drawn.** A full Maxwell FDTD-in-the-loop solver is **infeasible** at 20 Hz on this hardware and is a non-goal (§6). What is feasible: a first-order Born / ray-tracing bistatic model (the ADR-143 spheroid geometry generalized), differentiable through finite differences or a small Candle graph, validated against recorded calibration captures (ADR-135 baselines give per-link empty-room ground truth for free). "Maxwell-consistent" should be read as "consistent with a stated first-order approximation, with the approximation order recorded in the witness metadata."
|
||||
|
||||
### P3 — RF-SLAM × WorldGraph × OccWorld closed loop (exists in parts, wiring is the work)
|
||||
|
||||
**What.** Close the loop: RF-SLAM discovers reflectors/anchors → WorldGraph persists them as `object_anchor` nodes → OccWorld consumes graph occupancy → `TrajectoryPrior`s feed the Kalman tracker → improved tracks refine SLAM association. The environment model becomes self-acquiring and self-correcting (furniture moved ⇒ `BaselineTopologyChange` ⇒ recalibration trigger, ADR-143 §1.4).
|
||||
|
||||
**Why beyond SOTA.** Published RF-SLAM work maps *or* tracks; no published consumer system maintains a persistent, provenance-bearing, privacy-rolled-up environmental digital twin (`PrivacyRollup` in `wifi-densepose-worldgraph/src/graph.rs`) that is simultaneously the SLAM map, the automation substrate, and the audit record. The differentiator is the closed loop with evidence edges (`supports`/`contradicts`).
|
||||
|
||||
**Builds on.** All three vertices exist: `v2/crates/wifi-densepose-signal/src/ruvsense/rf_slam.rs` (`RfSlam::observe`, line 176, already a field of `StreamingEngine` — `wifi-densepose-engine/src/lib.rs:116`); `v2/crates/wifi-densepose-worldgraph/src/lib.rs`; `v2/crates/wifi-densepose-worldmodel/src/{bridge.rs,occupancy.rs}` (`worldgraph_to_occupancy`, `OccWorldBridge::predict`). The engine already upserts SLAM output and person tracks into the graph. Missing: prior-injection back into `ruvsense/pose_tracker.rs`, and the topology-change → ADR-135 recalibration edge.
|
||||
|
||||
**Contract sketch** (extends existing types):
|
||||
```rust
|
||||
impl StreamingEngine {
|
||||
/// PROPOSED: inject OccWorld priors into the next tracker cycle.
|
||||
pub fn apply_trajectory_priors(&mut self, priors: &[TrajectoryPrior]) -> Vec<WorldId>;
|
||||
}
|
||||
// WorldEdge gains (PROPOSED): PredictedBy { model_id: u16 } — prior provenance edge
|
||||
```
|
||||
|
||||
**Feasibility: HIGH.** This is mostly integration glue between tested crates. The two real risks are already named by ADR-143: no ground-truth oracle in a live home (mitigated by the v1-fixed / v2-flagged rollout, `#[cfg(feature = "rf-slam-v2")]`), and OccWorld's Python subprocess (ADR-147: 375 ms/inference) being off the deterministic path — priors must be treated as advisory, never witness-bearing (§5).
|
||||
|
||||
### P4 — Cross-modal distillation: camera-teacher → RF-student, privacy-preserving deployment (PROPOSED)
|
||||
|
||||
**What.** Train-time-only camera supervision: a vision pose teacher labels synchronized CSI (MM-Fi already provides paired modalities, ADR-015), distilling dense pose + uncertainty into the P1 encoder. Deployed systems ship **no camera and no camera-derived identity features**; the ADR-145 privacy-leakage metric (membership-inference score in `wifi-densepose-train/src/ablation.rs`) gates that the student does not retain identity.
|
||||
|
||||
**Why beyond SOTA.** Camera-supervised WiFi pose is the original DensePose-WiFi recipe; what is *not* published is distillation with a measured, CI-enforced privacy-leakage budget and a witnessed claim that the deployed artifact is camera-free. The beyond-SOTA move is making "privacy-preserving" a *measured property of the release pipeline*, not a marketing adjective.
|
||||
|
||||
**Builds on.** `v2/crates/wifi-densepose-train/src/{trainer.rs,losses.rs,dataset.rs}` (training substrate); ADR-015 paired datasets; ADR-145 `FeatureSet` matrix + privacy-leakage scalar; `v2/crates/wifi-densepose-bfld` (`privacy_gate.rs`, `signature_hasher.rs` — runtime identity controls, ADR-120 invariants I1–I3).
|
||||
|
||||
**Contract sketch** (in `wifi-densepose-train`, PROPOSED):
|
||||
```rust
|
||||
pub struct DistillationLoss { pub teacher: TeacherSource, pub temperature: f32, pub uq_transfer: bool }
|
||||
pub enum TeacherSource { CachedPoseLabels(PathBuf), /* never a live camera in the serving graph */ }
|
||||
/// Release gate: leakage(student) ≤ budget, asserted by the ADR-145 harness per variant.
|
||||
pub struct PrivacyBudget { pub max_mia_score: f32 }
|
||||
```
|
||||
|
||||
**Feasibility: HIGH.** All ingredients exist; the work is a loss term, a label cache format, and a CI gate. The honest caveat: MIA-based leakage scores are a lower bound on real leakage; the budget is a regression tripwire, not a formal guarantee.
|
||||
|
||||
### P5 — Swarm-distributed multistatic sensing with Raft-coordinated apertures (ADR-148, partially built)
|
||||
|
||||
**What.** Treat the drone swarm + fixed ESP32 mesh as one *reconfigurable multistatic aperture*: a Raft-elected cluster head plans node positions/channel assignments to maximize geometric diversity (GDI) for the current sensing task; per-node frames flow into the same `MultistaticFuser` path as fixed nodes.
|
||||
|
||||
**Why beyond SOTA.** Published multistatic WiFi sensing assumes fixed geometry. Closed-loop aperture optimization — moving the sensors to where the Fisher information is — driven by the GDI/Cramér–Rao machinery that already exists in `v2/crates/wifi-densepose-ruvector/src/viewpoint/geometry.rs` (per CLAUDE.md module table: `GeometricDiversityIndex`, Cramér-Rao bounds) is a genuinely new system class for SAR/MAT scenarios.
|
||||
|
||||
**Builds on.** `v2/crates/ruview-swarm/src/sensing/{multiview.rs,payload.rs,occworld_bridge.rs}`, `topology.rs`, `planning.rs`, `marl/` (MAPPO, `candle_ppo.rs`); `ruvsense/multistatic.rs` + `array_coordinator.rs` (ADR-138 clock-quality gating — moving nodes will stress exactly this); `wifi-densepose-mat` (the MAT use case).
|
||||
|
||||
**Contract sketch** (in `ruview-swarm`, PROPOSED):
|
||||
```rust
|
||||
pub trait AperturePlanner: Send + Sync {
|
||||
/// Given current twin + task, propose node placements maximizing expected GDI.
|
||||
fn plan(&self, twin: &WorldGraphSnapshot, task: &SwarmTask) -> Vec<(NodeId, Position3D)>;
|
||||
}
|
||||
// Output flows through Raft (topology.rs) as a normal SwarmTask; frames return as ArrayNodeInput.
|
||||
```
|
||||
|
||||
**Feasibility: MEDIUM.** Coordination, MARL, and fusion code exist and are tested; the hard physical problems are honest unknowns: airborne CSI phase stability (rotor vibration), clock sync across mobile nodes (ADR-138 gate will reject a lot initially), and ADR-148 §1.3's own regulatory scoping. Simulation-first via `ruview-swarm/src/evals.rs` + `bench_support.rs`; hardware validation is Phase 3.
|
||||
|
||||
### P6 — Continual / test-time adaptation with EWC-style forgetting control (PROPOSED on existing TTT)
|
||||
|
||||
**What.** Promote `rapid_adapt.rs` from a per-deployment trick to a managed continual-learning loop: TTT/entropy adaptation produces LoRA deltas on the P1 encoder; an EWC (elastic weight consolidation) penalty — **which does not exist in the workspace today** (no EWC match in `wifi-densepose-train/src/rapid_adapt.rs`) — anchors weights important to previously-validated environments; every adaptation step is versioned as a new `model_version` (u16, ADR-136 §2.2) and must re-pass the ADR-145 acceptance matrix before activation.
|
||||
|
||||
**Why beyond SOTA.** TTT papers adapt and hope; nothing published couples adaptation to a *deterministic regression gate with witness hashes*, where an adapted model that regresses tier or leaks identity is automatically rejected and the `model_version` provenance lets any semantic state be traced to the exact adaptation step.
|
||||
|
||||
**Builds on.** `v2/crates/wifi-densepose-train/src/rapid_adapt.rs` (`AdaptationLoss::ContrastiveTTT`, entropy-minimization variant — lines 8–16); LoRA adapters in `sensing-server/src/embedding.rs` (rank-4 `lora_1`/`lora_2`); ADR-027 MERIDIAN evaluator (`train/src/eval.rs`); ADR-146 §2 calibration-robustness loss.
|
||||
|
||||
**Contract sketch** (in `wifi-densepose-train`, PROPOSED):
|
||||
```rust
|
||||
pub struct EwcPenalty { pub fisher_diag: Vec<f32>, pub anchor: Vec<f32>, pub lambda: f32 }
|
||||
pub struct AdaptationStep {
|
||||
pub parent_model_version: u16, pub new_model_version: u16,
|
||||
pub loss: AdaptationLoss, pub ewc: Option<EwcPenalty>,
|
||||
pub acceptance: RuViewAcceptanceResult, // must be ≥ parent tier
|
||||
pub witness: [u8; 32], // hash of delta + acceptance
|
||||
}
|
||||
```
|
||||
|
||||
**Feasibility: HIGH.** EWC over a small LoRA delta is cheap (Fisher diagonal over the replay corpus); the acceptance gate and proof seeds exist (`proof.rs`, `PROOF_SEED = 42`). Risk: online Fisher estimation from unlabeled home data is noisy — start with adaptation restricted to LoRA parameters only, backbone frozen.
|
||||
|
||||
### P7 — On-device WASM edge inference with deterministic replay (extends existing Tier-3)
|
||||
|
||||
**What.** Push P1 head subsets (presence, vitals, coarse activity) into hot-loadable WASM modules on ESP32-S3, and onto browsers/workers via `wifi-densepose-wasm`. Every edge module's output is replayable: the same `CanonicalFrame` input bytes through the same module hash produce the same output bytes, verified in CI on x86_64/aarch64/wasm32.
|
||||
|
||||
**Why beyond SOTA.** Edge WiFi-sensing exists; *bit-deterministic, witness-hashed edge inference with hot-swap and replay parity against the server pipeline* does not appear in published systems. It turns the edge from a trust hole into a witness-chain extension.
|
||||
|
||||
**Builds on.** `v2/crates/wifi-densepose-wasm-edge/src/lib.rs` (WASM3 host ABI: `csi_get_*`, `on_frame` at ~20 Hz, ADR-040 Tier 3); `nvsim` as the proof that a no-std-time, no-OS-entropy, seeded-PRNG crate runs identically on wasm32 (`nvsim/src/lib.rs` doc); ADR-136 AC7 cross-architecture byte-stability test.
|
||||
|
||||
**Contract sketch** (PROPOSED additions to the wasm-edge host ABI):
|
||||
```rust
|
||||
// exports added to module lifecycle:
|
||||
// on_replay_begin(seed: u64) — pins any module-internal PRNG
|
||||
// witness_digest(buf_ptr: i32) -> i32 — module returns BLAKE3 of its output stream
|
||||
pub trait EdgeStage: Stage<CsiFrameView, EdgeEvent> { fn module_hash(&self) -> [u8; 32]; }
|
||||
```
|
||||
|
||||
**Feasibility: HIGH for presence/vitals heads, LOW for full pose on-ESP32.** WASM3 interpretation on Xtensa caps throughput; full 7-head inference stays on Pi/Hailo/browser. Float determinism across native vs WASM needs care (no fast-math, fixed reduction order — same obligation ADR-136 §3.2 already accepts).
|
||||
|
||||
### P8 — NV-magnetometry fusion: an honest classical-quantum hybrid (PROPOSED, simulation-first)
|
||||
|
||||
**What.** Add `nvsim`-modeled NV-magnetometer nodes as a *fourth sensing modality class* (after CSI, mmWave/ADR-021, BFLD) in the multistatic fusion: near-range (≤ tens of cm, per the physics review) cardiac/respiratory magnetic signatures fused with CSI/mmWave vitals under the ADR-137 evidence contract. Simulation-first: the modality lands end-to-end against `nvsim` before any hardware exists.
|
||||
|
||||
**Why beyond SOTA.** Not range — the Ghost Murmur review (`docs/research/quantum-sensing/16-ghost-murmur-ruview-spec.md`) documents why multi-mile cardiac magnetometry contradicts published physics, and this design adopts that conclusion. The beyond-SOTA element is architectural honesty: a fusion engine that can ingest a quantum-sensor modality with explicit, witnessed physics bounds (`nvsim`'s forward model states its approximations and hashes its output, `nvsim/src/proof.rs`), so that when real NV hardware matures, the integration path and the anti-hype guardrails already exist. No published consumer sensing stack has this.
|
||||
|
||||
**Builds on.** `v2/crates/nvsim/src/` (scene→source→attenuation→NV ensemble→digitiser, SHA-256 witness, ADR-089); `nvsim-server`; `wifi-densepose-vitals` (mmWave HR/BR — the modality NV would cross-validate); `ruvsense/multistatic.rs` fusion + ADR-137 `EvidenceRef`.
|
||||
|
||||
**Contract sketch** (PROPOSED): a `SensorModality::NvMagnetometer` variant on the existing `wifi-densepose-worldgraph` `SensorModality` enum, plus an `ArrayNodeInput` adapter from `nvsim` frames; vitals agreement/disagreement between NV and mmWave becomes an `EvidenceRef`/`ContradictionFlag` pair.
|
||||
|
||||
**Feasibility: HIGH in simulation, SPECULATIVE on hardware.** The sim path is days of glue; COTS NV magnetometers with the required sensitivity at consumer cost do not exist in 2026. This pillar's deliverable is the *contract and the simulated validation*, explicitly labeled as such.
|
||||
|
||||
---
|
||||
|
||||
## 4. Phased implementation plan
|
||||
|
||||
Phases are gated by the Pre-Merge Checklist (CLAUDE.md) and the witness chain (§5). Crate names per the ADR-136 §2.1 normative map — no new `ruview_*` crates except where a crate already exists (`ruview-swarm`).
|
||||
|
||||
**Phase 0 — Hardening (close the ADR-136 "integration glue" debt).**
|
||||
- `wifi-densepose-signal`: wire the full 600-frame `Stage`-chain replay (ADR-136 AC6) and register `streaming_engine_replay_v1` in `archive/v1/data/proof/expected_features.sha256`.
|
||||
- CI: cross-architecture witness matrix x86_64/aarch64 (AC7); add wasm32 lane for `nvsim` + `wifi-densepose-wasm`.
|
||||
- `wifi-densepose-engine`: populate `FrameMeta.calibration_id`/`model_id` from the live calibration and model-binding stages (currently defaulted — ADR-136 §8).
|
||||
- `homecore-recorder`: define the **replay corpus** format (canonical-bytes frame streams + witness manifest) that P4/P6 training and all ablations consume.
|
||||
|
||||
**Phase 1 — Encoder + measurement (P1, P4 groundwork, P6 skeleton).**
|
||||
- `wifi-densepose-nn`: `RfEncoder`/`TaskHead` traits, seven-head fan-out, UQ layer (ADR-146); relocate `ProjectionHead` from `sensing-server/src/embedding.rs`.
|
||||
- `wifi-densepose-train`: `ContrastiveBatcher`, ADR-150 loss stack, distillation loss + cached-teacher format (P4), `EwcPenalty` + `AdaptationStep` (P6); extend `ablation.rs` `FeatureSet` with per-head and per-pillar variants; pin `expected_ablation_*.sha256`.
|
||||
- Run the ADR-150 three-variant frozen-decoder experiment; promotion gate on cross-subject delta.
|
||||
|
||||
**Phase 2 — Closed loop + edge (P3, P7).**
|
||||
- `wifi-densepose-engine`: `apply_trajectory_priors` (OccWorld → `pose_tracker.rs`); `PredictedBy` provenance edge in `wifi-densepose-worldgraph`; topology-change → ADR-135 recalibration trigger.
|
||||
- `wifi-densepose-wasm-edge`: replay ABI (`on_replay_begin`, `witness_digest`), presence/vitals head modules; parity test vs server pipeline on identical canonical bytes.
|
||||
- Enable `rf-slam-v2` feature on the 7-day validation dataset (ADR-143 gate).
|
||||
|
||||
**Phase 3 — Frontier (P2, P5, P8).**
|
||||
- `wifi-densepose-signal/src/ruvsense/forward_model.rs`: Born/ray forward model seeded from `tomography.rs`; `PhysicsResidual` → `EvidenceRef`; synthetic-data generator into `train/src/dataset.rs`.
|
||||
- `ruview-swarm`: `AperturePlanner` over GDI (`ruvector/src/viewpoint/geometry.rs`); simulation evals in `evals.rs`; airborne CSI stability study before any hardware claim.
|
||||
- `nvsim` ↔ `wifi-densepose-engine`: `SensorModality::NvMagnetometer` adapter, simulated NV+mmWave vitals cross-validation in the ablation matrix.
|
||||
|
||||
---
|
||||
|
||||
## 5. Determinism & witness-chain preservation
|
||||
|
||||
The non-negotiable invariant (ADR-136 §2.5–2.6, ADR-028): replaying recorded canonical bytes through the pipeline twice yields byte-identical outputs and equal BLAKE3 witness hashes. Strategy per component class:
|
||||
|
||||
1. **Everything on the trust path implements `CanonicalFrame`.** New frame types (`MultiTaskOutput`, `PhysicsResidual`, `AdaptationStep`, edge events, NV frames) get fixed-field-order LE encodings and `witness_hash()`; encoders are the only serializers (no ad-hoc serde on the witness path).
|
||||
2. **Inference is witnessed by (input hash, model hash, output hash).** `model_id`/`model_version` on `FrameMeta` already bind frames to models; P1 adds a weights digest so the triple is closed. Pure-Rust f32 inference (ADR-146 ABI) with fixed reduction order; no GPU nondeterminism on the witness path — GPU/libtorch is training-only, and training determinism is pinned by the existing seeds (`proof.rs`: `PROOF_SEED = 42`, `MODEL_SEED = 0`).
|
||||
3. **Advisory vs witnessed split.** Components that cannot be made deterministic — the OccWorld Python subprocess (ADR-147), live MARL exploration, any future LLM/agent output (ADR-140 Ruflo) — are **advisory**: their outputs may bias estimates but never enter `to_canonical_bytes()` directly; instead the *decision to use them* is recorded (prior id + content hash) so replay reproduces the decision even if the producer cannot be re-run. The Kalman tracker consumes priors as explicit inputs recorded in the replay corpus.
|
||||
4. **Adaptation is a chain of witnessed steps.** P6's `AdaptationStep.witness` hashes (parent version ‖ delta ‖ acceptance result); the active model at any timestamp is reconstructible from the step chain — the model-weights analogue of the frame witness chain.
|
||||
5. **Edge parity.** P7 modules must produce the same `witness_digest` as the server-side reference implementation on the AC6 fixture; the module hash joins the firmware `source-hashes.txt` in the ADR-028 witness bundle.
|
||||
6. **Witness bundle growth is mechanical.** Each pillar adds expected-hash keys (`forward_model_residual_v1`, `edge_presence_replay_v1`, `nvsim` already ships `proof.rs`) to the existing `verify.py` chain rather than inventing new verification mechanisms.
|
||||
|
||||
---
|
||||
|
||||
## 6. Explicit non-goals
|
||||
|
||||
- **No workspace rename or rewrite.** Reaffirms ADR-136 §2.1/§4.1: no `ruview_*` crate prefix migration, no umbrella crate; pillars land inside the existing crates listed above.
|
||||
- **No full-wave Maxwell solver in the runtime loop.** P2 is first-order Born/ray, with the approximation order declared. "Physics-informed" never means FDTD at 20 Hz.
|
||||
- **No long-range cardiac magnetometry claims.** P8 is bounded by the physics review in `docs/research/quantum-sensing/16-ghost-murmur-ruview-spec.md`; ranges beyond published MCG physics are out of scope permanently, not just deferred.
|
||||
- **No camera in any deployed serving graph** (P4 teachers are train-time, cached-label only) and **no identity recognition as a product feature** — identity embeddings remain in-RAM, hash-rotated (ADR-120 invariants).
|
||||
- **No weaponization or LAWS capability in P5**, per ADR-148 §1.3; swarm work targets SAR/MAT and stays behind the ADR-148 regulatory gates.
|
||||
- **No fabricated benchmarks.** All pillar performance statements in this document are targets; promotion of any pillar requires the ADR-145 ablation matrix delta plus pinned determinism hashes, in CI, before any external claim.
|
||||
- **No new verification mechanisms.** The witness chain extends `verify.py` / BLAKE3 / `expected_*.sha256`; we do not introduce a second, parallel proof system.
|
||||
|
||||
---
|
||||
|
||||
## 7. Open questions for the next document in this series
|
||||
|
||||
1. Airborne CSI phase stability (P5): what does the ADR-138 clock-quality gate measure on a real quadrotor payload?
|
||||
2. Forward-model fidelity floor (P2): what Born-residual magnitude on the ADR-135 empty-room captures is "good enough" to be a useful contradiction signal?
|
||||
3. Replay-corpus governance (Phase 0): retention, privacy class of recorded canonical bytes, and consent — the recorder stores signal evidence, which is itself sensitive.
|
||||
@@ -0,0 +1,384 @@
|
||||
# Beyond-SOTA Validation, Test & Benchmark Methodology
|
||||
|
||||
**Series:** `docs/research/ruview-beyond-sota/` · Document 03
|
||||
**Date:** 2026-06-09
|
||||
**Scope:** How RuView proves (and gates) beyond-SOTA claims using the verification
|
||||
infrastructure that already exists in this repository. Every number below is sourced
|
||||
from a cited file in this repo; nothing is invented.
|
||||
|
||||
---
|
||||
|
||||
## 1. The Layered Validation Pyramid
|
||||
|
||||
Six layers, cheapest/most-deterministic at the bottom, most expensive/most-credible at
|
||||
the top. A beyond-SOTA claim must survive **every layer below it** before it may be
|
||||
published from the layer it lives at.
|
||||
|
||||
| Layer | What it proves | Tooling | Frequency | Determinism |
|
||||
|-------|----------------|---------|-----------|-------------|
|
||||
| **L0** Unit/integration tests | Code correctness | `cargo test --workspace --no-default-features` + pytest | per commit | exact |
|
||||
| **L1** Deterministic proof + witness bundle | Pipeline is real, unchanged, reproducible | `archive/v1/data/proof/verify.py`, `scripts/generate-witness-bundle.sh` | per merge / release | exact (SHA-256) |
|
||||
| **L2** Criterion micro-benchmarks | Compute latency only — never quality (ADR-149 §2) | 15 bench targets across `v2/crates/*/benches/` | nightly / pre-release | statistical |
|
||||
| **L3** Dataset-level accuracy eval | Pose/presence/vitals quality vs published SOTA | MM-Fi / Wi-Pose (ADR-015), `ruview_metrics.rs` tiers, ADR-145 ablation harness | per model release | seeded |
|
||||
| **L4** Hardware-in-loop | Real CSI on real ESP32, no mocks | COM9 (S3) / COM12 (C6) protocol, witness firmware hashes | per firmware release | A/B controlled |
|
||||
| **L5** Field trials / live capture | End-to-end behavior in a real room | live-session captures (e.g. `benchmark_baseline.json`) | campaign | statistical |
|
||||
|
||||
### 1.1 L0 — Workspace tests (current counts)
|
||||
|
||||
- ADR-028 audit (2026-03-01): **1,031 passed, 0 failed, 8 ignored** for
|
||||
`cargo test --workspace --no-default-features`
|
||||
(`docs/adr/ADR-028-esp32-capability-audit.md` §2).
|
||||
- Current `CHANGELOG.md` (Unreleased, cross-platform fix entry): **2,682 workspace
|
||||
tests pass / 0 fail on Windows** — the suite has more than doubled since the audit.
|
||||
- `CLAUDE.md` pre-merge gate still cites "1,031+ passed, 0 failed" as the floor.
|
||||
|
||||
**Rule:** the post-change test count may never be lower than the pre-change count, and
|
||||
failures must be 0. The witness bundle records the full log
|
||||
(`test-results/rust-workspace-tests.log`) and an aggregated `summary.txt`
|
||||
(`scripts/generate-witness-bundle.sh` step 3).
|
||||
|
||||
### 1.2 L1 — Deterministic proof ("Trust Kill Switch") + witness bundle
|
||||
|
||||
`archive/v1/data/proof/verify.py` (header comment): feeds 1,000 synthetic CSI frames
|
||||
(seed=42, `sample_csi_data.json`) through the **production** `CSIProcessor`
|
||||
(`src/core/csi_processor.py`), hashes the first 100 frames' feature output
|
||||
(`VERIFICATION_FRAME_COUNT = 100`), and compares against
|
||||
`archive/v1/data/proof/expected_features.sha256`.
|
||||
|
||||
- **Current published hash (file contents, verified during this investigation):**
|
||||
`f8e76f21a0f9852b70b6d9dd5318239f6b20cbcb4cdd995863263cecdc446f7a`
|
||||
- The hash is **environment-coupled** and has been legitimately regenerated before:
|
||||
ADR-028 §5.3 recorded `8c0680d7…` under numpy 2.4.2/scipy 1.17.1; `CHANGELOG.md`
|
||||
(#560 fix) recorded `667eb054…` after 6-decimal quantization + single-thread BLAS
|
||||
pinning (`OMP_NUM_THREADS=1` etc.). Each regeneration must follow the documented
|
||||
procedure: `python verify.py --generate-hash` then `python verify.py` → `VERDICT: PASS`.
|
||||
|
||||
`scripts/generate-witness-bundle.sh` packages: witness log + ADR-028, the Python proof
|
||||
(verify.py + expected hash + reference-signal metadata), full Rust test log + summary,
|
||||
the ADR-134 CIR proof, firmware source/binary SHA-256s, crate version manifest, npm
|
||||
tarball SHA-256, and a recipient-side `VERIFY.sh`.
|
||||
|
||||
**Accuracy note on check counts:** `CLAUDE.md` describes the recipient verification as
|
||||
"7/7 PASS"; the current `VERIFY.sh` embedded in the script performs **10** `check()`
|
||||
assertions (witness log, ADR, proof-hash file, tests, firmware hashes, crate manifest,
|
||||
npm manifest, Python proof, CIR proof, CIR hash file) but prints a hardcoded
|
||||
`"ALL CHECKS PASSED (8/8)"` string (`generate-witness-bundle.sh` line 293). The
|
||||
hardcoded count is stale relative to the actual check list — fix it to print
|
||||
`${PASS_COUNT}/${PASS_COUNT+FAIL_COUNT}` so the verdict can never silently desynchronize
|
||||
from the check inventory.
|
||||
|
||||
### 1.3 L2 — Criterion micro-benchmark inventory (all 15 targets)
|
||||
|
||||
All bench sources read directly. Per ADR-149 §2 these are **latency regression gates
|
||||
only, never quality evidence**.
|
||||
|
||||
| Bench target | Crate | Benchmark functions / groups | What it measures | Recorded value or in-source target (citation) |
|
||||
|---|---|---|---|---|
|
||||
| `engine_cycle.rs` | wifi-densepose-engine | `process_cycle_4nodes_56sc` | One full `StreamingEngine::process_cycle` (fuse + quality + calibration provenance + privacy gate + WorldGraph node), 4-node/56-subcarrier ESP32-S3 HT20 mesh | Budget: **50 ms** (20 Hz) — bench header |
|
||||
| `signal_bench.rs` | wifi-densepose-signal | `CSI Preprocessing`, `Phase Sanitization`, `Feature Extraction`, `Motion Detection`, `Full Pipeline` | SOTA signal stages (ADR-014) at varying frame sizes | no recorded baseline |
|
||||
| `cir_bench.rs` | wifi-densepose-signal | `cir_estimate` (HT20/HT40/HE20/HE40), `cir_estimate_12link`, `cir_estimator_new` | ADR-134 `CirEstimator::estimate()` per tier; 12-link multistatic amortization; cold-start | no recorded baseline |
|
||||
| `calibration_bench.rs` | wifi-densepose-signal | `bench_recorder_record`, `bench_recorder_finalize`, `bench_deviation`, `bench_record_600`, `bench_to_bytes` (K=52/114/242/484) | ADR-135 empty-room baseline recorder + deviation scoring | no recorded baseline |
|
||||
| `aether_prefilter_bench.rs` | wifi-densepose-signal | `aether_search_d…_n…_k…` (search vs prefilter) | ADR-084 Pass-2: `EmbeddingHistory::search_prefilter` vs brute force, prefilter_factor=8 | Pass: **≥4× at n=1024** — bench header |
|
||||
| `sketch_bench.rs` | wifi-densepose-ruvector | `compare_d128/256/512` × `float_l2`/`float_cosine`/`sketch_hamming` | ADR-084 sketch-vs-float per-pair compare cost (AETHER 128-d, spectrogram 256-d) | Pass: **sketch ≥8× faster** at every dim (ADR-084 threshold 8×–30×) — bench header |
|
||||
| `crv_bench.rs` | wifi-densepose-ruvector | `gestalt_classify_single/batch_100`, `sensory_encode_single`, `pipeline_full_session`, `convergence_two_sessions`, `crv_session_create`, `crv_embedding_dimension_scaling` (32/128/384), `crv_stage_vi_partition` | CRV integration throughput | no recorded baseline |
|
||||
| `inference_bench.rs` | wifi-densepose-nn | `tensor_ops` (relu/sigmoid/tanh), `densepose_inference`, `translator_inference`, `mock_inference`, `batch_inference` | NN forward-pass cost by input/batch size | no recorded baseline; **`mock_inference` group must never be quoted as a pipeline number** (§6) |
|
||||
| `training_bench.rs` | wifi-densepose-train | `interp_114_to_56_batch32`, `interp_scaling`, `compute_interp_weights_114_56`, `synthetic_dataset_get`, `synthetic_epoch`, `config_validate`, PCK over 100 samples | Training preprocessing + metrics hot paths; fixtures fully deterministic (no `rand`) — header | no recorded baseline |
|
||||
| `detection_bench.rs` | wifi-densepose-mat | `breathing_detection`, `heartbeat_detection`, `movement_classification`, `detection_pipeline`, localization (triangulation/depth), alert generation | MAT survivor-detection algorithms at varying signal lengths / noise | no recorded baseline |
|
||||
| `transport_bench.rs` | wifi-densepose-hardware | `beacon_serialize_16byte/28byte_auth/quic_framed`, `auth_beacon_verify`, `replay_window`, `framed_message` encode/decode, `secure_tdm_cycle` (manual vs QUIC) | TDM beacon crypto + transport | no recorded baseline |
|
||||
| `mqtt_throughput.rs` | wifi-densepose-sensing-server | `discovery::build_*`, `state::*`, `rate_limiter::allow_*`, `privacy::decide_*`, `semantic::bus_tick_all_10_primitives` | ADR-115 MQTT hot path | Targets (header): discovery **<5 µs**, state encode **<2 µs**, rate limit **<100 ns**, privacy **<50 ns**, bus tick **<10 µs** |
|
||||
| `swarm_bench.rs` | ruview-swarm | `marl_actor_inference`, `rrt_apf_100iter`, `multiview_fusion_3drones`, `demo_coverage_estimate`, `ppo_update_64transitions` | ADR-148 swarm control-loop compute | Measured: **3.3 µs / 43 µs / 54–58.5 ns / 100 ps / 248 µs** (ADR-149 §4.3; `CHANGELOG.md` Performance section) |
|
||||
| `pipeline_throughput.rs` | nvsim | `pipeline_run` (sample-count sweep), `witness::run` vs `run_with_witness` | NV-diamond sim throughput + witness overhead | Acceptance: **≥1 kHz** simulated samples/s on Cortex-A53-class CPU — bench header |
|
||||
| `state_machine.rs` | homecore | `set` first/warm/no-op, `get` hit/miss, `all_snapshot`, `all_by_domain_light_20_of_100`, `broadcast_fan_out` | HOMECORE state-machine hot paths | no recorded baseline |
|
||||
|
||||
**Honest gap — `benchmark_baseline.json` is not a criterion baseline.** The repo-root
|
||||
`benchmark_baseline.json` (369.9 KB) contains **1,566 live-capture samples** from a
|
||||
2-node session (fields: `tick`, `n_nodes`, `variance`, `motion`, `presence`,
|
||||
`confidence`, `est_persons`, `n_persons_rendered`, `kp_spread`, `rssi`) plus a summary
|
||||
block — it records **field-trial telemetry (L5)**, not micro-benchmark latencies.
|
||||
No file in the repo references it (`grep -rn benchmark_baseline` → 0 hits outside the
|
||||
file itself); its producer must be identified and committed (§5.3). Summary values
|
||||
(all from the file's `summary` object):
|
||||
|
||||
| Metric | Baseline value |
|
||||
|---|---:|
|
||||
| `total_frames` | 1,566 |
|
||||
| `presence_ratio` | 0.9336 (1,462/1,566 frames presence-true) |
|
||||
| `confidence_mean` | 0.6433 |
|
||||
| `variance_mean` / `variance_std` | 109.36 / 154.13 |
|
||||
| `kp_spread_mean` / `kp_spread_std` | 86.73 / 4.52 |
|
||||
| `person_count_changes` | 10 |
|
||||
|
||||
Criterion latencies that *have* been recorded live in ADR documents instead
|
||||
(ADR-147-benchmark-proof.md, ADR-149 §4.3, CHANGELOG Performance) — §5 below defines
|
||||
how to consolidate them into a real machine-readable criterion baseline.
|
||||
|
||||
### 1.4 L3 — Dataset-level accuracy evaluation
|
||||
|
||||
- **Datasets (ADR-015):** primary **MM-Fi** (40 subjects × 27 actions × ~320K frames,
|
||||
1TX×3RX, 114 subcarriers @100 Hz, 17-keypoint COCO + DensePose UV, CC BY-NC 4.0);
|
||||
secondary **Wi-Pose** (12 volunteers × 12 actions × 166,600 packets, 3×3, 30
|
||||
subcarriers). 114→56 subcarrier interpolation via `subcarrier.rs`; validation split =
|
||||
subjects 33–40 held out (ADR-015 Phase 1).
|
||||
- **Acceptance tiers:** `wifi-densepose-train/src/ruview_metrics.rs` —
|
||||
PCK@0.2 / OKS / MOTA / vitals rolled into `RuViewTier`
|
||||
(Fail/Bronze/Silver/Gold) (ADR-145 §1.1).
|
||||
- **Ablation harness (ADR-145):** 6-variant matrix (`csi_only`, `cir_only`,
|
||||
`csi_plus_cir`, `plus_doppler`, `plus_bfld`, `plus_uwb`-skipped), each variant
|
||||
producing acceptance tier + `SpecMetrics` (presence ≥0.90, localization ≤0.50 m,
|
||||
activity ≥0.70, FP ≤0.05, FN ≤0.10), `LatencyProfile` (p95 ≤100 ms), and
|
||||
`PrivacyLeakage` (MIA `leakage_score` ≤0.05), SHA-256-pinned per variant under
|
||||
`PROOF_SEED=42` (ADR-145 §2.2–2.6). Built at commit `0f336b7d3` (ADR-145
|
||||
implementation status); CLI auto-mode wiring is pending.
|
||||
- **Cross-environment:** ADR-027 MERIDIAN `CrossDomainEvaluator`
|
||||
(`wifi-densepose-train/src/eval.rs`) — `domain_gap_ratio`, extended by ADR-145
|
||||
`cross_room_degradation()` with a 17-joint PCK-delta heatmap.
|
||||
|
||||
### 1.5 L4 — Hardware-in-loop
|
||||
|
||||
- Real CSI nodes: ESP32-S3 on **COM9**, ESP32-C6 + MR60BHA2 on **COM12** (`CLAUDE.md`
|
||||
hardware table). ADR-018 binary frame protocol over UDP:5005 (ADR-028 §3.2/§3.4).
|
||||
- ADR-145 Tier-4 test (gated, `#[cfg(feature = "hardware-test")]`): replay a live 30 s
|
||||
COM9 capture through `csi_only` and `csi_plus_cir`; assert no presence regression and
|
||||
p95 < 100 ms.
|
||||
- A/B board protocol precedent (`CHANGELOG.md` #987): fixed vs unmodified control board
|
||||
against Apple-Watch ground truth (control pegged 40–49 BPM; fixed 88–91 vs 87 GT) —
|
||||
this fixed-board/control-board + external ground-truth pattern is the required design
|
||||
for all hardware vital-sign claims.
|
||||
- Witness bundle pins firmware: per-file SHA-256 of all sources + release binaries
|
||||
(`generate-witness-bundle.sh` step 5).
|
||||
|
||||
### 1.6 L5 — Field trials
|
||||
|
||||
Live multi-node sessions captured as JSONL/JSON with summary statistics —
|
||||
`benchmark_baseline.json` (§1.3) is the existing exemplar. ADR-149 §6 adds the seeded
|
||||
`evals/` episode harness (Stage 1 kinematic full-matrix, Stage 2 Gazebo/PX4 SITL on the
|
||||
3 median seeds) for the swarm domain.
|
||||
|
||||
---
|
||||
|
||||
## 2. Beyond-SOTA Acceptance Criteria per Capability Axis
|
||||
|
||||
A claim is "beyond SOTA" only with: a named external baseline, an exact metric and
|
||||
protocol match, the dataset/split named, the threshold pre-registered, and the
|
||||
statistical procedure of §3 followed. Current axes with measured status:
|
||||
|
||||
| Axis | Metric (exact) | Dataset / protocol | SOTA baseline | Beyond-SOTA threshold | Measured status (cited) |
|
||||
|---|---|---|---|---|---|
|
||||
| In-domain pose accuracy | torso-PCK@20: `‖pred−gt‖ ≤ 0.2·‖R-shoulder−L-hip‖` | MM-Fi `random_split` (ratio 0.8, seed 0) | MultiFormer **72.25%** (Table VII); CSI2Pose 68.41% | > 72.25% with 95% CI lower bound above it | Flagship **83.59%**; micro (75,237 params) **74.30%** (`docs/benchmarks/wifi-pose-efficiency-frontier.md`) |
|
||||
| Edge efficiency frontier | torso-PCK@20 at deployed precision + params + batch-1 latency | same | MultiFormer 72.25% at full size | Pareto-dominance: smaller **and** above 72.25% at the deployed precision | int8 73.5 KB **74.70%**; int4-QAT 36.7 KB **74.46%**; shipped int4 verified **74.08%**, 0.135 ms 1-thread x86 (same file) |
|
||||
| Cross-subject generalization | torso-PCK@20, official MM-Fi cross-subject split (256,608 train / 64,152 test) | leakage-free split | own zero-shot baseline 63.99% | ADR-150 §4 gate: **+≥6 pts cross-subject without losing >2 pts random-split** | Best zero-shot **64.92%** (mixup+TTA+3-seed); gate judged unreachable without new capture (ADR-150 §3.2) |
|
||||
| Few-shot calibration (deployment) | PCK@20 after K labeled in-room samples; adapter size | MM-Fi cross-subject & cross-environment splits | zero-shot (64% / 10.6%) | SOTA-level (≳72%) from ≤200 samples with ≤~11 KB per-room adapter | cross-subject ~**72%** @100–200 samples (3 seeds); cross-env **10.6→73.1%** @200, 60.1% @5 (ADR-150 §3.5–3.6) |
|
||||
| Swarm SAR localization | CEP50/CEP95 (m), GDOP-stratified | seeded episode distribution (ADR-149 §6), not single geometry | Wi2SAR **5 m** (arxiv 2604.09115, paper-to-paper) | CEP50 < 5 m, IQM over ≥10 seeds, 95% CI excluding 5 m | 1.732 m single synthetic geometry — graded **Low–Medium**, not yet claimable (ADR-149 §7) |
|
||||
| Swarm coverage | coverage-rate@240 s; time-to-95% | episode rollouts | Wi2SAR 160k m²/13.5 min | rollout (not analytic) mean+CI beating baseline | 223 s is an analytic estimate — graded **Low** (ADR-149 §7) |
|
||||
| Control-loop latency | criterion wall-clock | local hardware, named | 10 ms / 100 Hz budget | all stages ≪ budget | 3.3 µs MARL / 43 µs RRT-APF / 54 ns fusion / 248 µs PPO (ADR-149 §4.3) |
|
||||
| World-model trajectory | MDE (m) at 5-frame horizon | RuView CSI-derived occupancy | pre-fine-tune random-weight baseline 9.49 m MDE | **≤1.0 m (2.0 vox)** at 5-frame horizon (ADR-147 §5 target, cited in benchmark-proof §4) | 9.49 m / FDE 16.23 m random weights; 208.45 ms median latency on real CSI (ADR-147-benchmark-proof §4, §7) |
|
||||
| Privacy leakage | MIA `leakage_score = 2·(AUC−0.5)` | fixed replay, fixed-seed shadow classifier | chance (0) | ≤ **0.05** (attacker AUC ≤ 0.525) | gate defined, harness built (ADR-145 §2.3) |
|
||||
| Vitals (hardware) | BPM error vs wearable ground truth | live A/B board protocol | control board behavior | within physiological agreement of ground truth, stable spread | 88–91 BPM vs 87 GT, spread 59→0 (CHANGELOG #987) |
|
||||
|
||||
### Claim-language discipline (from ADR-149 §7 grading)
|
||||
|
||||
| Evidence | Permitted language |
|
||||
|---|---|
|
||||
| Single run / single geometry / analytic estimate | "directional", never "beats SOTA" |
|
||||
| Seeded multi-run with CIs vs paper baseline | "exceeds the published X result paper-to-paper" |
|
||||
| Same metric, same split, same protocol, CI excludes baseline | "beyond SOTA on <dataset>/<split>" |
|
||||
| No public leaderboard exists (swarm CSI-SAR) | never claim "leaderboard standing" (ADR-149 §3) |
|
||||
|
||||
---
|
||||
|
||||
## 3. Statistical Procedure for Honest Claims
|
||||
|
||||
Adopted from ADR-149 §5 (Agarwal 2021 / Gorsane 2022 standard) and the practices
|
||||
already used in ADR-150/efficiency-frontier measurements:
|
||||
|
||||
1. **Seeds.** ≥10 independent seeds for RL/episodic claims (ADR-149 §5); ≥3 seeds
|
||||
minimum for supervised dataset evals (ADR-150 §3.5 used 3 seeds; report all).
|
||||
Training seeds, eval seeds, and split files are versioned and committed.
|
||||
2. **Aggregate.** IQM (not mean/median) for episodic metrics + performance profiles;
|
||||
for dataset accuracy report mean across seeds with each seed's value listed.
|
||||
3. **Confidence intervals.** 95% stratified bootstrap, 1,000 resamples (ADR-149 §5;
|
||||
reference impl: `rliable`).
|
||||
4. **Paired comparisons.** When comparing model A vs B (e.g. `csi_plus_cir` vs
|
||||
`csi_only`, or ours vs a reproduced baseline), evaluate both on the **identical
|
||||
frozen test frames** and use a paired bootstrap over per-sample correctness
|
||||
(PCK hit/miss is per-joint binary — pair at the joint-sample level). For
|
||||
paper-to-paper comparisons where the baseline cannot be re-run, state so
|
||||
explicitly ("paper-to-paper", ADR-149 §2) and require the CI lower bound to clear
|
||||
the published point value.
|
||||
5. **Pre-registration.** The threshold lives in an ADR **before** the run
|
||||
(precedent: ADR-150 §4 gate written before §3.2 measurements; the measurements
|
||||
honestly reported the gate as not met).
|
||||
6. **Negative results are recorded.** ADR-150 §1/§3.2 keeps DANN-failed,
|
||||
capacity-hurts, and KD-didn't-help results in the record — required practice.
|
||||
7. **Eval episodes (swarm):** 50 fixed, versioned episodes per policy
|
||||
(10 victim layouts × 5 CSI-noise levels), ≥3 baselines (random walk,
|
||||
boustrophedon+triangulation, IPPO) (ADR-149 §5).
|
||||
8. **GDOP stratification** for any localization claim, so geometry artifacts cannot
|
||||
produce the headline (ADR-149 §6.3).
|
||||
|
||||
---
|
||||
|
||||
## 4. Regression-Gate Design (CI Enforcement)
|
||||
|
||||
### 4.1 Three gate classes, three tolerances
|
||||
|
||||
| Gate class | Source of truth | Tolerance | On breach |
|
||||
|---|---|---|---|
|
||||
| Determinism hashes | `expected_features.sha256`, `expected_cir_features.sha256`, `expected_calibration_features.sha256`, future `expected_ablation_<slug>.sha256` | **exact (0%)** | exit 1 = FAIL; exit 2 = SKIP only for placeholder hashes (proof.rs `0/1/2` convention, ADR-145 §2.4) |
|
||||
| Accuracy / quality metrics | per-variant canonical bytes, quantized 1e-3 (ADR-145 §2.6) | exact after quantization | FAIL CI; tier change requires ADR amendment |
|
||||
| Latency / throughput | criterion estimates JSON | **% tolerance per scale** (below) | FAIL on regression beyond tolerance; trend everything |
|
||||
|
||||
### 4.2 Criterion baseline file (replaces the current gap)
|
||||
|
||||
Today criterion numbers live in prose (ADR-147-benchmark-proof, ADR-149 §4.3,
|
||||
CHANGELOG). Formalize:
|
||||
|
||||
1. `cargo bench --workspace -- --save-baseline main` on a **named, fixed runner**
|
||||
(ADR-147 used RTX 5080 / specific host; record host + toolchain in the file).
|
||||
2. Export `target/criterion/*/estimates.json` point estimates into a committed
|
||||
`v2/benchmarks/criterion-baseline.json`: `{bench_id, crate, p50_ns, host, commit}`.
|
||||
3. CI compares new runs against it with scale-aware tolerance — wall-clock noise is
|
||||
proportionally larger at small magnitudes:
|
||||
|
||||
| Magnitude | Tolerance | Rationale |
|
||||
|---|---|---|
|
||||
| < 1 µs (e.g. fusion 54 ns, privacy decide <50 ns target) | ±25% | timer/jitter dominated |
|
||||
| 1 µs – 1 ms (MARL 3.3 µs, RRT-APF 43 µs, PPO 248 µs) | ±15% | criterion CI typically <5%, leave CI-runner headroom |
|
||||
| > 1 ms (engine cycle vs 50 ms budget, OccWorld ~209 ms) | ±10% **and** absolute budget (50 ms / 500 ms ADR-147 §6) | budgets are the contract |
|
||||
|
||||
4. Hard in-source acceptance thresholds remain authoritative regardless of baseline:
|
||||
sketch ≥8× (`sketch_bench.rs`), prefilter ≥4× (`aether_prefilter_bench.rs`),
|
||||
nvsim ≥1 kHz (`pipeline_throughput.rs`), MQTT header targets, ADR-145 p95 ≤100 ms.
|
||||
5. Latency stays **out of determinism hashes** (ADR-145 §2.6) but **in** the trended
|
||||
`summary.json`, so sub-threshold drift is visible (ADR-145 §3.2 mitigation).
|
||||
|
||||
### 4.3 Live-capture baseline gate (`benchmark_baseline.json`)
|
||||
|
||||
Adopt the file as the L5 regression anchor with documented provenance, then gate a
|
||||
re-capture of the same scenario (same 2-node placement, same room class) against the
|
||||
summary block:
|
||||
|
||||
| Field | Baseline | Suggested gate |
|
||||
|---|---:|---|
|
||||
| `presence_ratio` | 0.9336 | ≥ 0.90 for an occupied-room session |
|
||||
| `confidence_mean` | 0.6433 | within ±0.10 |
|
||||
| `kp_spread_std` | 4.52 | ≤ 2× baseline (skeleton stability) |
|
||||
| `person_count_changes` | 10 / 1,566 frames | ≤ 2× baseline (count flapping — see CHANGELOG #803/#894 clamp bugs this metric would have caught) |
|
||||
|
||||
Field-trial gates are **soft** (warn + require human sign-off), never auto-merge
|
||||
blockers — environments differ; the gate exists to force an explanation.
|
||||
|
||||
### 4.4 Wiring
|
||||
|
||||
Pre-merge (`CLAUDE.md` checklist): L0 + L1. Nightly: L2 criterion + ADR-145 Tier-3
|
||||
ablation matrix (minutes-scale, ADR-145 §3.2). Release: full witness bundle +
|
||||
`VERIFY.sh` + L4 on real COM-port hardware (`CLAUDE.md` firmware rule 6/7).
|
||||
|
||||
---
|
||||
|
||||
## 5. Reproducibility & External-Witness Requirements
|
||||
|
||||
Anyone outside the project must be able to re-run every claimed result:
|
||||
|
||||
1. **One command per layer.** `cargo test --workspace --no-default-features`;
|
||||
`python archive/v1/data/proof/verify.py`; `bash scripts/generate-witness-bundle.sh`
|
||||
then `bash VERIFY.sh` inside the bundle; per ADR-150 §4 every accuracy result needs
|
||||
"one-command reproduction" (efficiency frontier publishes its exact command:
|
||||
`python aether-arena/staging/train_efficiency_pareto.py npy/X.npy npy/Y.npy npy/split_random.npy`).
|
||||
2. **Pinned numerical environment.** The Python proof requires single-threaded BLAS
|
||||
(`OMP_NUM_THREADS=1`, `OPENBLAS_NUM_THREADS=1`, `MKL_NUM_THREADS=1`,
|
||||
`VECLIB_MAXIMUM_THREADS=1`, `NUMEXPR_NUM_THREADS=1`) and 6-decimal quantization
|
||||
(`HASH_QUANTIZATION_DECIMALS=6`) — the #560 fix in `CHANGELOG.md`; Rust proof
|
||||
runners use coarse u16 quantization at 1e-3 in natural order
|
||||
(`calibration_proof_runner.rs` pattern, ADR-145 §2.6) for libm portability.
|
||||
3. **Seeds are constants, committed:** `PROOF_SEED=42`, `MODEL_SEED=0`
|
||||
(`proof.rs`, ADR-015 Phase 5); dataset splits committed as `.npy`
|
||||
(`split_random.npy`); swarm configs as versioned YAML with all seeds (ADR-149 §5).
|
||||
4. **Artifacts carry hashes.** Published model artifacts include SHA-256 (HuggingFace
|
||||
`pose_micro_int4.npz`, sha256 `c03eeb…` — efficiency-frontier doc); witness bundle
|
||||
has a `MANIFEST.sha256` over every file; provenance fields
|
||||
(`replay_sha256`, `model_sha256`, `calibration_version`, `privacy_mode`) are bound
|
||||
into ablation proof hashes (ADR-145 §2.7) so a metric cannot be quoted without its
|
||||
exact model + calibration + privacy decision.
|
||||
5. **Hardware claims name the hardware.** ADR-147 records RTX 5080 / CUDA 12.8 /
|
||||
PyTorch 2.10.0; nvsim states the Cortex-A53 scaling caveat in the bench header;
|
||||
efficiency-frontier flags ARM validation as pending. Copy this discipline.
|
||||
6. **Witness rows.** Every new proof gains rows in `docs/WITNESS-LOG-028.md`
|
||||
(ADR-145 §5.3 adds W-39…W-41) and the bundle's `source-hashes.txt`.
|
||||
7. **Secret hygiene in evidence.** Bundle logs pass through
|
||||
`scripts/redact-secrets.py` (ADR-110 wave-5 incident note in
|
||||
`generate-witness-bundle.sh` step 4) — external evidence must never embed `.env`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Known Measurement Pitfalls (WiFi-sensing specific)
|
||||
|
||||
| # | Pitfall | Repo evidence | Mitigation in this methodology |
|
||||
|---|---|---|---|
|
||||
| 1 | **Subject leakage / split optimism.** In-domain `random_split` has temporal/subject-adjacency effects; the same model family scores 83.6% random-split but ~11.6% torso-PCK on the leakage-free cross-subject split | efficiency-frontier "Controlled claim" footnote; ADR-150 §1, §3.2 | Always report the split name; publish random-split and cross-subject numbers side by side; cross-subject claims only on the official split |
|
||||
| 2 | **Per-environment overfitting.** Zero-shot cross-environment collapses to 10.6%; subject-scaling saturates ~63.7% past 16–20 subjects because the residual is room/device shift | ADR-150 §3.3, §3.6 | Cross-room degradation + 17-joint heatmap in every ablation (ADR-145 §2.5); claim deployment accuracy only with the calibration protocol stated (K samples, adapter size) |
|
||||
| 3 | **Mock-mode contamination.** Mock firmware missed a real Kconfig threshold bug; the nn crate ships a `mock_inference` criterion group that must never be quoted as pipeline performance | `CLAUDE.md` firmware rule 7; `inference_bench.rs` `bench_mock_inference` | L4 mandatory before firmware release ("Always test with real WiFi CSI, not mock mode"); label mock benches in reports; ADR-147 §7 re-ran the benchmark on real CSI explicitly "no mocks" |
|
||||
| 4 | **Single-run point estimates.** 1.732 m localization from one synthetic geometry; 223 s coverage from an analytic formula | ADR-149 §1, §7 | §3 seed/CI protocol; evidence-grade table before publication |
|
||||
| 5 | **Random-weight / untrained baselines read as results.** OccWorld MDE 9.49 m is a pre-fine-tuning random-weight reading | ADR-147-benchmark-proof §4 | Label baseline-vs-target explicitly; never aggregate untrained-model numbers into capability claims |
|
||||
| 6 | **Latency conflated with quality.** Criterion µs numbers prove no compute bottleneck, nothing about accuracy | ADR-149 §2, §4.3 | L2 is gate-only; quality claims live in L3+ |
|
||||
| 7 | **Floating-point nondeterminism breaking proofs.** SciPy FFT SIMD reordering + multithreaded BLAS produced different hashes across CI microarchitectures | CHANGELOG #560; `calibration_proof_runner.rs` lines 1–13 (cited in ADR-145 §2.3) | Quantize before hashing; pin thread env vars; exclude wall-clock from hashes |
|
||||
| 8 | **Hash churn without procedure.** Three distinct historical values of the proof hash exist (`8c0680d7…` ADR-028, `667eb054…` CHANGELOG #560, `f8e76f21…` current file) | cited files | Every regeneration via `--generate-hash` + re-verify + CHANGELOG entry + witness bundle refresh |
|
||||
| 9 | **Aggregation bugs masking accuracy.** Person count clamped to 1 by EMA mapping; eigenvalue path leaking counts up to 10; both invisible to unit tests for months | CHANGELOG #803, #894 | L5 summary gates on `person_count_changes`/count distributions; convergence tests replaying the live loop |
|
||||
| 10 | **Stale verification claims.** `VERIFY.sh` prints hardcoded "(8/8)" over 10 actual checks; `CLAUDE.md` says "7/7" | `generate-witness-bundle.sh` line 293; `CLAUDE.md` | Compute the verdict count; audit doc claims against scripts each release |
|
||||
| 11 | **Licensing limits on the eval set.** MM-Fi is CC BY-NC — weights trained solely on it cannot back commercial claims | ADR-015 Consequences | Track dataset license alongside every published number |
|
||||
|
||||
---
|
||||
|
||||
## 7. Gap List (what must be built to fully execute this methodology)
|
||||
|
||||
| Gap | Owner layer | Source |
|
||||
|---|---|---|
|
||||
| Machine-readable criterion baseline (`v2/benchmarks/criterion-baseline.json`) + CI comparison job | L2 | §4.2 (numbers currently only in ADR prose) |
|
||||
| Provenance + producer script for `benchmark_baseline.json`; soft-gate job | L5 | §1.3, §4.3 (zero code references today) |
|
||||
| `ruview-cli --ablation mode=auto` wiring + `expected_ablation_<slug>.sha256` (currently placeholders → exit 2) | L3 | ADR-145 implementation status |
|
||||
| Seeded swarm `evals/` harness + `evals/RESULTS.md` internal leaderboard | L3/L5 | ADR-149 §6, §8 open issues |
|
||||
| Fix `VERIFY.sh` hardcoded verdict count; reconcile `CLAUDE.md` "7/7" | L1 | §1.2 |
|
||||
| Curated paired room-A/room-B labeled replay set (frozen, SHA-pinned, never trained on) | L3 | ADR-145 §3.2 |
|
||||
| ARM/edge on-device latency validation for the int4 model (x86-only today) | L4 | efficiency-frontier doc ("Pi fleet pending") |
|
||||
| Bench validation of the antenna-placement matrix on real hardware | L4 | PRODUCTION-ROADMAP.md Tier 2.3 |
|
||||
|
||||
---
|
||||
|
||||
## Update — falsifiable occupancy benchmark implemented
|
||||
|
||||
`wifi-densepose-train::occupancy_bench` (added this branch) makes the
|
||||
presence/person-count claim **falsifiable in code**, directly enforcing the L3
|
||||
discipline above. It grades predictions vs ground truth and gates a SOTA claim
|
||||
behind a single `claim_allowed` invariant that requires **all** of:
|
||||
|
||||
1. `DataProvenance::Measured` — synthetic/mock data is scorable for regression
|
||||
but **never claimable** (anti-mock-contamination; the CLAUDE.md Kconfig-bug
|
||||
lesson made structural).
|
||||
2. A leak-free `EvalSplit` — `validate()` refuses any split where a subject *or*
|
||||
environment id appears in both train and test (subject leakage / per-env
|
||||
overfitting).
|
||||
3. `n_test ≥ min_test_samples` (small-N guard).
|
||||
4. Presence F1 whose **bootstrap-CI lower bound** (deterministic splitmix64,
|
||||
seeded) clears the threshold — not the point estimate.
|
||||
5. Count MAE within threshold.
|
||||
|
||||
The claim string is unreadable except through the gate (returns `NO_CLAIM`
|
||||
otherwise) — same discipline as the `ruview-gamma` acceptance gate. 10 tests
|
||||
cover each refusal path. What remains is *data*, not *method*: feed it a frozen,
|
||||
SHA-pinned, subject/environment-disjoint **measured** replay set (the curated
|
||||
room-A/room-B item above) and the "beyond SOTA" claim becomes a passing or
|
||||
failing test, not a slogan.
|
||||
|
||||
---
|
||||
|
||||
*All values cited from: `benchmark_baseline.json`, `v2/crates/*/benches/*.rs` (15
|
||||
files), `docs/adr/ADR-147-benchmark-proof.md`,
|
||||
`docs/adr/ADR-149-swarm-benchmarking-evaluation-methodology.md`,
|
||||
`docs/adr/ADR-145-ablation-eval-harness-privacy-leakage.md`,
|
||||
`docs/adr/ADR-028-esp32-capability-audit.md`,
|
||||
`docs/adr/ADR-015-public-dataset-training-strategy.md`,
|
||||
`docs/adr/ADR-150-rf-foundation-encoder.md`,
|
||||
`docs/benchmarks/wifi-pose-efficiency-frontier.md`,
|
||||
`scripts/generate-witness-bundle.sh`, `archive/v1/data/proof/verify.py`,
|
||||
`archive/v1/data/proof/expected_features.sha256`, `CHANGELOG.md`, `CLAUDE.md`,
|
||||
`docs/research/sota-2026-05-22/PRODUCTION-ROADMAP.md`.*
|
||||
@@ -0,0 +1,252 @@
|
||||
# RuView Beyond-SOTA — 04: Performance Review & Optimization Roadmap
|
||||
|
||||
**Scope:** the streaming sensing pipeline (CSI ingest → multistatic fusion → CIR gate →
|
||||
pose publish) in `v2/`, hot-path crates `wifi-densepose-signal` (ruvsense),
|
||||
`wifi-densepose-engine`, `wifi-densepose-ruvector`, plus build-profile and edge-target
|
||||
(Pi 5-class, WASM) considerations.
|
||||
|
||||
**Hard constraint (non-negotiable):** the witness chain (ADR-028, ADR-136 §2.5 replay
|
||||
contract, ADR-137 §2.7 BLAKE3 witness in
|
||||
`v2/crates/wifi-densepose-engine/src/lib.rs:437-448`) requires **bit-exact deterministic
|
||||
float output**. Every recommendation below is tagged with its determinism risk. Anything
|
||||
that reorders float additions, enables FMA contraction, fast-math, or parallel reduction
|
||||
**changes the witness hash** and requires a coordinated proof-hash regeneration
|
||||
(`verify.py --generate-hash`) plus witness-bundle re-issue.
|
||||
|
||||
---
|
||||
|
||||
## 1. What we actually have measured (and what we don't)
|
||||
|
||||
`/home/user/RuView/benchmark_baseline.json` is a **signal-quality soak baseline**, not a
|
||||
latency benchmark: 1,566 samples (ticks 51131–52395) of
|
||||
`variance / motion / presence / confidence / est_persons / kp_spread / rssi`, with a
|
||||
summary block (`confidence_mean: 0.643`, `presence_ratio: 0.934`,
|
||||
`kp_spread_mean: 86.7`, `person_count_changes: 10`). **It contains zero timing data.**
|
||||
It is the accuracy guardrail for any optimization (post-change soak must reproduce these
|
||||
distributions), not a latency baseline.
|
||||
|
||||
Latency benchmarks exist but no committed results were found in the repo:
|
||||
|
||||
| Bench | File | What it measures |
|
||||
|---|---|---|
|
||||
| `process_cycle_4nodes_56sc` | `v2/crates/wifi-densepose-engine/benches/engine_cycle.rs:34-48` | One full engine cycle, 4 nodes × 56 subcarriers, vs. the documented 50 ms budget (`engine_cycle.rs:3-6`) |
|
||||
| `cir_bench` | `v2/crates/wifi-densepose-signal/benches/cir_bench.rs` | `CirEstimator::estimate()` per tier (HT20/HT40/HE20/HE40) + 12-link amortization |
|
||||
| `sketch_bench` | `v2/crates/wifi-densepose-ruvector/benches/sketch_bench.rs:86-175` | Hamming sketch vs. float L2/cosine compare; top-K over 1,024-sketch bank |
|
||||
| `signal_bench`, `calibration_bench`, `aether_prefilter_bench` | `v2/crates/wifi-densepose-signal/benches/` | Signal-path and ADR-135 calibration throughput |
|
||||
|
||||
**Action zero of the roadmap is to run these on a Pi 5 and commit the criterion
|
||||
baselines.** All impact classes below are derived from operation counts read out of the
|
||||
code (cited), not invented measurements.
|
||||
|
||||
---
|
||||
|
||||
## 2. Latency budget model — streaming pipeline
|
||||
|
||||
Two clock domains exist and must not be conflated:
|
||||
|
||||
- **TDMA sensing cycle: 20 Hz / 50 ms** — the architecture's own budget
|
||||
(`v2/crates/wifi-densepose-signal/src/ruvsense/mod.rs:5`, `RuvSenseConfig::target_hz =
|
||||
20.0` at `mod.rs:258`, and the bench doc `engine_cycle.rs:3`).
|
||||
- **CSI ingest: 100 Hz per node** — raw frames arrive ~5× faster than the fused output
|
||||
rate; per-frame ingest work (parse, normalize, calibrate, window) must therefore fit a
|
||||
**10 ms** per-frame envelope while the fused path fits **< 50 ms end-to-end**.
|
||||
|
||||
Proposed per-stage budget for the 50 ms end-to-end target (4 nodes, HT20 / 56
|
||||
subcarriers — the configuration the engine bench encodes):
|
||||
|
||||
| # | Stage | Code | Budget | Risk (from code reading) |
|
||||
|---|---|---|---|---|
|
||||
| 1 | Ingest + hardware normalize (per 100 Hz frame) | `hardware_norm`, `multiband.rs` | 2 ms | Low — vector ops on 56 floats |
|
||||
| 2 | Calibration apply (ADR-135) | `ruvsense/calibration.rs` | 2 ms | Low — Welford lookups |
|
||||
| 3 | Phase alignment | `phase_align.rs:117-152` | 1 ms | Low — ≤ 20 iterations over ≤ 17 static subcarriers (`config.max_iterations: 20`, `phase_align.rs:57`); allocation churn only (§3) |
|
||||
| 4 | Multistatic fusion (attention + softmax) | `multistatic.rs:512-598` | 2 ms | Low — O(nodes × 56); but does duplicate work in `fuse_scored` (§3, F2) |
|
||||
| 5 | **CIR gate (ISTA L1)** | `multistatic.rs:440-475` → `cir.rs:601-654` | 15 ms | **HIGH** — dominant cost, scales badly with PHY tier (below) |
|
||||
| 6 | Coherence score + gate decision | `coherence.rs`, `coherence_gate.rs` | 2 ms | Low — z-scores over 56 subcarriers |
|
||||
| 7 | Tomography (ADR-030 tier 2, when enabled) | `tomography.rs:236-323` | 8 ms | **Medium** — per-iteration allocation + loose step size (§3, F8/F9) |
|
||||
| 8 | Pose tracker (17-kp Kalman + re-ID) | `pose_tracker.rs` | 8 ms | Medium — sketch prefilter (ADR-084) already mitigates the re-ID scan |
|
||||
| 9 | Engine: quality score, privacy gate, WorldGraph node, BLAKE3 witness | `engine/src/lib.rs:304-368` | 5 ms | Low per cycle, but **unbounded memory growth** (§4) |
|
||||
| 10 | Publish (WS/serde) | sensing-server | 5 ms | Low |
|
||||
| | **Total** | | **50 ms** | |
|
||||
|
||||
### Why stage 5 is the at-risk stage — operation counts from the code
|
||||
|
||||
`ista_solve` (`cir.rs:601-654`) runs **two dense complex mat-vecs per iteration**
|
||||
(`matvec_phi` at `cir.rs:717-726`, `matvec_phi_h` at `cir.rs:730-745`), each O(K·G)
|
||||
complex MACs (≈ 8 FLOPs each), up to `max_iters: 100` (`cir.rs:176`). Per
|
||||
`CirConfig` (`cir.rs:164-233`):
|
||||
|
||||
| Tier | K (active) | G (taps) | FLOPs/iter (2·K·G·8) | FLOPs @100 iters |
|
||||
|---|---|---|---|---|
|
||||
| HT20 | 52 | 156 | ≈ 0.13 M | ≈ 13 M |
|
||||
| HT40 | 114 | 342 | ≈ 0.62 M | ≈ 62 M |
|
||||
| HE20 | 242 | 726 | ≈ 2.8 M | ≈ 0.28 G |
|
||||
| HE40 | 484 | 1,452 | ≈ 11.2 M | ≈ 1.1 G |
|
||||
|
||||
HT20 fits the 15 ms budget comfortably on a Pi 5; **HE40 at worst-case iteration count
|
||||
is ~1.1 GFLOP of scalar, cache-unfriendly work per estimate and will not fit any 50 ms
|
||||
budget without structural change** (F4 below). Today the gate runs once per cycle on the
|
||||
first link only (`multistatic.rs:452-463`), which contains the damage; the 12-link
|
||||
amortization pattern in `cir_bench.rs` shows the intended scale-up, which multiplies
|
||||
this cost ×12.
|
||||
|
||||
---
|
||||
|
||||
## 3. Findings table — optimization opportunities
|
||||
|
||||
Impact: relative cycle-time/memory effect at the 4-node HT20 operating point unless
|
||||
noted. Determinism: **EXACT** = bit-identical output guaranteed; **TIE** = only
|
||||
tie-breaking/ordering may differ; **CHANGES-FLOATS** = output bits change, witness/proof
|
||||
hash must be regenerated.
|
||||
|
||||
| ID | Finding (file:line) | Impact | Effort | Determinism |
|
||||
|---|---|---|---|---|
|
||||
| F1 | `FusedSensingFrame` deep-copies every input frame each cycle: `node_frames: node_frames.to_vec()` (`multistatic.rs:282`) — clones all per-node amplitude+phase vectors per 50 ms cycle even when downstream geometry consumers don't need them | Med | Low (Arc/Cow or borrow) | EXACT |
|
||||
| F2 | `fuse_scored` re-derives the per-node amplitude views and recomputes `node_attention_weights` after `fuse` already computed them inside `attention_weighted_fusion` (`multistatic.rs:311-321` duplicating `multistatic.rs:520`) — full cosine-sim + softmax done twice per cycle | Low-Med | Low (return weights from `fuse`) | EXACT (same math, computed once) |
|
||||
| F3 | CIR gate rebuilds a heap `CsiFrame` per cycle: `build_csi_frame_from_channel` allocates an `Array2<Complex64>` and converts amplitude/phase via `from_polar` per subcarrier (`multistatic.rs:488-506`, called from `multistatic.rs:462`), then `extract_csi_vector` converts back to `Complex32` (`cir.rs:505-530`) — f32→f64→f32 round-trip plus two allocations purely as glue | Med | Med (give `CirEstimator` a slice-based entry point) | EXACT if conversions reproduce exactly (f32→f64 is lossless; `from_polar` in f64 then truncate ≠ f32 polar — keep the f64 intermediate to stay exact, or accept CHANGES-FLOATS and regenerate hashes) |
|
||||
| F4 | ISTA inner loop uses dense O(K·G) mat-vecs (`cir.rs:717-745`) although Φ is a sub-sampled DFT (`cir.rs:539-558`) — the products Φx and Φᴴr are computable via an FFT of length G in O(G log G), an ~8–40× FLOP cut at HE20/HE40 (table §2) | **High** (the only path to HE40 real-time) | High | **CHANGES-FLOATS** (different summation order than the sequential dot product) — must ship behind a feature flag, A/B against `cir_proof_runner`, regenerate `expected_features.sha256` + witness bundle |
|
||||
| F5 | `neumann_warm_start` recomputes the diagonal of ΦᴴΦ with a full K×G pass **per frame** (`cir.rs:676-681`), rebuilds the COO→CSR diagonal matrix per frame (`cir.rs:683-685`), and collects `rhs_re`/`rhs_im` Vecs per frame (`cir.rs:689-690`) — yet `diag` depends only on Φ, which is fixed at `CirEstimator::new` | Med | Low (precompute diag+CSR in `new()`) | EXACT (same values, computed once) |
|
||||
| F6 | `phase_variance` collects a `Vec<f32>` of phases per call (`cir.rs:792`) — replaceable by a two-pass loop with zero allocation | Low | Low | EXACT |
|
||||
| F7 | Φ and Φᴴ are both stored densely (`cir.rs:546-547`): 2·K·G·8 bytes — Φᴴ entries are just conjugates of Φ (`cir.rs:555`), so a transposed-iteration kernel over Φ alone halves the footprint (HE40: 11.2 MB → 5.6 MB) | Low (latency) / Med (memory §4) | Med | EXACT (conjugation is exact; keep identical accumulation order in the transposed kernel) |
|
||||
| F8 | Tomography allocates the gradient vector **inside** the solver iteration loop: `let mut gradient = vec![0.0_f64; self.n_voxels]` (`tomography.rs:266`) — one heap alloc + zeroing per iteration, up to `max_iterations: 100` (`tomography.rs:75`); hoist and `fill(0.0)` | Med (for tier-2 deployments) | Low | EXACT |
|
||||
| F9 | Tomography step size uses the Frobenius-norm upper bound for the Lipschitz constant (`tomography.rs:253-259`, comment admits `‖WᵀW‖ ≤ ‖W‖_F²`) — a bound loose by up to the matrix rank, forcing proportionally more ISTA iterations than the power-method estimate used in `cir.rs:566-590` | Med | Low (reuse the cir.rs power-method pattern) | **CHANGES-FLOATS** (different step ⇒ different iterate path) |
|
||||
| F10 | `apply_phase_correction` clones the amplitude vector and allocates a fresh corrected-phase Vec per channel per cycle (`phase_align.rs:258-268`, `frame.amplitude.clone()` at `phase_align.rs:264`); `align` additionally `frames.to_vec()`s on the single-channel path (`phase_align.rs:128`) — an in-place `align_mut` avoids all of it | Low-Med | Low | EXACT |
|
||||
| F11 | Static-subcarrier selection fully sorts all subcarriers by variance (`phase_align.rs:180`) where `select_nth_unstable_by` suffices — trivial at 56 subcarriers, relevant at HE tiers (242–484) | Low | Low | **TIE** (equal-variance ties may select a different subcarrier set; pin a stable tie-break on index to stay EXACT) |
|
||||
| F12 | Engine clones each node's amplitude vector for the array coordinator every cycle: `cf.amplitude.clone()` (`engine/src/lib.rs:385`); also allocates a `Vec<Option<CalibrationId>>` per cycle (`lib.rs:293`) and `format!("{e:?}")` strings for every evidence ref (`lib.rs:337`) | Low | Low | EXACT |
|
||||
| F13 | `fuse_scored_calibrated` computes the modal calibration id in O(n²) (`multistatic.rs:404-410`) — harmless at n ≤ 15 nodes, noted for swarm-scale reuse (ADR-148) | Low | Low | EXACT |
|
||||
| F14 | **No `rayon` and no SIMD feature exists anywhere in the hot crates** (grep over `crates/*/Cargo.toml`: zero hits for rayon/simd/target-feature outside wasm-opt flags). The 12-link CIR pattern (`cir_bench.rs:4-5`) and the per-node ingest path are embarrassingly parallel **across independent links/nodes** | High (multi-link tiers) | Med | **EXACT if and only if** parallelism stays at link/node granularity with results collected in deterministic (index) order and no shared float accumulator; intra-link parallel reductions are CHANGES-FLOATS and are banned |
|
||||
| F15 | `Cir::top_k_taps` clones and fully sorts all G taps (`cir.rs:322-332`) — O(G log G) with a G-sized clone; a k-heap (the exact pattern already written in `sketch.rs:546-563`) is O(G log k) | Low | Low | TIE (equal-magnitude ordering; pin index tie-break) |
|
||||
| F16 | Core `CsiFrame` carries `Complex64` while the entire ruvsense DSP path computes in f32 (conversion at `cir.rs:525`) — 2× memory and bandwidth on every ingest for precision the pipeline immediately discards | Med (memory/bandwidth) | High (core type change ripples everywhere) | **CHANGES-FLOATS** at the boundary; defer until a major version |
|
||||
| F17 | Sketch path is already well-optimized: heap-based top-K with n ≤ k fast path (`sketch.rs:536-569`), 28-byte wire format (`sketch.rs:303`). Remaining win is build-level: `count_ones()` only lowers to POPCNT/NEON-vcnt when the target CPU enables it (see §5) | Low | Low | EXACT (integer ops) |
|
||||
|
||||
---
|
||||
|
||||
## 4. Memory-footprint analysis (Pi 5-class and WASM; ESP32 aggregation out of scope)
|
||||
|
||||
**Static, per-process (from struct definitions):**
|
||||
|
||||
| Component | Sizing source | Footprint |
|
||||
|---|---|---|
|
||||
| `CirEstimator` HT20 (Φ + Φᴴ, `Complex32`) | `cir.rs:546-547`, K=52 G=156 | 2 · 52 · 156 · 8 B ≈ **130 KB** |
|
||||
| `CirEstimator` HE20 | K=242 G=726 | ≈ **2.8 MB** |
|
||||
| `CirEstimator` HE40 | K=484 G=1452 | ≈ **11.2 MB** (halvable via F7) |
|
||||
| Tomography weight matrix | `tomography.rs:214-217`, sparse per-link (voxel,weight) pairs; default grid 8×8×4 = 256 voxels (`tomography.rs:70-73`) | tens of KB at default grid |
|
||||
| Sketch bank, 1,024 × 128-d | `sketch.rs` 1 bit/dim | 1,024 · 16 B ≈ **16 KB** (vs 512 KB float) |
|
||||
|
||||
A Pi 5 (4–8 GB) absorbs all of this trivially. The real memory risks are dynamic:
|
||||
|
||||
1. **Unbounded WorldGraph growth (the one genuine leak-class issue).** Every
|
||||
`process_cycle` appends a `SemanticState` node plus a `DerivedFrom` edge
|
||||
(`engine/src/lib.rs:346-352`), and change-points append `Event` nodes
|
||||
(`lib.rs:422-428`). At 20 Hz that is **1.73 M nodes/day** with no eviction anywhere
|
||||
in the engine. `snapshot_json` (`lib.rs:191-193`) then serializes the whole graph.
|
||||
**Required:** a retention/compaction policy (ring buffer or time-windowed rollup of
|
||||
SemanticStates). Determinism caveat: eviction changes snapshot *contents* (a product
|
||||
decision), not float math — the per-cycle witness (`lib.rs:437-448`) is unaffected.
|
||||
2. **Per-cycle allocation churn** (F1, F3, F5, F8, F10, F12): at 20 Hz this is dozens of
|
||||
short-lived heap allocations per cycle. On a Pi 5 this is allocator pressure and
|
||||
cache pollution rather than RSS growth; on WASM (bump-ish dlmalloc, no MADV_FREE) it
|
||||
inflates the linear memory high-water mark, which is never returned to the host.
|
||||
3. **WASM targets.** `wifi-densepose-wasm` is a browser binding crate (JS interop,
|
||||
serde, chrono — `crates/wifi-densepose-wasm/Cargo.toml`) and pulls `wifi-densepose-mat`
|
||||
optionally; it relies on `wasm-opt -O4` (`Cargo.toml` `[package.metadata.wasm-pack]`).
|
||||
`wifi-densepose-wasm-edge` is the disciplined one: `no_std` + `libm`, its own profile
|
||||
`opt-level = "s"`, lto, cgu=1 (`crates/wifi-densepose-wasm-edge/Cargo.toml`). Neither
|
||||
enables `+simd128` (§5). If the CIR estimator is ever compiled to wasm-edge, HE40's
|
||||
11.2 MB of sensing matrix alone is ~700 pages of linear memory — restrict edge WASM
|
||||
to HT20 (130 KB) or ship F4/F7 first.
|
||||
|
||||
---
|
||||
|
||||
## 5. Build-profile review & recommendations
|
||||
|
||||
Current release profile (`v2/Cargo.toml:213-218`) is already aggressive and correct:
|
||||
`opt-level = 3`, `lto = true` (fat), `codegen-units = 1`, `panic = "abort"`,
|
||||
`strip = true`; `bench` inherits release with debug symbols (`v2/Cargo.toml:225-227`).
|
||||
There is nothing wrong to fix here — the gains left are target- and feedback-driven:
|
||||
|
||||
1. **Per-target CPU tuning (EXACT, do first).** No `target-cpu` is set anywhere. For
|
||||
Pi 5 fleet builds: `RUSTFLAGS="-C target-cpu=cortex-a76"` — enables NEON scheduling
|
||||
and `vcnt` for the sketch path (F17) without changing IEEE semantics. LLVM does not
|
||||
reassociate float reductions or contract to FMA without explicit fast-math/contract
|
||||
flags, so scalar float results stay bit-exact. **Verify with the existing proof
|
||||
runners** (`cir_proof_runner`, `calibration_proof_runner`,
|
||||
`signal/Cargo.toml`) as the acceptance gate — that is exactly what they exist for.
|
||||
2. **WASM SIMD.** Add `-C target-feature=+simd128` for `wifi-densepose-wasm` builds and
|
||||
keep a non-SIMD artifact for older runtimes. Same determinism note as above; gate
|
||||
with the proof runners compiled to wasm where feasible.
|
||||
3. **PGO: feasible and determinism-safe.** PGO changes inlining/layout, never FP
|
||||
semantics. The repo already has ideal deterministic training workloads: the proof
|
||||
runner binaries plus `engine_cycle` / `cir_bench`. Pipeline: `cargo pgo build` →
|
||||
run proof runners + benches → `cargo pgo optimize`. Expect mid-single-digit to ~15%
|
||||
on branchy paths (gate decisions, tracker lifecycle); the dense ISTA loop will see
|
||||
little. Cost: CI complexity. Verdict: do it after F1–F12, not before.
|
||||
4. **Do not** enable `-ffast-math`-equivalents (`fadd_fast`, `core::intrinsics`,
|
||||
`-C llvm-args=-fp-contract=fast`) anywhere in the witness path. This must be a
|
||||
stated rule in CONTRIBUTING/ADR, not tribal knowledge.
|
||||
5. **BOLT / `opt-level` experiments are not worth it** ahead of F4; the pipeline is
|
||||
FLOP-bound in one loop, not front-end bound.
|
||||
|
||||
---
|
||||
|
||||
## 6. Prioritized 90-day plan
|
||||
|
||||
### Phase 0 — Measure (days 1–10)
|
||||
- Run and commit criterion baselines on a Pi 5 and an x86 dev box:
|
||||
`engine_cycle`, `cir_bench` (all four tiers), `sketch_bench`, `signal_bench`,
|
||||
`calibration_bench`. The 50 ms claim in `engine_cycle.rs:3` becomes a measured number.
|
||||
- Add a lightweight per-stage timing histogram (feature-gated, off in witness builds) at
|
||||
the §2 stage boundaries; wire a CI perf-regression gate (±10%) on the committed
|
||||
baselines.
|
||||
- Re-run the soak that produced `benchmark_baseline.json` and pin it as the accuracy
|
||||
guardrail for everything below.
|
||||
|
||||
### Phase 1 — Exact, zero-risk wins (days 10–35)
|
||||
All EXACT findings; no witness impact; each lands with proof-runner verification:
|
||||
- F5 (precompute warm-start diag/CSR in `CirEstimator::new`) — biggest exact CIR win.
|
||||
- F8 (hoist tomography gradient buffer), F6, F10, F12, F1, F2 (allocation/duplication
|
||||
removal), F15 + F11 with pinned index tie-breaks.
|
||||
- WorldGraph retention policy (the §4.1 unbounded-growth fix) — design ADR + ring-buffer
|
||||
implementation.
|
||||
- Expected outcome: measurable cycle-time reduction and flat memory under 24 h soak;
|
||||
**identical witness hashes**.
|
||||
|
||||
### Phase 2 — Determinism-managed structural wins (days 35–70)
|
||||
Each behind a feature flag, A/B'd against the legacy path (the `use_cir_gate` A/B switch
|
||||
at `multistatic.rs:103` is the template), with proof-hash regeneration as an explicit,
|
||||
witnessed release event:
|
||||
- **F4: FFT-based Φ/Φᴴ application in ISTA** — the headline item; the only route to
|
||||
HE20/HE40 real-time and the 12-link pattern. Acceptance: cir_bench speedup ≥ 5× at
|
||||
HE20, soak metrics within guardrail, new `expected_features.sha256` published in a
|
||||
fresh witness bundle.
|
||||
- F9 (power-method Lipschitz in tomography) riding the same hash-regen train.
|
||||
- F3 (slice-based CIR entry point), choosing the exact-f64-intermediate variant if the
|
||||
hash train slips.
|
||||
- F14: feature-gated `rayon` across **links/nodes only**, deterministic index-ordered
|
||||
collection; CI must run the determinism test (`engine/src/lib.rs:535-548`
|
||||
`cycle_is_deterministic`) with the feature on.
|
||||
|
||||
### Phase 3 — Platform & toolchain (days 70–90)
|
||||
- Pi 5 `target-cpu=cortex-a76` fleet builds + proof-runner verification (§5.1).
|
||||
- `+simd128` WASM artifact + size budget check for wasm-edge (§5.2, §4.3).
|
||||
- PGO pilot in CI using proof runners as the training corpus (§5.3).
|
||||
- Re-baseline: new criterion numbers, refreshed witness bundle, updated this document's
|
||||
§1 with real measured latencies.
|
||||
|
||||
**Out of 90-day scope, flagged for the architecture backlog:** F16 (Complex64→Complex32
|
||||
in core), F7 (single-matrix Φ kernel — bundle with F4), and HE40-on-edge (blocked on
|
||||
F4+F7).
|
||||
|
||||
---
|
||||
|
||||
## 7. Summary
|
||||
|
||||
The pipeline's only structural latency hazard is the dense ISTA CIR solver
|
||||
(`cir.rs:601-654` + `cir.rs:717-745`): fine at HT20, ~1.1 GFLOP worst-case per estimate
|
||||
at HE40, and slated to run per-link (×12). Everything else is allocation churn and
|
||||
duplicated work that can be removed with **bit-exact** refactors (F1–F12), plus one
|
||||
genuine memory bug-class issue: unbounded WorldGraph growth at 20 Hz
|
||||
(`engine/src/lib.rs:346-352`). The build profile is already optimal; remaining toolchain
|
||||
gains (target-cpu, wasm simd128, PGO) are determinism-safe and cheap. The determinism
|
||||
constraint is workable because the repo already owns the right tools — deterministic
|
||||
proof runners, an A/B gate pattern, and a per-cycle witness — so float-changing
|
||||
optimizations become scheduled, witnessed hash-regeneration events rather than risks.
|
||||
@@ -0,0 +1,96 @@
|
||||
# RuView Beyond-SOTA Research Series
|
||||
|
||||
Research swarm output (2026-06-09) defining what a beyond-state-of-the-art
|
||||
RuView implementation is, what the current system actually delivers, and the
|
||||
validation/benchmark/optimization evidence gathered in the same session.
|
||||
|
||||
Produced by a 5-agent hierarchical research swarm (system reviewer, SOTA
|
||||
surveyor, architect, benchmark methodologist, performance analyst) plus a
|
||||
validation pass run against the working tree.
|
||||
|
||||
## Documents
|
||||
|
||||
| Doc | Scope | One-line takeaway |
|
||||
|-----|-------|-------------------|
|
||||
| [00-system-review.md](00-system-review.md) | Capability audit of the current engine | Signal layer is the deepest asset (`ruvsense/` ≈14.4k lines, 310 in-module tests); the model tier is the emptiest (no trained checkpoint in-tree); the live 20 Hz path is the main integration gap |
|
||||
| [01-sota-landscape-2026.md](01-sota-landscape-2026.md) | Published SOTA per capability axis (web-verified) | Defines the beyond-SOTA bar: 12-row capability → published SOTA → RuView-today → target table; IEEE 802.11bf-2025 is ratified and moves the moat up-stack |
|
||||
| [02-beyond-sota-architecture.md](02-beyond-sota-architecture.md) | Target architecture | 8 pillars (RF foundation encoder + UQ heads, differentiable RF forward model, RF-SLAM×WorldGraph loop, camera→RF distillation, swarm apertures, continual adaptation, deterministic WASM edge, NV fusion) — all landing inside existing crates, no rewrite (per ADR-136 §2.1) |
|
||||
| [03-benchmark-validation-methodology.md](03-benchmark-validation-methodology.md) | Test/validation/benchmark methodology | 6-layer validation pyramid; 15 criterion bench targets inventoried; `benchmark_baseline.json` is a live-capture anchor, not a criterion baseline; statistical protocol from ADR-149 (≥10 seeds, IQM, bootstrap CIs) |
|
||||
| [04-optimization-roadmap.md](04-optimization-roadmap.md) | Performance review + 90-day plan | ISTA CIR solver is the dominant latency hazard (~1.1 GFLOP/frame at HE40); exact zero-risk wins identified; WorldGraph grows unboundedly (no eviction) — a real bug-class |
|
||||
|
||||
## Validation results (this session, 2026-06-09)
|
||||
|
||||
All measured on this branch (`claude/ruview-beyond-sota-xgv8aq`), Linux
|
||||
container, `cargo test --workspace --exclude wifi-densepose-desktop
|
||||
--no-default-features` (the desktop crate needs GTK system libraries absent in
|
||||
the container; this is an environment limitation, not a code failure).
|
||||
|
||||
| Layer | Command | Result |
|
||||
|-------|---------|--------|
|
||||
| L0 unit/integration | `cargo test --workspace --exclude wifi-densepose-desktop --no-default-features` | **154 suites, 2,797 passed, 0 failed** (pre-optimization baseline; re-run post-optimization also green) |
|
||||
| L1 deterministic proof | `python archive/v1/data/proof/verify.py` | **VERDICT: PASS** — hash `f8e76f21a0f9852b70b6d9dd5318239f6b20cbcb4cdd995863263cecdc446f7a` (bit-exact) |
|
||||
| L2 criterion (CIR) | `cargo bench -p wifi-densepose-signal --bench cir_bench --no-default-features --features cir` | Baselines captured pre/post optimization (below) |
|
||||
|
||||
~~Known pre-existing issue (not introduced here): `cargo check -p
|
||||
wifi-densepose-mat --no-default-features` fails standalone with 101 serde
|
||||
feature-unification errors; it builds and passes inside `--workspace` runs.~~
|
||||
**Fixed on this branch:** `pub mod api` (the only serde user) is now gated
|
||||
behind the `api` feature that owns the optional serde dependency; all feature
|
||||
combos compile.
|
||||
|
||||
## Optimizations applied (this session)
|
||||
|
||||
Two **exact** (bit-identical float results — summation order unchanged,
|
||||
witness chain unaffected) optimizations from the 04 roadmap's "zero-risk"
|
||||
tier were implemented and verified:
|
||||
|
||||
1. **`cir.rs` warm-start precompute** — the diagonal Tikhonov preconditioner
|
||||
`diag(Φ^H Φ) + λI` and its CSR matrix depend only on Φ and λ (fixed at
|
||||
`CirEstimator::new`) but were rebuilt on every frame (O(K·G) pass + CSR
|
||||
allocation). Moved to construction
|
||||
(`crates/wifi-densepose-signal/src/ruvsense/cir.rs`,
|
||||
`build_warm_start_system`).
|
||||
2. **`tomography.rs` solver hoisting** — the ISTA gradient `Vec` was
|
||||
allocated inside the 100-iteration loop and the Frobenius Lipschitz bound
|
||||
recomputed per `reconstruct` call; both hoisted
|
||||
(`crates/wifi-densepose-signal/src/ruvsense/tomography.rs`).
|
||||
|
||||
### Measured impact (criterion, paired pre/post baselines, same container)
|
||||
|
||||
| Bench | Pre-opt | Post-opt | Change | Significant? |
|
||||
|-------|---------|----------|--------|--------------|
|
||||
| `cir_estimate/he40` | 12.34 ms | 11.86 ms | **−3.9 %** | yes (p < 0.01) |
|
||||
| `cir_multiband_3band` (30 ms group) | 30.16 ms | 29.72 ms | −1.4 % | yes (p < 0.01) |
|
||||
| `cir_multiband` (142 ms group) | 141.9 ms | 140.1 ms | −1.2 % | yes (p < 0.01) |
|
||||
| `cir_estimate/ht40` | 11.73 ms | 11.78 ms | +0.4 % | no (p = 0.28) |
|
||||
| `cir_estimate/he20` | 2.49 ms | 2.49 ms | −0.1 % | no (p = 0.85) |
|
||||
| `cir_estimate/ht20` | 2.48 ms | 2.58 ms | +3.8 % | noise — see note |
|
||||
|
||||
Note on ht20: `cir_estimator_new/ht20` (construction, which now does strictly
|
||||
*more* work) also shows "+3 %", establishing a ≈3–4 % container noise floor;
|
||||
the ht20 estimate delta is within it. The honest summary: the warm-start
|
||||
precompute removes 1 of ~101 O(K·G) passes per frame, so the expected gain is
|
||||
≈1–4 % — consistent with what was measured. The dominant per-frame cost is
|
||||
the 100-iteration ISTA loop itself, which is exactly what the roadmap's
|
||||
flag-gated FFT-operator proposal (8–40× on the mat-vecs, requires witnessed
|
||||
hash regeneration) targets next.
|
||||
|
||||
Correctness post-optimization: `wifi-densepose-signal` 456 tests green;
|
||||
`wifi-densepose-engine` 11/11 green including `cycle_is_deterministic` and
|
||||
`calibration_mismatch_demotes_and_witness_stable` (witness-chain stability).
|
||||
|
||||
## Headline conclusions
|
||||
|
||||
1. **"Beyond SOTA" is currently unfalsifiable** without a real-CSI
|
||||
ground-truth benchmark — standing one up (per doc 03's acceptance table
|
||||
and ADR-149's statistical protocol) is the highest-leverage next step.
|
||||
2. **The path is evolution, not rewrite**: all eight architecture pillars in
|
||||
doc 02 land inside existing crates on the ADR-136 `Stage<I,O>`/`FrameMeta`
|
||||
contract spine.
|
||||
3. **The biggest engineering gaps** are the live 20 Hz ingest path, a trained
|
||||
RF encoder checkpoint, and WorldGraph retention/eviction — ahead of any
|
||||
frontier capability work.
|
||||
4. **Determinism is the differentiator**: every optimization and new pillar
|
||||
must preserve the witness chain; the advisory-vs-witnessed split (doc 02
|
||||
§determinism) is the mechanism that lets frontier components in without
|
||||
breaking it.
|
||||
@@ -65,6 +65,15 @@ target_compile_definitions(${COMPONENT_LIB} PUBLIC
|
||||
d_m3LogOutput=0 # Disable WASM3 stdout logging (use ESP_LOG)
|
||||
d_m3FixedHeap=0 # Use dynamic allocation (PSRAM-friendly)
|
||||
WASM3_AVAILABLE=1 # Flag for conditional compilation
|
||||
# Issue #946: GCC 15.2.0 for Xtensa (ESP-IDF v6.0.1) rejects wasm3's
|
||||
# `M3_MUSTTAIL` aggressive tail-call attribute with
|
||||
# "cannot tail-call: machine description does not have a sibcall_epilogue
|
||||
# instruction pattern". wasm3 falls back to a regular call sequence when
|
||||
# M3_NO_MUSTTAIL is defined — slightly slower per opcode but functionally
|
||||
# identical. Forcing it off unconditionally on Xtensa is fine because the
|
||||
# tail-call optimisation was never reliable on this target anyway. Older
|
||||
# IDF/GCC builds also accept the define (it just becomes a no-op).
|
||||
M3_NO_MUSTTAIL=1
|
||||
)
|
||||
|
||||
# Suppress warnings from third-party code.
|
||||
|
||||
@@ -220,11 +220,20 @@ static void fast_loop_cb(TimerHandle_t t)
|
||||
adaptive_controller_decide(&s_cfg, s_state, &obs, &dec);
|
||||
apply_decision(&dec);
|
||||
|
||||
/* ADR-081 Layer 4/5: emit compact feature state on every fast tick
|
||||
* (default 200 ms → 5 Hz, within the 1–10 Hz spec). Replaces raw
|
||||
* ADR-018 CSI as the default upstream; raw remains available as a
|
||||
* debug stream gated by the channel plan. */
|
||||
emit_feature_state();
|
||||
/* ADR-081 Layer 4/5: emit compact feature state at 1 Hz (the spec's
|
||||
* 1–10 Hz floor). Was previously emitted on every fast tick (~5 Hz at
|
||||
* the default 200 ms fast period), which combined with CSI promiscuous
|
||||
* RX saturated the WiFi TX airtime — measured live on COM8 (S3) and
|
||||
* COM9 (C6): every adaptive cycle showed `sendto ENOMEM — backing off
|
||||
* for 100 ms`, and bumping LWIP/WiFi buffer pools to 4× had no effect
|
||||
* on the rate because the bottleneck was radio TX time, not pool size.
|
||||
* Dropping to 1 Hz (5× less feature_state traffic) frees the TX queue
|
||||
* for CSI sends and lands well within the spec. */
|
||||
static uint8_t s_emit_divider = 0;
|
||||
if (++s_emit_divider >= 5) {
|
||||
s_emit_divider = 0;
|
||||
emit_feature_state();
|
||||
}
|
||||
}
|
||||
|
||||
static void medium_loop_cb(TimerHandle_t t)
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
#include "esp_wifi.h"
|
||||
#include "esp_mac.h"
|
||||
#include "esp_timer.h"
|
||||
#include "esp_idf_version.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/timers.h"
|
||||
#include <string.h>
|
||||
@@ -144,11 +145,31 @@ static void on_recv(const uint8_t *src_mac, const uint8_t *data, int len)
|
||||
}
|
||||
}
|
||||
|
||||
/* Issue #944: ESP-IDF v6.0 changed `esp_now_send_cb_t` from
|
||||
* void (*)(const uint8_t *mac, esp_now_send_status_t status)
|
||||
* to
|
||||
* void (*)(const esp_now_send_info_t *tx_info, esp_now_send_status_t status)
|
||||
* Both signatures ignore the address-side argument here — we only inspect
|
||||
* `status` to bump the TX-fail counter — so the body is identical; only the
|
||||
* function-pointer type differs.
|
||||
*
|
||||
* Issue #1005: Espressif backported the new signature to v5.5
|
||||
* (`esp_now_send_info_t` = typedef of `wifi_tx_info_t` there), so the guard
|
||||
* must be the full version triple, not ESP_IDF_VERSION_MAJOR.
|
||||
*/
|
||||
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 0)
|
||||
static void on_send(const esp_now_send_info_t *tx_info, esp_now_send_status_t status)
|
||||
{
|
||||
(void)tx_info;
|
||||
if (status != ESP_NOW_SEND_SUCCESS) s_tx_fail++;
|
||||
}
|
||||
#else
|
||||
static void on_send(const uint8_t *mac, esp_now_send_status_t status)
|
||||
{
|
||||
(void)mac;
|
||||
if (status != ESP_NOW_SEND_SUCCESS) s_tx_fail++;
|
||||
}
|
||||
#endif
|
||||
|
||||
static void beacon_timer_cb(TimerHandle_t t)
|
||||
{
|
||||
|
||||
@@ -23,6 +23,9 @@
|
||||
#include "esp_wifi.h"
|
||||
#include "esp_timer.h"
|
||||
#include "sdkconfig.h"
|
||||
#include "esp_netif.h" /* #954: STA gateway lookup for self-ping CSI source */
|
||||
#include "ping/ping_sock.h" /* #954: esp_ping gateway traffic generator */
|
||||
#include "lwip/ip_addr.h" /* #954: ip_addr_t target for esp_ping */
|
||||
|
||||
/* ADR-060: Access the global NVS config for MAC filter and channel override. */
|
||||
extern nvs_config_t g_nvs_config;
|
||||
@@ -365,6 +368,67 @@ static void wifi_promiscuous_cb(void *buf, wifi_promiscuous_pkt_type_t type)
|
||||
(void)type;
|
||||
}
|
||||
|
||||
/* ---- RuView#521/#954: connected-STA CSI traffic source (additive) ----
|
||||
*
|
||||
* The ESP32 CSI engine only produces CSI for received OFDM frames (L-LTF/HT-LTF).
|
||||
* On a quiet network — or on a display-enabled build where the #893 MGMT->MGMT+DATA
|
||||
* promiscuous upgrade is skipped (has_display=true) — the only CSI-eligible frames
|
||||
* are sparse beacons (often non-OFDM DSSS), so wifi_csi_callback can starve to
|
||||
* yield=0pps -> DEGRADED -> motion/presence=0 (#521, #954).
|
||||
*
|
||||
* This guarantees a ~50 Hz OFDM unicast floor by pinging the STA's own gateway:
|
||||
* the router's ICMP echo replies are OFDM frames destined to this station, which
|
||||
* drive the CSI engine regardless of promiscuous filter state or ambient traffic.
|
||||
* It is ADDITIVE — promiscuous capture (#396/#893) is left fully intact so
|
||||
* multistatic/multi-node sensing still hears other stations' frames. Mirrors
|
||||
* Espressif's esp-csi csi_recv_router reference.
|
||||
*/
|
||||
static esp_ping_handle_t s_self_ping = NULL;
|
||||
static void csi_ping_cb_noop(esp_ping_handle_t hdl, void *args) { (void)hdl; (void)args; }
|
||||
|
||||
static void csi_start_self_ping(void)
|
||||
{
|
||||
if (s_self_ping != NULL) {
|
||||
return; /* already running */
|
||||
}
|
||||
|
||||
esp_netif_t *sta = esp_netif_get_handle_from_ifkey("WIFI_STA_DEF");
|
||||
esp_netif_ip_info_t ip;
|
||||
if (sta == NULL || esp_netif_get_ip_info(sta, &ip) != ESP_OK || ip.gw.addr == 0) {
|
||||
ESP_LOGW(TAG, "self-ping: no gateway IP yet; CSI relies on ambient frames (#954)");
|
||||
return;
|
||||
}
|
||||
|
||||
char gw_str[16];
|
||||
esp_ip4addr_ntoa(&ip.gw, gw_str, sizeof(gw_str));
|
||||
|
||||
ip_addr_t target;
|
||||
memset(&target, 0, sizeof(target));
|
||||
ipaddr_aton(gw_str, &target);
|
||||
|
||||
esp_ping_config_t cfg = ESP_PING_DEFAULT_CONFIG();
|
||||
cfg.target_addr = target;
|
||||
cfg.count = ESP_PING_COUNT_INFINITE;
|
||||
cfg.interval_ms = 20; /* 50 Hz -> ~50 received OFDM replies/sec */
|
||||
cfg.data_size = 1;
|
||||
cfg.task_stack_size = 4096;
|
||||
|
||||
esp_ping_callbacks_t cbs = {
|
||||
.cb_args = NULL,
|
||||
.on_ping_success = csi_ping_cb_noop,
|
||||
.on_ping_timeout = csi_ping_cb_noop,
|
||||
.on_ping_end = csi_ping_cb_noop,
|
||||
};
|
||||
|
||||
if (esp_ping_new_session(&cfg, &cbs, &s_self_ping) == ESP_OK && s_self_ping != NULL) {
|
||||
esp_ping_start(s_self_ping);
|
||||
ESP_LOGI(TAG, "self-ping started -> %s @50Hz (CSI OFDM source, fix #521/#954)", gw_str);
|
||||
} else {
|
||||
ESP_LOGW(TAG, "self-ping: esp_ping_new_session failed");
|
||||
s_self_ping = NULL;
|
||||
}
|
||||
}
|
||||
|
||||
void csi_collector_set_node_id(uint8_t node_id)
|
||||
{
|
||||
s_node_id = node_id;
|
||||
@@ -526,6 +590,11 @@ void csi_collector_init(void)
|
||||
|
||||
ESP_LOGI(TAG, "CSI collection initialized (node_id=%u, channel=%u)",
|
||||
(unsigned)s_node_id, (unsigned)csi_channel);
|
||||
|
||||
/* RuView#521/#954: start the connected-STA traffic source so the CSI engine
|
||||
* receives a guaranteed OFDM unicast floor even when promiscuous capture is
|
||||
* starved (display builds / quiet networks). Additive to #396/#893. */
|
||||
csi_start_self_ping();
|
||||
}
|
||||
|
||||
/* Accessor for other modules that need the authoritative runtime node_id. */
|
||||
|
||||
@@ -215,6 +215,113 @@ static float estimate_bpm_zero_crossing(const float *history, uint16_t len,
|
||||
return freq_hz * 60.0f; /* Hz to BPM. */
|
||||
}
|
||||
|
||||
/**
|
||||
* Autocorrelation periodicity estimator (RuView #954/#985/#987 follow-up).
|
||||
*
|
||||
* Zero-crossing HR estimation parked at ~45 BPM for two reasons: (1) it used a
|
||||
* stale fixed sample rate (10 Hz) after #985's self-ping raised the real CSI
|
||||
* rate to a variable ~13-19 Hz, and (2) it locked onto breathing harmonics —
|
||||
* a 0.25 Hz breathing fundamental puts its 3rd harmonic at ~0.74 Hz ≈ 44 BPM,
|
||||
* right inside the HR band. This finds the dominant period in the HR band by
|
||||
* autocorrelation, explicitly rejecting lags that coincide with breathing
|
||||
* harmonics, and refines the peak with parabolic interpolation. Uses the
|
||||
* MEASURED sample rate so the BPM is in real units.
|
||||
*
|
||||
* @param sig Band-filtered signal (contiguous, oldest..newest).
|
||||
* @param len Number of samples.
|
||||
* @param fs Measured sample rate in Hz.
|
||||
* @param bpm_lo Low edge of the search band (BPM).
|
||||
* @param bpm_hi High edge of the search band (BPM).
|
||||
* @param reject_br_hz Breathing fundamental (Hz) whose harmonics are rejected
|
||||
* (k=1..6); pass 0 to disable rejection (fundamental search).
|
||||
* @return Dominant rate in BPM within the band, or 0 if no confident peak.
|
||||
*/
|
||||
static float estimate_periodicity_autocorr(const float *sig, uint16_t len, float fs,
|
||||
float bpm_lo, float bpm_hi, float reject_br_hz)
|
||||
{
|
||||
if (len < 32 || fs <= 0.0f || bpm_hi <= bpm_lo) return 0.0f;
|
||||
|
||||
int lag_min = (int)(fs * 60.0f / bpm_hi);
|
||||
int lag_max = (int)(fs * 60.0f / bpm_lo);
|
||||
if (lag_min < 2) lag_min = 2;
|
||||
if (lag_max >= (int)len) lag_max = (int)len - 1;
|
||||
if (lag_max <= lag_min + 1) return 0.0f;
|
||||
|
||||
const float br_hz = reject_br_hz;
|
||||
|
||||
float r0 = 0.0f;
|
||||
for (uint16_t i = 0; i < len; i++) r0 += sig[i] * sig[i];
|
||||
if (r0 <= 1e-6f) return 0.0f;
|
||||
|
||||
float best = -1.0f;
|
||||
int best_lag = 0;
|
||||
|
||||
for (int lag = lag_min; lag <= lag_max; lag++) {
|
||||
float f = fs / (float)lag; /* candidate HR frequency (Hz) */
|
||||
|
||||
/* Reject candidates within 8% of a breathing harmonic k*f_br (k=1..6). */
|
||||
if (br_hz > 0.0f) {
|
||||
bool harmonic = false;
|
||||
for (int k = 1; k <= 6; k++) {
|
||||
float h = (float)k * br_hz;
|
||||
if (fabsf(f - h) < 0.08f * h) { harmonic = true; break; }
|
||||
}
|
||||
if (harmonic) continue;
|
||||
}
|
||||
|
||||
float acc = 0.0f;
|
||||
for (int i = 0; i + lag < (int)len; i++) acc += sig[i] * sig[i + lag];
|
||||
if (acc > best) { best = acc; best_lag = lag; }
|
||||
}
|
||||
|
||||
if (best_lag == 0) return 0.0f;
|
||||
/* Require a real periodicity, not a noise peak. */
|
||||
if (best / r0 < 0.2f) return 0.0f;
|
||||
|
||||
/* Parabolic interpolation around best_lag for sub-sample period resolution. */
|
||||
float lag_ref = (float)best_lag;
|
||||
{
|
||||
float a = 0.0f, c = 0.0f;
|
||||
for (int i = 0; i + (best_lag - 1) < (int)len; i++) a += sig[i] * sig[i + best_lag - 1];
|
||||
for (int i = 0; i + (best_lag + 1) < (int)len; i++) c += sig[i] * sig[i + best_lag + 1];
|
||||
float denom = a - 2.0f * best + c;
|
||||
if (fabsf(denom) > 1e-6f) {
|
||||
float delta = 0.5f * (a - c) / denom;
|
||||
if (delta > -1.0f && delta < 1.0f) lag_ref += delta;
|
||||
}
|
||||
}
|
||||
|
||||
return fs / lag_ref * 60.0f;
|
||||
}
|
||||
|
||||
/* Median smoother for the emitted heart rate. The per-frame autocorr estimate
|
||||
* still has occasional single-frame outliers (startup transient before the
|
||||
* filters re-tune, momentary harmonic mis-locks); a median over the last few
|
||||
* VALID estimates stops the reported HR from "dropping a lot" between frames
|
||||
* without lagging real changes much. Only valid (in-range) estimates are
|
||||
* pushed, so out-of-range/zero results never pollute the window. */
|
||||
#define HR_SMOOTH_N 13
|
||||
static float s_hr_ring[HR_SMOOTH_N];
|
||||
static uint8_t s_hr_ring_n;
|
||||
static uint8_t s_hr_ring_idx;
|
||||
|
||||
static float hr_smooth_push(float hr)
|
||||
{
|
||||
s_hr_ring[s_hr_ring_idx] = hr;
|
||||
s_hr_ring_idx = (uint8_t)((s_hr_ring_idx + 1) % HR_SMOOTH_N);
|
||||
if (s_hr_ring_n < HR_SMOOTH_N) s_hr_ring_n++;
|
||||
|
||||
float tmp[HR_SMOOTH_N];
|
||||
for (uint8_t i = 0; i < s_hr_ring_n; i++) tmp[i] = s_hr_ring[i];
|
||||
for (uint8_t i = 1; i < s_hr_ring_n; i++) { /* insertion sort, tiny N */
|
||||
float v = tmp[i];
|
||||
int j = (int)i - 1;
|
||||
while (j >= 0 && tmp[j] > v) { tmp[j + 1] = tmp[j]; j--; }
|
||||
tmp[j + 1] = v;
|
||||
}
|
||||
return tmp[s_hr_ring_n / 2];
|
||||
}
|
||||
|
||||
/* ======================================================================
|
||||
* DSP Pipeline State
|
||||
* ====================================================================== */
|
||||
@@ -246,6 +353,14 @@ static edge_biquad_t s_bq_heartrate;
|
||||
static float s_breathing_filtered[EDGE_PHASE_HISTORY_LEN];
|
||||
static float s_heartrate_filtered[EDGE_PHASE_HISTORY_LEN];
|
||||
|
||||
/** Measured CSI sample rate (Hz), smoothed from frame timestamps.
|
||||
* #985's self-ping raised the callback rate above the old ~10 Hz beacon
|
||||
* assumption and made it variable (~13-19 Hz); a fixed rate scaled BPM wrong
|
||||
* and made HR swing with CSI yield. See update in process_csi_frame(). */
|
||||
static float s_sample_rate_hz = 15.0f;
|
||||
static float s_filter_design_fs = 20.0f; /* fs the biquads were last designed at */
|
||||
static uint32_t s_last_frame_ts_us = 0;
|
||||
|
||||
/** Latest vitals state. */
|
||||
static float s_breathing_bpm;
|
||||
static float s_heartrate_bpm;
|
||||
@@ -535,7 +650,11 @@ static void update_multi_person_vitals(const uint8_t *iq_data, uint16_t n_sc,
|
||||
}
|
||||
|
||||
float br = estimate_bpm_zero_crossing(s_scratch_br, buf_len, sample_rate);
|
||||
float hr = estimate_bpm_zero_crossing(s_scratch_hr, buf_len, sample_rate);
|
||||
/* Robust breathing period (autocorr) drives HR harmonic rejection —
|
||||
* the zero-crossing estimate is too noisy under motion and notched
|
||||
* the wrong frequencies, letting HR lock onto a breathing harmonic. */
|
||||
float br_rob = estimate_periodicity_autocorr(s_scratch_br, buf_len, sample_rate, 6.0f, 40.0f, 0.0f);
|
||||
float hr = estimate_periodicity_autocorr(s_scratch_hr, buf_len, sample_rate, 45.0f, 180.0f, br_rob / 60.0f);
|
||||
|
||||
/* Sanity clamp. */
|
||||
if (br >= 6.0f && br <= 40.0f) pv->breathing_bpm = br;
|
||||
@@ -715,11 +834,36 @@ static void process_frame(const edge_ring_slot_t *slot)
|
||||
s_frame_count++;
|
||||
s_latest_rssi = slot->rssi;
|
||||
|
||||
/* CSI sample rate. MGMT-only promiscuous filter (RuView#396, csi_collector.c)
|
||||
* yields ~10 Hz from beacons; keep this value aligned with csi_collector's
|
||||
* effective callback rate or estimate_bpm_zero_crossing() reports the wrong
|
||||
* BPM (2× rate mismatch → 2× wrong breathing/HR). */
|
||||
const float sample_rate = 10.0f;
|
||||
/* Measure the REAL CSI sample rate from inter-frame timestamps. #985's
|
||||
* self-ping made the callback rate variable (~13-19 Hz); the old fixed
|
||||
* 10 Hz both scaled BPM wrong (true ~87 BPM read as ~45) and made HR swing
|
||||
* as CSI yield fluctuated. EMA-smooth and clamp to a plausible band. */
|
||||
if (s_last_frame_ts_us != 0 && slot->timestamp_us > s_last_frame_ts_us) {
|
||||
float dt = (float)(slot->timestamp_us - s_last_frame_ts_us) * 1e-6f;
|
||||
if (dt > 0.02f && dt < 0.5f) { /* 2-50 Hz plausible; reject gaps/hops */
|
||||
float inst = 1.0f / dt;
|
||||
s_sample_rate_hz += 0.05f * (inst - s_sample_rate_hz);
|
||||
if (s_sample_rate_hz < 8.0f) s_sample_rate_hz = 8.0f;
|
||||
if (s_sample_rate_hz > 30.0f) s_sample_rate_hz = 30.0f;
|
||||
}
|
||||
}
|
||||
s_last_frame_ts_us = slot->timestamp_us;
|
||||
|
||||
/* Re-tune the biquads if the measured rate has drifted from their design fs,
|
||||
* so the breathing (0.1-0.5 Hz) and HR (0.8-2.0 Hz) passbands stay in real
|
||||
* Hz. biquad_bandpass_design resets delay state, so only redesign on real
|
||||
* drift (>15%) — the autocorr window averages over the one-time transient. */
|
||||
if (fabsf(s_sample_rate_hz - s_filter_design_fs) > 0.15f * s_filter_design_fs) {
|
||||
biquad_bandpass_design(&s_bq_breathing, s_sample_rate_hz, 0.1f, 0.5f);
|
||||
biquad_bandpass_design(&s_bq_heartrate, s_sample_rate_hz, 0.8f, 2.0f);
|
||||
for (uint8_t pp = 0; pp < EDGE_MAX_PERSONS; pp++) {
|
||||
biquad_bandpass_design(&s_person_bq_br[pp], s_sample_rate_hz, 0.1f, 0.5f);
|
||||
biquad_bandpass_design(&s_person_bq_hr[pp], s_sample_rate_hz, 0.8f, 2.0f);
|
||||
}
|
||||
s_filter_design_fs = s_sample_rate_hz;
|
||||
}
|
||||
|
||||
const float sample_rate = s_sample_rate_hz;
|
||||
|
||||
/* --- Step 1-2: Phase extraction + unwrapping per subcarrier --- */
|
||||
float phases[EDGE_MAX_SUBCARRIERS];
|
||||
@@ -777,11 +921,13 @@ static void process_frame(const edge_ring_slot_t *slot)
|
||||
}
|
||||
|
||||
float br_bpm = estimate_bpm_zero_crossing(s_scratch_br, buf_len, sample_rate);
|
||||
float hr_bpm = estimate_bpm_zero_crossing(s_scratch_hr, buf_len, sample_rate);
|
||||
/* Robust breathing period (autocorr) drives HR harmonic rejection. */
|
||||
float br_rob = estimate_periodicity_autocorr(s_scratch_br, buf_len, sample_rate, 6.0f, 40.0f, 0.0f);
|
||||
float hr_bpm = estimate_periodicity_autocorr(s_scratch_hr, buf_len, sample_rate, 45.0f, 180.0f, br_rob / 60.0f);
|
||||
|
||||
/* Sanity clamp: breathing 6-40 BPM, heart rate 40-180 BPM. */
|
||||
if (br_bpm >= 6.0f && br_bpm <= 40.0f) s_breathing_bpm = br_bpm;
|
||||
if (hr_bpm >= 40.0f && hr_bpm <= 180.0f) s_heartrate_bpm = hr_bpm;
|
||||
if (hr_bpm >= 40.0f && hr_bpm <= 180.0f) s_heartrate_bpm = hr_smooth_push(hr_bpm);
|
||||
}
|
||||
|
||||
/* --- Step 8: Motion energy (variance of recent phases) --- */
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
* 0xC5110003 — ADR-069 feature vector (edge_processing.h)
|
||||
* 0xC5110004 — ADR-063 fused vitals (edge_processing.h)
|
||||
* 0xC5110005 — ADR-039 compressed CSI (edge_processing.h)
|
||||
* 0xC5110006 — ADR-081 feature state (this file) ← new
|
||||
* 0xC5110006 — ADR-081 feature state (this file)
|
||||
* 0xC5110007 — ADR-040 WASM output (wasm_runtime.h, reassigned per issue #928)
|
||||
*/
|
||||
|
||||
#ifndef RV_FEATURE_STATE_H
|
||||
|
||||
@@ -23,7 +23,16 @@
|
||||
static const char *TAG = "swarm";
|
||||
|
||||
/* ---- Task parameters ---- */
|
||||
#define SWARM_TASK_STACK 3072 /**< 3 KB stack — HTTP client uses ~2.5 KB. */
|
||||
/* Issue #949: 3 KB was sized for plain HTTP (~2.5 KB). The bug reporter
|
||||
* configured `--seed-url https://…` which exercises TLS — mbedTLS handshake
|
||||
* alone needs 4-6 KB on the stack (cipher suite + cert chain + ECDH), and on
|
||||
* top of that esp_http_client adds another 1.5-2 KB. The task panicked with
|
||||
* `0xa5a5a5a5` (FreeRTOS stack-fill sentinel) immediately after "bridge init
|
||||
* OK". 8 KB comfortably fits TLS with margin for the cert chain + headers;
|
||||
* confirmed against mbedTLS's stack analyser. Plain-HTTP deployments waste
|
||||
* ~5 KB of headroom but that's <0.1 % of PSRAM, an acceptable cost for the
|
||||
* bug class this prevents. */
|
||||
#define SWARM_TASK_STACK 8192 /**< 8 KB stack — fits mbedTLS handshake. */
|
||||
#define SWARM_TASK_PRIO 3
|
||||
#define SWARM_TASK_CORE 0
|
||||
#define SWARM_HTTP_TIMEOUT 3000 /**< HTTP timeout in ms (Seed responds <100ms on LAN). */
|
||||
|
||||
@@ -43,7 +43,16 @@
|
||||
|
||||
#define WASM_MAX_MODULE_SIZE (128 * 1024) /**< Max .wasm binary size (128 KB). */
|
||||
#define WASM_STACK_SIZE (8 * 1024) /**< WASM execution stack (8 KB). */
|
||||
#define WASM_OUTPUT_MAGIC 0xC5110004 /**< WASM output packet magic. */
|
||||
/* Issue #928: WASM output was originally 0xC5110004, but that magic is
|
||||
* canonically owned by ADR-063 fused vitals (edge_processing.h). Both packets
|
||||
* were transmitted on the same magic, and the host parser only knew the WASM
|
||||
* shape, so on the ESP32-C6 + MR60BHA2 mmWave config the 48-byte fused-vitals
|
||||
* packet was being read as garbage WASM events. Reassigned to 0xC5110007 (next
|
||||
* free slot in the registry — see rv_feature_state.h). Firmware older than
|
||||
* this commit will silently lose its WASM event stream against an updated host
|
||||
* — that's the deliberate "fail loud" choice over silent misparsing.
|
||||
*/
|
||||
#define WASM_OUTPUT_MAGIC 0xC5110007 /**< WASM output packet magic (post-#928). */
|
||||
#define WASM_MAX_EVENTS 16 /**< Max events per output packet. */
|
||||
|
||||
/* ---- WASM Event (5 bytes: u8 type + f32 value) ---- */
|
||||
@@ -54,7 +63,7 @@ typedef struct __attribute__((packed)) {
|
||||
|
||||
/* ---- WASM Output Packet ---- */
|
||||
typedef struct __attribute__((packed)) {
|
||||
uint32_t magic; /**< WASM_OUTPUT_MAGIC = 0xC5110004. */
|
||||
uint32_t magic; /**< WASM_OUTPUT_MAGIC = 0xC5110007 (issue #928). */
|
||||
uint8_t node_id; /**< ESP32 node identifier. */
|
||||
uint8_t module_id; /**< Module slot index. */
|
||||
uint16_t event_count; /**< Number of events in this packet. */
|
||||
|
||||
@@ -29,6 +29,30 @@ CONFIG_LOG_DEFAULT_LEVEL_INFO=y
|
||||
# LWIP: enable extended socket options for UDP multicast
|
||||
CONFIG_LWIP_SO_RCVBUF=y
|
||||
|
||||
# Issue (sibling of #946/#949/#864 cluster): UDP `sendto` returned ENOMEM
|
||||
# in a tight loop on both ESP32-S3 (COM8) and ESP32-C6 (COM9) at the v0.7.0
|
||||
# CSI packet rate (CSI cb + status + sync + feature_state all sharing the
|
||||
# LWIP/WiFi pools). stream_sender.c has a cooldown path so the device
|
||||
# doesn't crash, but ~90 % of CSI frames were dropped before reaching the
|
||||
# host — boot trace showed `sendto ENOMEM — backing off 100 ms` repeating
|
||||
# every capture cycle. Stock IDF v5.4 defaults: UDP recv mbox=6, TCPIP
|
||||
# mbox=32, WiFi dynamic TX buffers=32 — too small once CSI promiscuous
|
||||
# mode is active. These bumps roughly quadruple the relevant pools at
|
||||
# ~3 KB extra heap cost, measured live on both targets Jun 8 2026.
|
||||
CONFIG_LWIP_UDP_RECVMBOX_SIZE=32
|
||||
CONFIG_LWIP_TCPIP_RECVMBOX_SIZE=64
|
||||
CONFIG_ESP_WIFI_DYNAMIC_TX_BUFFER_NUM=64
|
||||
# NOTE: Empirical 25 s measurements on the S3 at COM8 showed these bumps
|
||||
# eliminate the csi_collector.sendto failure path (`fail #1..5` →
|
||||
# `fail #0`) — real improvement — but do NOT eliminate the broader
|
||||
# `feature_state emit` ENOMEM at ~10/s. That residual is the WiFi
|
||||
# radio's TX airtime saturating under CSI promiscuous RX, and bigger
|
||||
# buffers cap out at the 100 ms backoff window regardless of size
|
||||
# (verified at WIFI_DYNAMIC_TX=128 + PBUF_POOL=32 — identical count).
|
||||
# The proper fix is rate-limiting adaptive_controller.c's emit cadence
|
||||
# from ~50 ms to the intended 1 Hz, which is a code refactor tracked
|
||||
# in a separate follow-up issue.
|
||||
|
||||
# FreeRTOS: increase task stack for CSI processing
|
||||
CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/* Host-fuzzing stub for esp_netif.h (ADR-061).
|
||||
*
|
||||
* csi_collector.c's #954 self-ping needs the STA netif handle + gateway IP.
|
||||
* In the fuzz environment there is no network stack: the handle lookup
|
||||
* returns NULL, so csi_start_self_ping() takes its no-gateway early-out and
|
||||
* the esp_ping path is never exercised (but must compile and link).
|
||||
*/
|
||||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
|
||||
#include "esp_err.h"
|
||||
|
||||
typedef struct esp_netif_obj esp_netif_t;
|
||||
|
||||
typedef struct {
|
||||
uint32_t addr;
|
||||
} esp_ip4_addr_t;
|
||||
|
||||
typedef struct {
|
||||
esp_ip4_addr_t ip;
|
||||
esp_ip4_addr_t netmask;
|
||||
esp_ip4_addr_t gw;
|
||||
} esp_netif_ip_info_t;
|
||||
|
||||
static inline esp_netif_t *esp_netif_get_handle_from_ifkey(const char *if_key)
|
||||
{
|
||||
(void)if_key;
|
||||
return NULL; /* no netif in fuzz env -> self-ping early-out */
|
||||
}
|
||||
|
||||
static inline esp_err_t esp_netif_get_ip_info(esp_netif_t *netif, esp_netif_ip_info_t *ip_info)
|
||||
{
|
||||
(void)netif;
|
||||
(void)ip_info;
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
static inline char *esp_ip4addr_ntoa(const esp_ip4_addr_t *addr, char *buf, int buflen)
|
||||
{
|
||||
if (buf != NULL && buflen > 0) {
|
||||
snprintf(buf, (size_t)buflen, "%u.%u.%u.%u",
|
||||
(unsigned)(addr->addr & 0xff), (unsigned)((addr->addr >> 8) & 0xff),
|
||||
(unsigned)((addr->addr >> 16) & 0xff), (unsigned)((addr->addr >> 24) & 0xff));
|
||||
}
|
||||
return buf;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/* Host-fuzzing stub for lwip/ip_addr.h (ADR-061). Minimal surface for the
|
||||
* #954 self-ping block; never functionally exercised in the fuzz env. */
|
||||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
typedef struct {
|
||||
uint32_t addr;
|
||||
uint8_t type;
|
||||
} ip_addr_t;
|
||||
|
||||
static inline int ipaddr_aton(const char *cp, ip_addr_t *addr)
|
||||
{
|
||||
(void)cp;
|
||||
if (addr != NULL) {
|
||||
addr->addr = 0;
|
||||
addr->type = 0;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/* Host-fuzzing stub for ping/ping_sock.h (ADR-061). The #954 self-ping is
|
||||
* unreachable in the fuzz env (esp_netif stub returns no gateway), but the
|
||||
* symbols must compile and link. */
|
||||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
#include "esp_err.h"
|
||||
#include "lwip/ip_addr.h"
|
||||
|
||||
typedef void *esp_ping_handle_t;
|
||||
|
||||
typedef void (*esp_ping_cb_t)(esp_ping_handle_t hdl, void *args);
|
||||
|
||||
typedef struct {
|
||||
uint32_t count;
|
||||
uint32_t interval_ms;
|
||||
uint32_t timeout_ms;
|
||||
uint32_t data_size;
|
||||
uint8_t tos;
|
||||
int ttl;
|
||||
ip_addr_t target_addr;
|
||||
uint32_t task_stack_size;
|
||||
uint32_t task_prio;
|
||||
uint32_t interface;
|
||||
} esp_ping_config_t;
|
||||
|
||||
#define ESP_PING_COUNT_INFINITE (0)
|
||||
|
||||
#define ESP_PING_DEFAULT_CONFIG() \
|
||||
{ \
|
||||
.count = 5, \
|
||||
.interval_ms = 1000, \
|
||||
.timeout_ms = 1000, \
|
||||
.data_size = 64, \
|
||||
.tos = 0, \
|
||||
.ttl = 64, \
|
||||
.target_addr = {0, 0}, \
|
||||
.task_stack_size = 2048, \
|
||||
.task_prio = 2, \
|
||||
.interface = 0, \
|
||||
}
|
||||
|
||||
typedef struct {
|
||||
void *cb_args;
|
||||
esp_ping_cb_t on_ping_success;
|
||||
esp_ping_cb_t on_ping_timeout;
|
||||
esp_ping_cb_t on_ping_end;
|
||||
} esp_ping_callbacks_t;
|
||||
|
||||
static inline esp_err_t esp_ping_new_session(const esp_ping_config_t *config,
|
||||
const esp_ping_callbacks_t *cbs,
|
||||
esp_ping_handle_t *hdl_out)
|
||||
{
|
||||
(void)config;
|
||||
(void)cbs;
|
||||
if (hdl_out != NULL) {
|
||||
*hdl_out = (void *)0;
|
||||
}
|
||||
return ESP_FAIL; /* never starts a ping task in the fuzz env */
|
||||
}
|
||||
|
||||
static inline esp_err_t esp_ping_start(esp_ping_handle_t hdl)
|
||||
{
|
||||
(void)hdl;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
static inline esp_err_t esp_ping_stop(esp_ping_handle_t hdl)
|
||||
{
|
||||
(void)hdl;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
static inline esp_err_t esp_ping_delete_session(esp_ping_handle_t hdl)
|
||||
{
|
||||
(void)hdl;
|
||||
return ESP_OK;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Firewall-free CSI UDP relay for local Windows ESP32 testing.
|
||||
|
||||
On Windows, a freshly-built binary (e.g. `wifi-densepose calibrate-serve`) is
|
||||
blocked from receiving inbound LAN UDP by Windows Defender Firewall unless an
|
||||
admin adds an allow rule. `python.exe` is typically already allowed. This relay
|
||||
binds the public CSI port, receives the ESP32's frames, and forwards each
|
||||
datagram verbatim to a loopback port where the calibration server listens
|
||||
(loopback is exempt from the inbound firewall). No admin required.
|
||||
|
||||
Usage:
|
||||
python scripts/csi-udp-relay.py --listen 5005 --forward 5006
|
||||
|
||||
Then run the calibration server on the loopback port:
|
||||
wifi-densepose calibrate-serve --udp-bind 127.0.0.1 --udp-port 5006
|
||||
|
||||
Frames are passed through byte-for-byte; the relay never parses or mutates them.
|
||||
"""
|
||||
import argparse
|
||||
import socket
|
||||
import time
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser(description="Forward ESP32 CSI UDP to a loopback port (no admin).")
|
||||
ap.add_argument("--listen", type=int, default=5005, help="public UDP port the ESP32 streams to")
|
||||
ap.add_argument("--listen-host", default="0.0.0.0", help="bind address for the public port")
|
||||
ap.add_argument("--forward", type=int, default=5006, help="loopback port the calibration server listens on")
|
||||
ap.add_argument("--forward-host", default="127.0.0.1", help="loopback host to forward to")
|
||||
ap.add_argument("--quiet", action="store_true", help="suppress the periodic stats line")
|
||||
args = ap.parse_args()
|
||||
|
||||
rx = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
rx.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
rx.bind((args.listen_host, args.listen))
|
||||
rx.settimeout(1.0)
|
||||
tx = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
dst = (args.forward_host, args.forward)
|
||||
|
||||
print(f"[relay] {args.listen_host}:{args.listen} -> {dst[0]}:{dst[1]} (Ctrl-C to stop)")
|
||||
count = 0
|
||||
last_report = time.time()
|
||||
last_src = None
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
data, src = rx.recvfrom(2048)
|
||||
except socket.timeout:
|
||||
data = None
|
||||
if data:
|
||||
tx.sendto(data, dst)
|
||||
count += 1
|
||||
last_src = src
|
||||
now = time.time()
|
||||
if not args.quiet and now - last_report >= 5.0:
|
||||
print(f"[relay] forwarded {count} frames (last src={last_src})")
|
||||
last_report = now
|
||||
except KeyboardInterrupt:
|
||||
print(f"\n[relay] stopped after {count} frames")
|
||||
finally:
|
||||
rx.close()
|
||||
tx.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1 @@
|
||||
baselines/
|
||||
Generated
+29
-33
@@ -10811,12 +10811,27 @@ dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wifi-densepose-calibration"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"ndarray 0.17.2",
|
||||
"num-complex",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
"uuid",
|
||||
"wifi-densepose-core",
|
||||
"wifi-densepose-signal",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wifi-densepose-cli"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"assert_cmd",
|
||||
"axum",
|
||||
"chrono",
|
||||
"clap",
|
||||
"colored",
|
||||
@@ -10832,9 +10847,12 @@ dependencies = [
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tower 0.4.13",
|
||||
"tower-http",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"uuid",
|
||||
"wifi-densepose-calibration",
|
||||
"wifi-densepose-core",
|
||||
"wifi-densepose-mat",
|
||||
"wifi-densepose-signal",
|
||||
@@ -10892,12 +10910,13 @@ version = "0.3.0"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"criterion",
|
||||
"ruvector-mincut",
|
||||
"wifi-densepose-bfld",
|
||||
"wifi-densepose-core",
|
||||
"wifi-densepose-geo 0.1.0",
|
||||
"wifi-densepose-geo",
|
||||
"wifi-densepose-ruvector",
|
||||
"wifi-densepose-signal",
|
||||
"wifi-densepose-worldgraph 0.3.0",
|
||||
"wifi-densepose-worldgraph",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -10912,20 +10931,6 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wifi-densepose-geo"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "092ea59d81e7be76d6d9c2d81628c1dbe768fd77591f0e82dd3c80e2963ff04a"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wifi-densepose-hardware"
|
||||
version = "0.3.0"
|
||||
@@ -11075,9 +11080,13 @@ dependencies = [
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"ureq 2.12.1",
|
||||
"wifi-densepose-bfld",
|
||||
"wifi-densepose-engine",
|
||||
"wifi-densepose-geo",
|
||||
"wifi-densepose-hardware",
|
||||
"wifi-densepose-signal",
|
||||
"wifi-densepose-wifiscan",
|
||||
"wifi-densepose-worldgraph",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -11187,37 +11196,24 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wifi-densepose-worldgraph"
|
||||
version = "0.3.0"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"petgraph",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
"wifi-densepose-geo 0.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wifi-densepose-worldgraph"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13ad8df7b323061ed7afae1917dac7eedfbd24a463a668a55a16cde79df067e2"
|
||||
dependencies = [
|
||||
"petgraph",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
"wifi-densepose-geo 0.1.0 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"wifi-densepose-geo",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wifi-densepose-worldmodel"
|
||||
version = "0.3.0"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"wifi-densepose-worldgraph 0.3.0 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"wifi-densepose-worldgraph",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -28,6 +28,7 @@ members = [
|
||||
"crates/wifi-densepose-geo",
|
||||
"crates/wifi-densepose-worldgraph", # ADR-139 — WorldGraph environmental digital twin
|
||||
"crates/wifi-densepose-engine", # ADR-135..146 integration/composition layer
|
||||
"crates/wifi-densepose-calibration", # ADR-151 — per-room calibration & specialist training
|
||||
"crates/nvsim",
|
||||
"crates/nvsim-server",
|
||||
"crates/homecore", # ADR-127 — HOMECORE state machine
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "wifi-densepose-calibration"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
description = "ADR-151 per-room calibration & specialized model training (baseline → enroll → extract → train)"
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
wifi-densepose-core = { workspace = true }
|
||||
wifi-densepose-signal = { version = "0.3.0", path = "../wifi-densepose-signal", default-features = false }
|
||||
|
||||
serde = { workspace = true }
|
||||
serde_json = "1.0"
|
||||
thiserror = { workspace = true }
|
||||
uuid = { version = "1.6", features = ["v4", "serde"] }
|
||||
|
||||
[dev-dependencies]
|
||||
ndarray = { workspace = true }
|
||||
num-complex = { workspace = true }
|
||||
@@ -0,0 +1,351 @@
|
||||
//! Guided anchors + event-sourced enrollment session (ADR-151 Stage 2).
|
||||
//!
|
||||
//! Enrollment teaches the room a small set of *clean anchors* — not hours of
|
||||
//! data. Each anchor is a short labelled capture (stand / sit / lie / breathe /
|
||||
//! move / sleep) layered on top of the ADR-135 empty-room baseline. The session
|
||||
//! is event-sourced so re-enrollment is incremental and auditable (per CLAUDE.md
|
||||
//! state rules).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Coarse posture an anchor establishes.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Posture {
|
||||
/// Standing.
|
||||
Standing,
|
||||
/// Sitting.
|
||||
Sitting,
|
||||
/// Lying down.
|
||||
Lying,
|
||||
}
|
||||
|
||||
/// The fixed guided-anchor sequence (ADR-151 §2.2).
|
||||
///
|
||||
/// Serializes as snake_case (`empty`, `stand_still`, …) to match
|
||||
/// [`AnchorLabel::as_str`] and the documented JSON contract.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AnchorLabel {
|
||||
/// Empty room reference (reuses the ADR-135 baseline).
|
||||
Empty,
|
||||
/// Person standing still, in view of the sensor.
|
||||
StandStill,
|
||||
/// Person sitting.
|
||||
Sit,
|
||||
/// Person lying down.
|
||||
LieDown,
|
||||
/// Slow respiration (~0.1–0.15 Hz).
|
||||
BreatheSlow,
|
||||
/// Normal respiration (~0.2–0.3 Hz).
|
||||
BreatheNormal,
|
||||
/// Small limb movement.
|
||||
SmallMove,
|
||||
/// Quiescent sleep posture (lying, still).
|
||||
SleepPosture,
|
||||
}
|
||||
|
||||
impl AnchorLabel {
|
||||
/// The canonical enrollment order.
|
||||
pub const SEQUENCE: [AnchorLabel; 8] = [
|
||||
AnchorLabel::Empty,
|
||||
AnchorLabel::StandStill,
|
||||
AnchorLabel::Sit,
|
||||
AnchorLabel::LieDown,
|
||||
AnchorLabel::BreatheSlow,
|
||||
AnchorLabel::BreatheNormal,
|
||||
AnchorLabel::SmallMove,
|
||||
AnchorLabel::SleepPosture,
|
||||
];
|
||||
|
||||
/// Stable string id (used in persistence / API).
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
AnchorLabel::Empty => "empty",
|
||||
AnchorLabel::StandStill => "stand_still",
|
||||
AnchorLabel::Sit => "sit",
|
||||
AnchorLabel::LieDown => "lie_down",
|
||||
AnchorLabel::BreatheSlow => "breathe_slow",
|
||||
AnchorLabel::BreatheNormal => "breathe_normal",
|
||||
AnchorLabel::SmallMove => "small_move",
|
||||
AnchorLabel::SleepPosture => "sleep_posture",
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse from the stable string id.
|
||||
pub fn from_str(s: &str) -> Option<AnchorLabel> {
|
||||
AnchorLabel::SEQUENCE
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|a| a.as_str() == s)
|
||||
}
|
||||
|
||||
/// Operator-facing prompt shown by the CLI / UI.
|
||||
pub fn prompt(&self) -> &'static str {
|
||||
match self {
|
||||
AnchorLabel::Empty => "Leave the room empty and still…",
|
||||
AnchorLabel::StandStill => "Stand still, in view of the sensor…",
|
||||
AnchorLabel::Sit => "Sit down and stay still…",
|
||||
AnchorLabel::LieDown => "Lie down and stay still…",
|
||||
AnchorLabel::BreatheSlow => "Lie or sit still and breathe slowly…",
|
||||
AnchorLabel::BreatheNormal => "Stay still and breathe normally…",
|
||||
AnchorLabel::SmallMove => "Make small movements (wave a hand, shift)…",
|
||||
AnchorLabel::SleepPosture => "Lie in your sleep posture and relax…",
|
||||
}
|
||||
}
|
||||
|
||||
/// Suggested capture duration (seconds).
|
||||
pub fn duration_s(&self) -> u32 {
|
||||
match self {
|
||||
AnchorLabel::BreatheSlow
|
||||
| AnchorLabel::BreatheNormal
|
||||
| AnchorLabel::SleepPosture => 30,
|
||||
_ => 20,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a person is expected to be present for this anchor.
|
||||
pub fn expects_presence(&self) -> bool {
|
||||
!matches!(self, AnchorLabel::Empty)
|
||||
}
|
||||
|
||||
/// Whether the subject is expected to be (largely) still.
|
||||
pub fn expects_still(&self) -> bool {
|
||||
!matches!(self, AnchorLabel::SmallMove)
|
||||
}
|
||||
|
||||
/// Posture this anchor establishes, if any.
|
||||
pub fn posture(&self) -> Option<Posture> {
|
||||
match self {
|
||||
AnchorLabel::StandStill => Some(Posture::Standing),
|
||||
AnchorLabel::Sit => Some(Posture::Sitting),
|
||||
AnchorLabel::LieDown | AnchorLabel::SleepPosture => Some(Posture::Lying),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Quality assessment of a captured anchor (from the enrollment quality gate).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub struct AnchorQuality {
|
||||
/// Median amplitude z-score vs the empty-room baseline (presence strength).
|
||||
pub presence_z: f32,
|
||||
/// Fraction of frames flagged as motion.
|
||||
pub motion_rate: f32,
|
||||
/// Number of frames captured.
|
||||
pub frames: u32,
|
||||
/// Whether the anchor passed the gate.
|
||||
pub accepted: bool,
|
||||
}
|
||||
|
||||
/// A captured, accepted anchor.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Anchor {
|
||||
/// Which anchor in the sequence.
|
||||
pub label: AnchorLabel,
|
||||
/// Capture time (unix seconds).
|
||||
pub captured_at_unix_s: i64,
|
||||
/// Quality metrics.
|
||||
pub quality: AnchorQuality,
|
||||
}
|
||||
|
||||
/// Event log entry for an enrollment session (event sourcing).
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum EnrollmentEvent {
|
||||
/// Session opened.
|
||||
Started {
|
||||
/// Room scope.
|
||||
room_id: String,
|
||||
/// Baseline id the enrollment layers on.
|
||||
baseline_id: String,
|
||||
/// Unix seconds.
|
||||
at: i64,
|
||||
},
|
||||
/// An anchor passed the gate and was accepted.
|
||||
AnchorAccepted {
|
||||
/// The accepted anchor.
|
||||
anchor: Anchor,
|
||||
},
|
||||
/// An anchor failed the gate (re-prompt).
|
||||
AnchorRejected {
|
||||
/// Which anchor.
|
||||
label: AnchorLabel,
|
||||
/// Human-readable reason.
|
||||
reason: String,
|
||||
/// Unix seconds.
|
||||
at: i64,
|
||||
},
|
||||
/// All required anchors accepted.
|
||||
Completed {
|
||||
/// Unix seconds.
|
||||
at: i64,
|
||||
},
|
||||
}
|
||||
|
||||
/// Event-sourced enrollment session for one room.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EnrollmentSession {
|
||||
/// Room scope.
|
||||
pub room_id: String,
|
||||
/// Baseline id this session layers on.
|
||||
pub baseline_id: String,
|
||||
/// Append-only event log.
|
||||
pub events: Vec<EnrollmentEvent>,
|
||||
}
|
||||
|
||||
impl EnrollmentSession {
|
||||
/// Open a new session.
|
||||
pub fn new(room_id: impl Into<String>, baseline_id: impl Into<String>, at: i64) -> Self {
|
||||
let room_id = room_id.into();
|
||||
let baseline_id = baseline_id.into();
|
||||
let mut s = Self {
|
||||
room_id: room_id.clone(),
|
||||
baseline_id: baseline_id.clone(),
|
||||
events: Vec::new(),
|
||||
};
|
||||
s.events.push(EnrollmentEvent::Started {
|
||||
room_id,
|
||||
baseline_id,
|
||||
at,
|
||||
});
|
||||
s
|
||||
}
|
||||
|
||||
/// Append an event (event sourcing — state is derived, never mutated in place).
|
||||
pub fn apply(&mut self, event: EnrollmentEvent) {
|
||||
self.events.push(event);
|
||||
}
|
||||
|
||||
/// The set of accepted anchors (latest acceptance per label wins).
|
||||
pub fn accepted_anchors(&self) -> Vec<Anchor> {
|
||||
let mut out: Vec<Anchor> = Vec::new();
|
||||
for ev in &self.events {
|
||||
if let EnrollmentEvent::AnchorAccepted { anchor } = ev {
|
||||
if let Some(slot) = out.iter_mut().find(|a| a.label == anchor.label) {
|
||||
*slot = anchor.clone();
|
||||
} else {
|
||||
out.push(anchor.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The next anchor in the canonical sequence not yet accepted, if any.
|
||||
pub fn next_anchor(&self) -> Option<AnchorLabel> {
|
||||
let accepted = self.accepted_anchors();
|
||||
AnchorLabel::SEQUENCE
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|label| !accepted.iter().any(|a| a.label == *label))
|
||||
}
|
||||
|
||||
/// `(accepted, total)` progress.
|
||||
pub fn progress(&self) -> (usize, usize) {
|
||||
(
|
||||
self.accepted_anchors().len(),
|
||||
AnchorLabel::SEQUENCE.len(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Whether every anchor in the sequence has been accepted.
|
||||
pub fn is_complete(&self) -> bool {
|
||||
self.next_anchor().is_none()
|
||||
}
|
||||
|
||||
/// Labels still required.
|
||||
pub fn missing(&self) -> Vec<AnchorLabel> {
|
||||
let accepted = self.accepted_anchors();
|
||||
AnchorLabel::SEQUENCE
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|label| !accepted.iter().any(|a| a.label == *label))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn anchor(label: AnchorLabel) -> Anchor {
|
||||
Anchor {
|
||||
label,
|
||||
captured_at_unix_s: 1,
|
||||
quality: AnchorQuality {
|
||||
presence_z: 3.0,
|
||||
motion_rate: 0.1,
|
||||
frames: 400,
|
||||
accepted: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn label_roundtrip() {
|
||||
for l in AnchorLabel::SEQUENCE {
|
||||
assert_eq!(AnchorLabel::from_str(l.as_str()), Some(l));
|
||||
}
|
||||
assert_eq!(AnchorLabel::from_str("nope"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn label_serde_is_snake_case_matching_as_str() {
|
||||
// The JSON wire format must equal as_str() (the documented contract).
|
||||
for l in AnchorLabel::SEQUENCE {
|
||||
let json = serde_json::to_string(&l).unwrap();
|
||||
assert_eq!(json, format!("\"{}\"", l.as_str()));
|
||||
let back: AnchorLabel = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back, l);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sequence_order_and_next() {
|
||||
let mut s = EnrollmentSession::new("living-room", "base-1", 0);
|
||||
assert_eq!(s.next_anchor(), Some(AnchorLabel::Empty));
|
||||
s.apply(EnrollmentEvent::AnchorAccepted {
|
||||
anchor: anchor(AnchorLabel::Empty),
|
||||
});
|
||||
assert_eq!(s.next_anchor(), Some(AnchorLabel::StandStill));
|
||||
assert_eq!(s.progress(), (1, 8));
|
||||
assert!(!s.is_complete());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completion_and_missing() {
|
||||
let mut s = EnrollmentSession::new("r", "b", 0);
|
||||
for l in AnchorLabel::SEQUENCE {
|
||||
s.apply(EnrollmentEvent::AnchorAccepted { anchor: anchor(l) });
|
||||
}
|
||||
assert!(s.is_complete());
|
||||
assert!(s.missing().is_empty());
|
||||
assert_eq!(s.progress(), (8, 8));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reaccept_replaces_not_duplicates() {
|
||||
let mut s = EnrollmentSession::new("r", "b", 0);
|
||||
s.apply(EnrollmentEvent::AnchorAccepted {
|
||||
anchor: anchor(AnchorLabel::Sit),
|
||||
});
|
||||
s.apply(EnrollmentEvent::AnchorAccepted {
|
||||
anchor: anchor(AnchorLabel::Sit),
|
||||
});
|
||||
assert_eq!(
|
||||
s.accepted_anchors()
|
||||
.iter()
|
||||
.filter(|a| a.label == AnchorLabel::Sit)
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn posture_mapping() {
|
||||
assert_eq!(AnchorLabel::StandStill.posture(), Some(Posture::Standing));
|
||||
assert_eq!(AnchorLabel::LieDown.posture(), Some(Posture::Lying));
|
||||
assert_eq!(AnchorLabel::SmallMove.posture(), None);
|
||||
assert!(!AnchorLabel::SmallMove.expects_still());
|
||||
assert!(!AnchorLabel::Empty.expects_presence());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
//! The per-room specialist bank (ADR-151 Stage 4).
|
||||
//!
|
||||
//! A versioned collection of small models scoped to one `room_id`, fit from the
|
||||
//! enrollment anchors and tied to the ADR-135 baseline it was trained against.
|
||||
//! When the baseline drifts (room rearranged, AP moved), the bank is marked
|
||||
//! STALE rather than emitting confident-but-wrong readings — the calibration
|
||||
//! analogue of the firmware's honest `DEGRADED` flag.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::{CalibrationError, Result};
|
||||
use crate::extract::AnchorFeature;
|
||||
use crate::specialist::{
|
||||
AnomalySpecialist, BreathingSpecialist, HeartbeatSpecialist, PostureSpecialist,
|
||||
PresenceSpecialist, RestlessnessSpecialist, SpecialistKind,
|
||||
};
|
||||
|
||||
/// A versioned bank of room-calibrated specialists.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SpecialistBank {
|
||||
/// Room scope.
|
||||
pub room_id: String,
|
||||
/// ADR-135 baseline id this bank was trained against (drift → STALE).
|
||||
pub baseline_id: String,
|
||||
/// Training time (unix seconds).
|
||||
pub trained_at_unix_s: i64,
|
||||
/// Number of anchors used.
|
||||
pub anchor_count: usize,
|
||||
|
||||
/// Presence gate (requires the `empty` + an occupied anchor).
|
||||
pub presence: Option<PresenceSpecialist>,
|
||||
/// Posture classifier (requires posture anchors).
|
||||
pub posture: Option<PostureSpecialist>,
|
||||
/// Breathing (band-limited periodicity; stateless).
|
||||
pub breathing: BreathingSpecialist,
|
||||
/// Heartbeat (band-limited periodicity; stateless).
|
||||
pub heartbeat: HeartbeatSpecialist,
|
||||
/// Restlessness (requires calm + active anchors).
|
||||
pub restlessness: Option<RestlessnessSpecialist>,
|
||||
/// Anomaly novelty detector (requires ≥2 anchors).
|
||||
pub anomaly: Option<AnomalySpecialist>,
|
||||
}
|
||||
|
||||
impl SpecialistBank {
|
||||
/// Train a bank from enrollment anchor features.
|
||||
///
|
||||
/// Requires at least one anchor; specialists whose prerequisite anchors are
|
||||
/// missing are simply left `None` (a partial bank still works for the
|
||||
/// signals it could fit).
|
||||
pub fn train(
|
||||
room_id: impl Into<String>,
|
||||
baseline_id: impl Into<String>,
|
||||
anchors: &[AnchorFeature],
|
||||
at_unix_s: i64,
|
||||
) -> Result<Self> {
|
||||
if anchors.is_empty() {
|
||||
return Err(CalibrationError::InsufficientSamples {
|
||||
kind: "bank".into(),
|
||||
have: 0,
|
||||
need: 1,
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
room_id: room_id.into(),
|
||||
baseline_id: baseline_id.into(),
|
||||
trained_at_unix_s: at_unix_s,
|
||||
anchor_count: anchors.len(),
|
||||
presence: PresenceSpecialist::train(anchors),
|
||||
posture: PostureSpecialist::train(anchors),
|
||||
breathing: BreathingSpecialist::default(),
|
||||
heartbeat: HeartbeatSpecialist::default(),
|
||||
restlessness: RestlessnessSpecialist::train(anchors),
|
||||
anomaly: AnomalySpecialist::train(anchors),
|
||||
})
|
||||
}
|
||||
|
||||
/// `true` if the bank was trained against a different baseline (it is STALE).
|
||||
pub fn is_stale(&self, current_baseline_id: &str) -> bool {
|
||||
self.baseline_id != current_baseline_id
|
||||
}
|
||||
|
||||
/// Error out if stale.
|
||||
pub fn check_fresh(&self, current_baseline_id: &str) -> Result<()> {
|
||||
if self.is_stale(current_baseline_id) {
|
||||
Err(CalibrationError::StaleBaseline {
|
||||
trained: self.baseline_id.clone(),
|
||||
current: current_baseline_id.to_string(),
|
||||
})
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Which specialists were successfully fit.
|
||||
pub fn trained_kinds(&self) -> Vec<SpecialistKind> {
|
||||
let mut v = vec![SpecialistKind::Breathing, SpecialistKind::Heartbeat];
|
||||
if self.presence.is_some() {
|
||||
v.push(SpecialistKind::Presence);
|
||||
}
|
||||
if self.posture.is_some() {
|
||||
v.push(SpecialistKind::Posture);
|
||||
}
|
||||
if self.restlessness.is_some() {
|
||||
v.push(SpecialistKind::Restlessness);
|
||||
}
|
||||
if self.anomaly.is_some() {
|
||||
v.push(SpecialistKind::Anomaly);
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// Serialize to JSON.
|
||||
pub fn to_json(&self) -> Result<String> {
|
||||
serde_json::to_string_pretty(self).map_err(|e| CalibrationError::Serde(e.to_string()))
|
||||
}
|
||||
|
||||
/// Deserialize from JSON.
|
||||
pub fn from_json(s: &str) -> Result<Self> {
|
||||
serde_json::from_str(s).map_err(|e| CalibrationError::Serde(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::anchor::AnchorLabel;
|
||||
use crate::extract::Features;
|
||||
|
||||
fn af(label: AnchorLabel, variance: f32, motion: f32) -> AnchorFeature {
|
||||
AnchorFeature {
|
||||
room_id: "living-room".into(),
|
||||
label,
|
||||
features: Features {
|
||||
mean: 1.0,
|
||||
variance,
|
||||
motion,
|
||||
breathing_score: 0.0,
|
||||
breathing_hz: 0.0,
|
||||
heart_score: 0.0,
|
||||
heart_hz: 0.0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn full_anchors() -> Vec<AnchorFeature> {
|
||||
vec![
|
||||
af(AnchorLabel::Empty, 1.0, 0.1),
|
||||
af(AnchorLabel::StandStill, 10.0, 0.2),
|
||||
af(AnchorLabel::Sit, 6.0, 0.2),
|
||||
af(AnchorLabel::LieDown, 3.0, 0.2),
|
||||
af(AnchorLabel::SmallMove, 4.0, 1.2),
|
||||
af(AnchorLabel::SleepPosture, 3.0, 0.1),
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn train_full_bank() {
|
||||
let bank = SpecialistBank::train("living-room", "base-1", &full_anchors(), 1000).unwrap();
|
||||
let kinds = bank.trained_kinds();
|
||||
assert!(kinds.contains(&SpecialistKind::Presence));
|
||||
assert!(kinds.contains(&SpecialistKind::Posture));
|
||||
assert!(kinds.contains(&SpecialistKind::Restlessness));
|
||||
assert!(kinds.contains(&SpecialistKind::Anomaly));
|
||||
assert_eq!(bank.anchor_count, 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_anchors_error() {
|
||||
assert!(SpecialistBank::train("r", "b", &[], 0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_roundtrip() {
|
||||
let bank = SpecialistBank::train("r", "base-1", &full_anchors(), 1000).unwrap();
|
||||
let json = bank.to_json().unwrap();
|
||||
let back = SpecialistBank::from_json(&json).unwrap();
|
||||
assert_eq!(back.room_id, "r");
|
||||
assert_eq!(back.anchor_count, 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn staleness() {
|
||||
let bank = SpecialistBank::train("r", "base-1", &full_anchors(), 1000).unwrap();
|
||||
assert!(!bank.is_stale("base-1"));
|
||||
assert!(bank.is_stale("base-2"));
|
||||
assert!(bank.check_fresh("base-2").is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
//! Enrollment protocol — per-anchor capture with an adaptive quality gate
|
||||
//! (ADR-151 Stage 2).
|
||||
//!
|
||||
//! Bad anchors poison small calibrated models far more than large ones, so an
|
||||
//! anchor is only *accepted* when its captured statistics match what the anchor
|
||||
//! is supposed to teach: a person present (or absent for `empty`), and the
|
||||
//! expected stillness/motion. Failed anchors are re-prompted, not silently kept.
|
||||
//!
|
||||
//! Quality is measured against the ADR-135 empty-room baseline via
|
||||
//! [`wifi_densepose_signal::BaselineCalibration::deviation`], whose
|
||||
//! `CalibrationDeviationScore` gives a per-frame amplitude z-score (presence
|
||||
//! strength).
|
||||
//!
|
||||
//! **Motion is NOT taken from the score's `motion_flagged`** (ADR-152 finding,
|
||||
//! "z-band squeeze"): that flag fires on `amplitude_z_median > 2.0` — deviation
|
||||
//! from the *empty* baseline — which conflates presence strength with motion. A
|
||||
//! strongly-reflecting person standing perfectly still (z > 2 on every frame)
|
||||
//! would be rejected as "too much motion". Instead the recorder derives motion
|
||||
//! from the frame-to-frame *change* in the deviation series (|Δz| and |Δφ|),
|
||||
//! which is presence-independent: a still strong reflector has high z but a
|
||||
//! flat z-series; a moving person has a jittery one.
|
||||
|
||||
use wifi_densepose_core::types::CsiFrame;
|
||||
use wifi_densepose_signal::{BaselineCalibration, CalibrationDeviationScore};
|
||||
|
||||
use crate::anchor::{Anchor, AnchorLabel, AnchorQuality};
|
||||
|
||||
/// Thresholds for accepting an anchor.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct AnchorQualityGate {
|
||||
/// Minimum mean amplitude z-score to consider a person present.
|
||||
pub min_presence_z: f32,
|
||||
/// For `empty`: maximum mean z-score to consider the room truly empty.
|
||||
pub empty_max_z: f32,
|
||||
/// For "still" anchors: maximum motion-flag rate tolerated.
|
||||
pub max_still_motion: f32,
|
||||
/// For the "move" anchor: minimum motion-flag rate required.
|
||||
pub min_move_motion: f32,
|
||||
/// Minimum frames required to evaluate an anchor.
|
||||
pub min_frames: u32,
|
||||
}
|
||||
|
||||
impl Default for AnchorQualityGate {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
min_presence_z: 1.5,
|
||||
empty_max_z: 1.0,
|
||||
max_still_motion: 0.6,
|
||||
min_move_motion: 0.3,
|
||||
min_frames: 60,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AnchorQualityGate {
|
||||
/// Evaluate accumulated stats for `label`, returning the quality verdict
|
||||
/// and (on rejection) a human-readable reason.
|
||||
pub fn evaluate(
|
||||
&self,
|
||||
label: AnchorLabel,
|
||||
presence_z: f32,
|
||||
motion_rate: f32,
|
||||
frames: u32,
|
||||
) -> (AnchorQuality, Option<String>) {
|
||||
let mut reason: Option<String> = None;
|
||||
|
||||
if frames < self.min_frames {
|
||||
reason = Some(format!(
|
||||
"only {frames} frames (need ≥{}); is the ESP32 streaming?",
|
||||
self.min_frames
|
||||
));
|
||||
} else if label.expects_presence() {
|
||||
if presence_z < self.min_presence_z {
|
||||
reason = Some(format!(
|
||||
"no person detected (presence_z {presence_z:.2} < {:.2}) — move closer / face the sensor",
|
||||
self.min_presence_z
|
||||
));
|
||||
} else if label.expects_still() && motion_rate > self.max_still_motion {
|
||||
reason = Some(format!(
|
||||
"too much motion ({:.0}% > {:.0}%) for a still anchor — hold still",
|
||||
motion_rate * 100.0,
|
||||
self.max_still_motion * 100.0
|
||||
));
|
||||
} else if !label.expects_still() && motion_rate < self.min_move_motion {
|
||||
reason = Some(format!(
|
||||
"not enough motion ({:.0}% < {:.0}%) — move a bit more",
|
||||
motion_rate * 100.0,
|
||||
self.min_move_motion * 100.0
|
||||
));
|
||||
}
|
||||
} else {
|
||||
// `empty` anchor: the room must actually be empty.
|
||||
if presence_z > self.empty_max_z {
|
||||
reason = Some(format!(
|
||||
"room not empty (presence_z {presence_z:.2} > {:.2}) — clear the room",
|
||||
self.empty_max_z
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let quality = AnchorQuality {
|
||||
presence_z,
|
||||
motion_rate,
|
||||
frames,
|
||||
accepted: reason.is_none(),
|
||||
};
|
||||
(quality, reason)
|
||||
}
|
||||
}
|
||||
|
||||
/// Frame-to-frame amplitude-z change above which a frame counts as motion.
|
||||
///
|
||||
/// Presence-independent by construction: a still person shifts the z *level*
|
||||
/// but not its frame-to-frame delta (only noise-scale jitter survives), while
|
||||
/// body movement modulates the reflected paths every frame. Sized well above
|
||||
/// the delta the baseline's own noise floor produces (≲0.3σ) and well below
|
||||
/// the delta even small limb movements produce (≳1σ). See ADR-152.
|
||||
pub const Z_DELTA_MOTION: f32 = 0.5;
|
||||
|
||||
/// Frame-to-frame phase-drift change above which a frame counts as motion.
|
||||
/// Same constant family as the absolute π/6 drift bound in
|
||||
/// `CalibrationDeviationScore`, applied to the delta (static body phase shift
|
||||
/// cancels out).
|
||||
pub const PHASE_DELTA_MOTION: f32 = std::f32::consts::PI / 6.0;
|
||||
|
||||
/// Accumulates per-frame deviation statistics for a single anchor capture.
|
||||
pub struct AnchorRecorder {
|
||||
label: AnchorLabel,
|
||||
z_sum: f64,
|
||||
motion_count: u32,
|
||||
frames: u32,
|
||||
/// Previous frame's (amplitude_z_median, phase_drift_median) for the
|
||||
/// delta-based motion measure (ADR-152 z-band-squeeze fix).
|
||||
prev: Option<(f32, f32)>,
|
||||
}
|
||||
|
||||
impl AnchorRecorder {
|
||||
/// Start recording the given anchor.
|
||||
pub fn new(label: AnchorLabel) -> Self {
|
||||
Self {
|
||||
label,
|
||||
z_sum: 0.0,
|
||||
motion_count: 0,
|
||||
frames: 0,
|
||||
prev: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The anchor being recorded.
|
||||
pub fn label(&self) -> AnchorLabel {
|
||||
self.label
|
||||
}
|
||||
|
||||
/// Frames recorded so far.
|
||||
pub fn frames(&self) -> u32 {
|
||||
self.frames
|
||||
}
|
||||
|
||||
/// Record a pre-computed deviation score (caller runs `baseline.deviation`).
|
||||
///
|
||||
/// Motion is derived from the frame-to-frame change of the deviation
|
||||
/// series, NOT from `score.motion_flagged` — the flag conflates presence
|
||||
/// strength with motion (z-band squeeze, see module docs / ADR-152). The
|
||||
/// first frame of a capture is never motion (no predecessor).
|
||||
pub fn record_score(&mut self, score: &CalibrationDeviationScore) {
|
||||
let z = score.amplitude_z_median;
|
||||
let phase = score.phase_drift_median;
|
||||
if let Some((pz, pp)) = self.prev {
|
||||
if (z - pz).abs() > Z_DELTA_MOTION || (phase - pp).abs() > PHASE_DELTA_MOTION {
|
||||
self.motion_count += 1;
|
||||
}
|
||||
}
|
||||
self.prev = Some((z, phase));
|
||||
self.z_sum += z as f64;
|
||||
self.frames += 1;
|
||||
}
|
||||
|
||||
/// Convenience: record a CSI frame directly against a baseline.
|
||||
/// Frames that fail baseline geometry checks are skipped (not counted).
|
||||
pub fn record_frame(&mut self, baseline: &BaselineCalibration, frame: &CsiFrame) {
|
||||
if let Ok(score) = baseline.deviation(frame) {
|
||||
self.record_score(&score);
|
||||
}
|
||||
}
|
||||
|
||||
/// Mean presence z-score over the capture.
|
||||
pub fn presence_z(&self) -> f32 {
|
||||
if self.frames == 0 {
|
||||
0.0
|
||||
} else {
|
||||
(self.z_sum / self.frames as f64) as f32
|
||||
}
|
||||
}
|
||||
|
||||
/// Fraction of frames flagged as motion.
|
||||
pub fn motion_rate(&self) -> f32 {
|
||||
if self.frames == 0 {
|
||||
0.0
|
||||
} else {
|
||||
self.motion_count as f32 / self.frames as f32
|
||||
}
|
||||
}
|
||||
|
||||
/// Evaluate the capture against the gate and produce an `Anchor` (accepted
|
||||
/// or not) plus a rejection reason.
|
||||
pub fn finalize(
|
||||
&self,
|
||||
gate: &AnchorQualityGate,
|
||||
at_unix_s: i64,
|
||||
) -> (Anchor, Option<String>) {
|
||||
let (quality, reason) =
|
||||
gate.evaluate(self.label, self.presence_z(), self.motion_rate(), self.frames);
|
||||
(
|
||||
Anchor {
|
||||
label: self.label,
|
||||
captured_at_unix_s: at_unix_s,
|
||||
quality,
|
||||
},
|
||||
reason,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Build a score the way `BaselineCalibration::deviation` actually would:
|
||||
/// `motion_flagged` is DERIVED from z (z > 2.0 ⇒ flagged), never free.
|
||||
/// The old tests mocked `(z=3.0, motion=false)` — a combination the real
|
||||
/// producer can never emit, which is exactly how the z-band squeeze hid.
|
||||
fn score(z: f32) -> CalibrationDeviationScore {
|
||||
CalibrationDeviationScore {
|
||||
amplitude_z_median: z,
|
||||
amplitude_z_max: z + 1.0,
|
||||
phase_drift_median: 0.05,
|
||||
motion_flagged: z > 2.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Record a z-series and finalize against the default gate.
|
||||
fn run_series(label: AnchorLabel, zs: &[f32]) -> (Anchor, Option<String>) {
|
||||
let mut r = AnchorRecorder::new(label);
|
||||
for &z in zs {
|
||||
r.record_score(&score(z));
|
||||
}
|
||||
r.finalize(&AnchorQualityGate::default(), 100)
|
||||
}
|
||||
|
||||
/// Constant z (a perfectly still capture at the given presence strength).
|
||||
fn run_still(label: AnchorLabel, z: f32, n: usize) -> (Anchor, Option<String>) {
|
||||
run_series(label, &vec![z; n])
|
||||
}
|
||||
|
||||
/// Alternating z (every frame's |Δz| exceeds Z_DELTA_MOTION ⇒ all motion).
|
||||
fn run_jittery(label: AnchorLabel, z: f32, n: usize) -> (Anchor, Option<String>) {
|
||||
let zs: Vec<f32> = (0..n)
|
||||
.map(|i| if i % 2 == 0 { z } else { z + 2.0 * Z_DELTA_MOTION })
|
||||
.collect();
|
||||
run_series(label, &zs)
|
||||
}
|
||||
|
||||
/// ADR-152 z-band-squeeze regression: a STRONGLY-reflecting still person
|
||||
/// (z = 3.0, so every frame is motion_flagged by the baseline heuristic)
|
||||
/// must still pass a still anchor — presence strength is not motion.
|
||||
#[test]
|
||||
fn still_anchor_with_strong_still_person_accepts() {
|
||||
let (a, reason) = run_still(AnchorLabel::StandStill, 3.0, 400);
|
||||
assert!(a.quality.accepted, "z-band squeeze is back: {reason:?}");
|
||||
assert!(reason.is_none());
|
||||
assert!(a.quality.motion_rate < 0.05, "flat z-series must read still");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn still_anchor_rejects_when_no_presence() {
|
||||
let (a, reason) = run_still(AnchorLabel::Sit, 0.4, 400);
|
||||
assert!(!a.quality.accepted);
|
||||
assert!(reason.unwrap().contains("no person"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn still_anchor_rejects_on_motion() {
|
||||
let (a, reason) = run_jittery(AnchorLabel::LieDown, 3.0, 400);
|
||||
assert!(!a.quality.accepted);
|
||||
assert!(reason.unwrap().contains("motion"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_anchor_requires_motion() {
|
||||
let (still, r1) = run_still(AnchorLabel::SmallMove, 3.0, 400);
|
||||
assert!(!still.quality.accepted);
|
||||
assert!(r1.unwrap().contains("not enough motion"));
|
||||
let (moving, r2) = run_jittery(AnchorLabel::SmallMove, 3.0, 400);
|
||||
assert!(moving.quality.accepted, "reason: {r2:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn phase_delta_also_counts_as_motion() {
|
||||
// Constant z but a phase-drift series that swings past PHASE_DELTA_MOTION
|
||||
// every frame — motion must be detected from the phase channel alone.
|
||||
let mut r = AnchorRecorder::new(AnchorLabel::LieDown);
|
||||
for i in 0..400 {
|
||||
let mut s = score(1.8);
|
||||
s.phase_drift_median = if i % 2 == 0 { 0.0 } else { PHASE_DELTA_MOTION * 1.5 };
|
||||
r.record_score(&s);
|
||||
}
|
||||
let (a, reason) = r.finalize(&AnchorQualityGate::default(), 100);
|
||||
assert!(!a.quality.accepted);
|
||||
assert!(reason.unwrap().contains("motion"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_anchor_rejects_when_occupied() {
|
||||
let (occupied, reason) = run_still(AnchorLabel::Empty, 3.0, 400);
|
||||
assert!(!occupied.quality.accepted);
|
||||
assert!(reason.unwrap().contains("not empty"));
|
||||
let (empty, _) = run_still(AnchorLabel::Empty, 0.3, 400);
|
||||
assert!(empty.quality.accepted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn too_few_frames_rejected() {
|
||||
let (a, reason) = run_still(AnchorLabel::Sit, 3.0, 10);
|
||||
assert!(!a.quality.accepted);
|
||||
assert!(reason.unwrap().contains("frames"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
//! Error types for the calibration pipeline.
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
/// Errors surfaced by the per-room calibration & training pipeline (ADR-151).
|
||||
#[derive(Debug, Error)]
|
||||
pub enum CalibrationError {
|
||||
/// An anchor was recorded with zero frames.
|
||||
#[error("anchor '{0}' captured no frames")]
|
||||
EmptyAnchor(String),
|
||||
|
||||
/// The enrollment session is missing anchors required to train a specialist.
|
||||
#[error("enrollment incomplete: missing anchors {missing:?}")]
|
||||
IncompleteEnrollment {
|
||||
/// Labels still required.
|
||||
missing: Vec<String>,
|
||||
},
|
||||
|
||||
/// A frame did not match the expected tier geometry.
|
||||
#[error("frame geometry mismatch: {0}")]
|
||||
Geometry(String),
|
||||
|
||||
/// Not enough samples to fit a specialist.
|
||||
#[error("insufficient samples for '{kind}': have {have}, need {need}")]
|
||||
InsufficientSamples {
|
||||
/// Specialist kind.
|
||||
kind: String,
|
||||
/// Samples available.
|
||||
have: usize,
|
||||
/// Samples required.
|
||||
need: usize,
|
||||
},
|
||||
|
||||
/// Serialization / persistence failure.
|
||||
#[error("serialization error: {0}")]
|
||||
Serde(String),
|
||||
|
||||
/// The specialist bank was trained against a different baseline and is stale.
|
||||
#[error("bank is STALE: trained against baseline {trained}, current is {current}")]
|
||||
StaleBaseline {
|
||||
/// Baseline id the bank was trained against.
|
||||
trained: String,
|
||||
/// Current baseline id.
|
||||
current: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Convenience result alias.
|
||||
pub type Result<T> = std::result::Result<T, CalibrationError>;
|
||||
@@ -0,0 +1,295 @@
|
||||
//! Feature extraction (ADR-151 Stage 3).
|
||||
//!
|
||||
//! Turns an anchor capture — a per-frame scalar series derived from the
|
||||
//! baseline-subtracted CSI (mean amplitude or dominant-subcarrier phase) — into
|
||||
//! a compact [`Features`] vector the small specialists consume. No giant model:
|
||||
//! the useful signal (variance, motion, periodicity, dominant rhythm) is cheap
|
||||
//! to compute and is exactly what breathing/heartbeat/posture/presence need.
|
||||
//!
|
||||
//! Heartbeat and breathing are tiny *repeating* disturbances in the RF field, so
|
||||
//! periodicity is estimated by autocorrelation over the relevant band — the same
|
||||
//! technique that fixed the firmware HR estimator (#987).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::anchor::AnchorLabel;
|
||||
|
||||
/// Compact per-capture (or per-window) feature vector.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Features {
|
||||
/// Mean of the scalar series (presence / static load).
|
||||
pub mean: f32,
|
||||
/// Variance of the series (motion / occupancy energy).
|
||||
pub variance: f32,
|
||||
/// Mean absolute first difference (instantaneous motion proxy).
|
||||
pub motion: f32,
|
||||
/// Dominant periodicity score in the breathing band [0, 1].
|
||||
pub breathing_score: f32,
|
||||
/// Dominant breathing frequency (Hz), 0 if none.
|
||||
pub breathing_hz: f32,
|
||||
/// Dominant periodicity score in the heart-rate band [0, 1].
|
||||
pub heart_score: f32,
|
||||
/// Dominant heart-rate frequency (Hz), 0 if none.
|
||||
pub heart_hz: f32,
|
||||
}
|
||||
|
||||
/// Minimum periodicity score for a band's frequency to enter the prototype
|
||||
/// embedding. Below it `autocorr_dominant` still reports its best in-band
|
||||
/// peak, but for noise windows that peak is a *random* in-band frequency —
|
||||
/// letting it into the embedding makes posture/anomaly prototype distances
|
||||
/// noisy (ADR-152 finding, "ungated hz embedding"). The raw `breathing_hz` /
|
||||
/// `heart_hz` fields stay un-gated: the breathing/heartbeat specialists apply
|
||||
/// their own (stricter) `min_score` gates.
|
||||
pub const EMBED_MIN_SCORE: f32 = 0.25;
|
||||
|
||||
impl Features {
|
||||
/// A fixed-length numeric embedding for nearest-prototype classifiers.
|
||||
///
|
||||
/// The hz components are zeroed unless their periodicity score clears
|
||||
/// [`EMBED_MIN_SCORE`] — see the constant's docs.
|
||||
pub fn embedding(&self) -> [f32; 5] {
|
||||
let breathing_hz = if self.breathing_score >= EMBED_MIN_SCORE {
|
||||
self.breathing_hz
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let heart_hz = if self.heart_score >= EMBED_MIN_SCORE {
|
||||
self.heart_hz
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
[self.mean, self.variance, self.motion, breathing_hz, heart_hz]
|
||||
}
|
||||
|
||||
/// Squared Euclidean distance between two embeddings.
|
||||
pub fn distance2(&self, other: &Features) -> f32 {
|
||||
self.embedding()
|
||||
.iter()
|
||||
.zip(other.embedding().iter())
|
||||
.map(|(a, b)| (a - b) * (a - b))
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// Extract features from a per-frame scalar series sampled at `fs` Hz.
|
||||
pub fn from_series(series: &[f32], fs: f32) -> Features {
|
||||
let n = series.len();
|
||||
if n == 0 {
|
||||
return Features {
|
||||
mean: 0.0,
|
||||
variance: 0.0,
|
||||
motion: 0.0,
|
||||
breathing_score: 0.0,
|
||||
breathing_hz: 0.0,
|
||||
heart_score: 0.0,
|
||||
heart_hz: 0.0,
|
||||
};
|
||||
}
|
||||
let mean = series.iter().copied().sum::<f32>() / n as f32;
|
||||
let variance =
|
||||
series.iter().map(|v| (v - mean) * (v - mean)).sum::<f32>() / n as f32;
|
||||
let motion = if n > 1 {
|
||||
series.windows(2).map(|w| (w[1] - w[0]).abs()).sum::<f32>() / (n - 1) as f32
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
// De-mean before periodicity search.
|
||||
let centered: Vec<f32> = series.iter().map(|v| v - mean).collect();
|
||||
let (breathing_hz, breathing_score) = autocorr_dominant(¢ered, fs, 0.1, 0.6);
|
||||
let (heart_hz, heart_score) = autocorr_dominant(¢ered, fs, 0.8, 3.0);
|
||||
|
||||
Features {
|
||||
mean,
|
||||
variance,
|
||||
motion,
|
||||
breathing_score,
|
||||
breathing_hz,
|
||||
heart_score,
|
||||
heart_hz,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A labelled feature record from an enrollment anchor (ADR-151 Stage 3).
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct AnchorFeature {
|
||||
/// Room scope.
|
||||
pub room_id: String,
|
||||
/// Which anchor this came from.
|
||||
pub label: AnchorLabel,
|
||||
/// The extracted features.
|
||||
pub features: Features,
|
||||
}
|
||||
|
||||
impl AnchorFeature {
|
||||
/// Build from a per-frame scalar series.
|
||||
pub fn from_series(
|
||||
room_id: impl Into<String>,
|
||||
label: AnchorLabel,
|
||||
series: &[f32],
|
||||
fs: f32,
|
||||
) -> AnchorFeature {
|
||||
AnchorFeature {
|
||||
room_id: room_id.into(),
|
||||
label,
|
||||
features: Features::from_series(series, fs),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Dominant frequency in `[lo_hz, hi_hz]` via autocorrelation, with a normalized
|
||||
/// peak score in `[0, 1]`. Returns `(0, 0)` if no confident peak.
|
||||
///
|
||||
/// The winning lag must be an **interior local maximum** of the in-band
|
||||
/// autocorrelation, not a band-edge value (ADR-152 finding, "heart-band
|
||||
/// leakage"): a strong out-of-band rhythm — breathing bleeding into the HR
|
||||
/// band — produces a monotonic slope whose largest in-band value sits at the
|
||||
/// lag floor (pinning `heart_hz` near the band's top frequency with a high
|
||||
/// score). A genuine in-band periodicity peaks *inside* the band; an edge
|
||||
/// maximum is leakage and is rejected.
|
||||
pub fn autocorr_dominant(sig: &[f32], fs: f32, lo_hz: f32, hi_hz: f32) -> (f32, f32) {
|
||||
let n = sig.len();
|
||||
if n < 16 || fs <= 0.0 || hi_hz <= lo_hz {
|
||||
return (0.0, 0.0);
|
||||
}
|
||||
let lag_min = ((fs / hi_hz).floor() as usize).max(1);
|
||||
let lag_max = ((fs / lo_hz).ceil() as usize).min(n - 1);
|
||||
if lag_max <= lag_min + 1 {
|
||||
return (0.0, 0.0);
|
||||
}
|
||||
|
||||
let r0: f32 = sig.iter().map(|v| v * v).sum();
|
||||
if r0 <= 1e-6 {
|
||||
return (0.0, 0.0);
|
||||
}
|
||||
|
||||
// Autocorrelation over the band, extended one lag on each side so the
|
||||
// band edges have real neighbors for the local-max test.
|
||||
let ext_min = lag_min.saturating_sub(1).max(1);
|
||||
let ext_max = (lag_max + 1).min(n - 1);
|
||||
let acc: Vec<f32> = (ext_min..=ext_max)
|
||||
.map(|lag| (0..(n - lag)).map(|i| sig[i] * sig[i + lag]).sum())
|
||||
.collect();
|
||||
|
||||
let mut best = 0.0f32;
|
||||
let mut best_lag = 0usize;
|
||||
for lag in lag_min..=lag_max {
|
||||
let idx = lag - ext_min;
|
||||
if idx == 0 || idx + 1 >= acc.len() {
|
||||
continue; // no neighbor on one side — cannot prove a local max
|
||||
}
|
||||
let v = acc[idx];
|
||||
// Interior local maximum (ties to the left tolerated for plateaus).
|
||||
if v >= acc[idx - 1] && v > acc[idx + 1] && v > best {
|
||||
best = v;
|
||||
best_lag = lag;
|
||||
}
|
||||
}
|
||||
if best_lag == 0 {
|
||||
return (0.0, 0.0);
|
||||
}
|
||||
let score = (best / r0).clamp(0.0, 1.0);
|
||||
(fs / best_lag as f32, score)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::f32::consts::PI;
|
||||
|
||||
fn sine(freq_hz: f32, fs: f32, n: usize) -> Vec<f32> {
|
||||
(0..n)
|
||||
.map(|i| (2.0 * PI * freq_hz * i as f32 / fs).sin())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autocorr_finds_breathing_freq() {
|
||||
// 0.25 Hz (15 BPM) breathing, sampled at 15 Hz for 20 s.
|
||||
let fs = 15.0;
|
||||
let s = sine(0.25, fs, (fs * 20.0) as usize);
|
||||
let (hz, score) = autocorr_dominant(&s, fs, 0.1, 0.6);
|
||||
assert!((hz - 0.25).abs() < 0.05, "got {hz}");
|
||||
assert!(score > 0.5, "score {score}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autocorr_finds_heart_freq() {
|
||||
// 1.45 Hz (~87 BPM), sampled at 15 Hz.
|
||||
let fs = 15.0;
|
||||
let s = sine(1.45, fs, (fs * 20.0) as usize);
|
||||
let (hz, _) = autocorr_dominant(&s, fs, 0.8, 3.0);
|
||||
assert!((hz * 60.0 - 87.0).abs() < 12.0, "got {} bpm", hz * 60.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn features_capture_breathing() {
|
||||
let fs = 15.0;
|
||||
let s = sine(0.3, fs, 300);
|
||||
let f = Features::from_series(&s, fs);
|
||||
assert!(f.breathing_score > 0.4);
|
||||
assert!((f.breathing_hz - 0.3).abs() < 0.06);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn motion_distinguishes_still_from_noisy() {
|
||||
let still = vec![1.0f32; 200];
|
||||
let noisy: Vec<f32> = (0..200).map(|i| if i % 2 == 0 { 0.0 } else { 5.0 }).collect();
|
||||
assert!(Features::from_series(&still, 15.0).motion < Features::from_series(&noisy, 15.0).motion);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_series_is_safe() {
|
||||
let f = Features::from_series(&[], 15.0);
|
||||
assert_eq!(f.mean, 0.0);
|
||||
assert_eq!(f.breathing_hz, 0.0);
|
||||
}
|
||||
|
||||
/// ADR-152 "heart-band leakage" regression: a strong breathing rhythm must
|
||||
/// NOT register as a heart-band periodicity — its in-band autocorr maximum
|
||||
/// sits at the band edge (monotonic leak), not an interior peak.
|
||||
#[test]
|
||||
fn heart_band_rejects_breathing_leakage() {
|
||||
let fs = 20.0;
|
||||
// Pure 0.30 Hz breathing, no heart component at all.
|
||||
let s = sine(0.30, fs, (fs * 30.0) as usize);
|
||||
let (hz, score) = autocorr_dominant(&s, fs, 0.8, 3.0);
|
||||
assert!(
|
||||
score < 0.25,
|
||||
"breathing-only signal scored {score} in the heart band (hz {hz}) — \
|
||||
the lag-floor leak is back"
|
||||
);
|
||||
// The breathing band itself must still find the true rate.
|
||||
let (bhz, bscore) = autocorr_dominant(&s, fs, 0.1, 0.6);
|
||||
assert!((bhz - 0.30).abs() < 0.05, "breathing band got {bhz}");
|
||||
assert!(bscore > 0.5);
|
||||
}
|
||||
|
||||
/// ADR-152 "ungated hz embedding" regression: a low-score in-band peak
|
||||
/// (noise) must NOT leak its random frequency into the prototype
|
||||
/// embedding, while a confident peak must pass through unchanged.
|
||||
#[test]
|
||||
fn embedding_gates_hz_on_score() {
|
||||
let noisy = Features {
|
||||
mean: 1.0,
|
||||
variance: 2.0,
|
||||
motion: 0.3,
|
||||
breathing_score: EMBED_MIN_SCORE - 0.05,
|
||||
breathing_hz: 0.42, // random in-band peak from a noise window
|
||||
heart_score: EMBED_MIN_SCORE - 0.05,
|
||||
heart_hz: 3.3, // breathing leakage pinned at the lag floor
|
||||
};
|
||||
let e = noisy.embedding();
|
||||
assert_eq!(e[3], 0.0, "low-score breathing_hz must be gated out");
|
||||
assert_eq!(e[4], 0.0, "low-score heart_hz must be gated out");
|
||||
|
||||
let confident = Features {
|
||||
breathing_score: EMBED_MIN_SCORE + 0.3,
|
||||
heart_score: EMBED_MIN_SCORE + 0.3,
|
||||
..noisy
|
||||
};
|
||||
let e = confident.embedding();
|
||||
assert_eq!(e[3], 0.42, "confident breathing_hz must pass through");
|
||||
assert_eq!(e[4], 3.3, "confident heart_hz must pass through");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
//! # wifi-densepose-calibration — ADR-151 per-room calibration & specialist training
|
||||
//!
|
||||
//! "Teach the room before you teach the model." A local-first pipeline that turns
|
||||
//! a few minutes of clean human anchors — layered on the ADR-135 empty-room
|
||||
//! baseline — into a versioned bank of small, specialised models for breathing,
|
||||
//! heartbeat, restlessness, posture, presence, and anomaly.
|
||||
//!
|
||||
//! Stages (ADR-151 §1.3):
|
||||
//! 1. **baseline** — empty-room environmental fingerprint (ADR-135; consumed here).
|
||||
//! 2. **enroll** — guided anchors with an adaptive quality gate ([`anchor`], [`enrollment`]).
|
||||
//! 3. **extract** — labelled feature records from anchor captures ([`extract`]).
|
||||
//! 4. **train** — a bank of small specialist models ([`specialist`], [`bank`]) and a
|
||||
//! confidence-gated mixture runtime ([`runtime`]).
|
||||
//!
|
||||
//! Invariants: specialisation over scale; local-first; honest `STALE` degradation
|
||||
//! when the baseline drifts.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
#![warn(missing_docs)]
|
||||
|
||||
pub mod anchor;
|
||||
pub mod enrollment;
|
||||
pub mod error;
|
||||
pub mod extract;
|
||||
pub mod specialist;
|
||||
pub mod bank;
|
||||
pub mod runtime;
|
||||
pub mod multistatic;
|
||||
|
||||
pub use anchor::{Anchor, AnchorLabel, AnchorQuality, EnrollmentEvent, EnrollmentSession, Posture};
|
||||
pub use bank::SpecialistBank;
|
||||
pub use enrollment::{AnchorQualityGate, AnchorRecorder};
|
||||
pub use error::{CalibrationError, Result};
|
||||
pub use extract::AnchorFeature;
|
||||
pub use multistatic::MultiNodeMixture;
|
||||
pub use runtime::{MixtureOfSpecialists, RoomState};
|
||||
pub use specialist::{Specialist, SpecialistKind, SpecialistReading};
|
||||
@@ -0,0 +1,265 @@
|
||||
//! Multistatic fusion (ADR-029 / ADR-151) — combine several *co-located* nodes
|
||||
//! observing one room.
|
||||
//!
|
||||
//! More links = more geometric diversity, so a person hidden from one node's
|
||||
//! line of sight is caught by another. Each node carries its own room-calibrated
|
||||
//! [`SpecialistBank`] (its own baseline + anchors); this fuses their per-window
|
||||
//! readings into a single [`RoomState`]:
|
||||
//!
|
||||
//! - **presence** — OR across nodes (any node seeing a person wins);
|
||||
//! - **posture / breathing / heartbeat** — the highest-*confidence* node (best
|
||||
//! viewpoint for that signal that window);
|
||||
//! - **restlessness** — max (any node detecting movement);
|
||||
//! - **anomaly / veto** — max / any (a single implausible node vetoes the room);
|
||||
//! - **stale** — any node's bank stale flags the fused result.
|
||||
//!
|
||||
//! This is *same-room* multistatic. Nodes in *different* rooms are a federation
|
||||
//! concern (ADR-105), not fusion — see ADR-151 §3.3.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::bank::SpecialistBank;
|
||||
use crate::extract::Features;
|
||||
use crate::runtime::{MixtureOfSpecialists, RoomState};
|
||||
use crate::specialist::SpecialistReading;
|
||||
|
||||
/// A bank plus the node's current baseline id (for per-node staleness).
|
||||
struct NodeEntry {
|
||||
mixture: MixtureOfSpecialists,
|
||||
baseline_id: String,
|
||||
}
|
||||
|
||||
/// Fuses co-located nodes' specialist banks into one room state.
|
||||
#[derive(Default)]
|
||||
pub struct MultiNodeMixture {
|
||||
nodes: BTreeMap<u8, NodeEntry>,
|
||||
}
|
||||
|
||||
impl MultiNodeMixture {
|
||||
/// Empty fusion set.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
nodes: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Register a node's bank. `current_baseline_id` is the baseline the node is
|
||||
/// observing now (drift vs the bank's training baseline → STALE).
|
||||
pub fn add_node(&mut self, node_id: u8, bank: SpecialistBank, current_baseline_id: impl Into<String>) {
|
||||
self.nodes.insert(
|
||||
node_id,
|
||||
NodeEntry {
|
||||
mixture: MixtureOfSpecialists::new(bank),
|
||||
baseline_id: current_baseline_id.into(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Number of registered nodes.
|
||||
pub fn node_count(&self) -> usize {
|
||||
self.nodes.len()
|
||||
}
|
||||
|
||||
/// Fuse per-node feature windows into one room state. Nodes without a feature
|
||||
/// entry this window are skipped.
|
||||
pub fn infer(&self, per_node: &BTreeMap<u8, Features>) -> RoomState {
|
||||
let states: Vec<RoomState> = per_node
|
||||
.iter()
|
||||
.filter_map(|(id, f)| {
|
||||
self.nodes
|
||||
.get(id)
|
||||
.map(|e| e.mixture.infer(f, &e.baseline_id))
|
||||
})
|
||||
.collect();
|
||||
|
||||
if states.is_empty() {
|
||||
return RoomState::default();
|
||||
}
|
||||
|
||||
let presence = fuse_presence(&states);
|
||||
let anomaly = max_value(states.iter().map(|s| &s.anomaly));
|
||||
// Conservative: a single node seeing a physically-implausible signal
|
||||
// vetoes the room (anti-hallucination, same as the single-node runtime).
|
||||
let vetoed = states.iter().any(|s| s.vetoed);
|
||||
let present = presence.as_ref().map(|r| r.value > 0.5).unwrap_or(true);
|
||||
|
||||
// Vitals/posture only when present and not vetoed.
|
||||
let (posture, breathing, heartbeat) = if present && !vetoed {
|
||||
(
|
||||
best_confidence(states.iter().map(|s| &s.posture)),
|
||||
best_confidence(states.iter().map(|s| &s.breathing)),
|
||||
best_confidence(states.iter().map(|s| &s.heartbeat)),
|
||||
)
|
||||
} else {
|
||||
(None, None, None)
|
||||
};
|
||||
|
||||
RoomState {
|
||||
presence,
|
||||
posture,
|
||||
breathing,
|
||||
heartbeat,
|
||||
restlessness: max_value(states.iter().map(|s| &s.restlessness)),
|
||||
anomaly,
|
||||
vetoed,
|
||||
stale: states.iter().any(|s| s.stale),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Presence: a person is present if ANY node sees one; confidence = max.
|
||||
fn fuse_presence(states: &[RoomState]) -> Option<SpecialistReading> {
|
||||
let readings: Vec<&SpecialistReading> = states.iter().filter_map(|s| s.presence.as_ref()).collect();
|
||||
if readings.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let any_present = readings.iter().any(|r| r.value > 0.5);
|
||||
let confidence = readings
|
||||
.iter()
|
||||
.map(|r| r.confidence)
|
||||
.fold(0.0f32, f32::max);
|
||||
Some(SpecialistReading {
|
||||
kind: readings[0].kind,
|
||||
value: if any_present { 1.0 } else { 0.0 },
|
||||
confidence,
|
||||
label: Some(if any_present { "present" } else { "absent" }.into()),
|
||||
})
|
||||
}
|
||||
|
||||
/// Pick the highest-confidence reading across nodes.
|
||||
fn best_confidence<'a>(
|
||||
readings: impl Iterator<Item = &'a Option<SpecialistReading>>,
|
||||
) -> Option<SpecialistReading> {
|
||||
readings
|
||||
.flatten()
|
||||
.fold(None::<&SpecialistReading>, |best, r| match best {
|
||||
Some(b) if b.confidence >= r.confidence => Some(b),
|
||||
_ => Some(r),
|
||||
})
|
||||
.cloned()
|
||||
}
|
||||
|
||||
/// Pick the reading with the maximum value across nodes (movement / anomaly).
|
||||
fn max_value<'a>(
|
||||
readings: impl Iterator<Item = &'a Option<SpecialistReading>>,
|
||||
) -> Option<SpecialistReading> {
|
||||
readings
|
||||
.flatten()
|
||||
.fold(None::<&SpecialistReading>, |best, r| match best {
|
||||
Some(b) if b.value >= r.value => Some(b),
|
||||
_ => Some(r),
|
||||
})
|
||||
.cloned()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::anchor::AnchorLabel;
|
||||
use crate::extract::AnchorFeature;
|
||||
|
||||
fn af(label: AnchorLabel, variance: f32, motion: f32) -> AnchorFeature {
|
||||
AnchorFeature {
|
||||
room_id: "r".into(),
|
||||
label,
|
||||
features: Features {
|
||||
mean: 1.0,
|
||||
variance,
|
||||
motion,
|
||||
breathing_score: 0.0,
|
||||
breathing_hz: 0.0,
|
||||
heart_score: 0.0,
|
||||
heart_hz: 0.0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn bank(baseline: &str) -> SpecialistBank {
|
||||
let anchors = vec![
|
||||
af(AnchorLabel::Empty, 1.0, 0.1),
|
||||
af(AnchorLabel::StandStill, 10.0, 0.2),
|
||||
af(AnchorLabel::Sit, 6.0, 0.2),
|
||||
af(AnchorLabel::SmallMove, 4.0, 1.2),
|
||||
af(AnchorLabel::SleepPosture, 3.0, 0.1),
|
||||
];
|
||||
SpecialistBank::train("r", baseline, &anchors, 1).unwrap()
|
||||
}
|
||||
|
||||
fn live(variance: f32, motion: f32, br_hz: f32, br_score: f32) -> Features {
|
||||
Features {
|
||||
mean: 1.0,
|
||||
variance,
|
||||
motion,
|
||||
breathing_score: br_score,
|
||||
breathing_hz: br_hz,
|
||||
heart_score: 0.0,
|
||||
heart_hz: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_nodes_register() {
|
||||
let mut m = MultiNodeMixture::new();
|
||||
m.add_node(1, bank("b1"), "b1");
|
||||
m.add_node(2, bank("b2"), "b2");
|
||||
assert_eq!(m.node_count(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presence_or_across_nodes() {
|
||||
let mut m = MultiNodeMixture::new();
|
||||
m.add_node(1, bank("b1"), "b1");
|
||||
m.add_node(2, bank("b1"), "b1");
|
||||
// Node 1 sees nobody (low variance), node 2 sees a person (high variance).
|
||||
let mut per = BTreeMap::new();
|
||||
per.insert(1u8, live(1.0, 0.1, 0.0, 0.0));
|
||||
per.insert(2u8, live(12.0, 0.2, 0.3, 0.9));
|
||||
let s = m.infer(&per);
|
||||
assert_eq!(s.presence.unwrap().value, 1.0, "any node present → present");
|
||||
assert!(s.breathing.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn breathing_picks_best_confidence_node() {
|
||||
let mut m = MultiNodeMixture::new();
|
||||
m.add_node(1, bank("b1"), "b1");
|
||||
m.add_node(2, bank("b1"), "b1");
|
||||
let mut per = BTreeMap::new();
|
||||
// Both present; node 2 has the stronger breathing periodicity.
|
||||
per.insert(1u8, live(12.0, 0.2, 0.2, 0.4));
|
||||
per.insert(2u8, live(12.0, 0.2, 0.3, 0.95));
|
||||
let s = m.infer(&per);
|
||||
let br = s.breathing.unwrap();
|
||||
assert!((br.value - 18.0).abs() < 0.3, "picked 0.3 Hz node");
|
||||
assert!(br.confidence > 0.9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anomaly_in_one_node_vetoes_room() {
|
||||
let mut m = MultiNodeMixture::new();
|
||||
m.add_node(1, bank("b1"), "b1");
|
||||
m.add_node(2, bank("b1"), "b1");
|
||||
let mut per = BTreeMap::new();
|
||||
per.insert(1u8, live(12.0, 0.2, 0.3, 0.9));
|
||||
per.insert(2u8, live(9000.0, 500.0, 0.0, 0.0)); // wild outlier
|
||||
let s = m.infer(&per);
|
||||
assert!(s.vetoed);
|
||||
assert!(s.breathing.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_node_flags_room() {
|
||||
let mut m = MultiNodeMixture::new();
|
||||
m.add_node(1, bank("b1"), "b2"); // trained on b1, now observing b2 → stale
|
||||
let mut per = BTreeMap::new();
|
||||
per.insert(1u8, live(12.0, 0.2, 0.3, 0.9));
|
||||
assert!(m.infer(&per).stale);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_window_safe() {
|
||||
let m = MultiNodeMixture::new();
|
||||
let s = m.infer(&BTreeMap::new());
|
||||
assert!(s.presence.is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
//! Mixture-of-specialists runtime (ADR-151 §2.5).
|
||||
//!
|
||||
//! Every specialist consumes the same live feature window and emits a
|
||||
//! `{value, confidence}`. Fusion rules keep the output honest:
|
||||
//! - the **anomaly** specialist holds a veto — a physically-implausible window
|
||||
//! suppresses positive vitals/posture rather than propagating a hallucination;
|
||||
//! - **presence = absent** short-circuits breathing/heartbeat/posture to `None`
|
||||
//! (you cannot have a respiration rate in an empty room);
|
||||
//! - a **STALE** bank (baseline drift) flags every reading.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::bank::SpecialistBank;
|
||||
use crate::extract::Features;
|
||||
use crate::specialist::{Specialist, SpecialistReading};
|
||||
|
||||
/// Fused room state for one feature window.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct RoomState {
|
||||
/// Presence reading.
|
||||
pub presence: Option<SpecialistReading>,
|
||||
/// Posture reading.
|
||||
pub posture: Option<SpecialistReading>,
|
||||
/// Breathing reading (BPM).
|
||||
pub breathing: Option<SpecialistReading>,
|
||||
/// Heartbeat reading (BPM).
|
||||
pub heartbeat: Option<SpecialistReading>,
|
||||
/// Restlessness reading [0, 1].
|
||||
pub restlessness: Option<SpecialistReading>,
|
||||
/// Anomaly reading [0, 1].
|
||||
pub anomaly: Option<SpecialistReading>,
|
||||
/// Anomaly veto fired — vitals/posture suppressed.
|
||||
pub vetoed: bool,
|
||||
/// Bank is stale (baseline drift) — readings are not trustworthy.
|
||||
pub stale: bool,
|
||||
}
|
||||
|
||||
/// Confidence-gated mixture over a [`SpecialistBank`].
|
||||
pub struct MixtureOfSpecialists {
|
||||
bank: SpecialistBank,
|
||||
/// Anomaly score above which vitals/posture are vetoed.
|
||||
pub veto_threshold: f32,
|
||||
}
|
||||
|
||||
impl MixtureOfSpecialists {
|
||||
/// Wrap a bank with the default veto threshold (0.5).
|
||||
pub fn new(bank: SpecialistBank) -> Self {
|
||||
Self {
|
||||
bank,
|
||||
veto_threshold: 0.5,
|
||||
}
|
||||
}
|
||||
|
||||
/// The underlying bank.
|
||||
pub fn bank(&self) -> &SpecialistBank {
|
||||
&self.bank
|
||||
}
|
||||
|
||||
/// Infer fused room state, marking `stale` if the bank was trained against a
|
||||
/// different baseline than `current_baseline_id`.
|
||||
pub fn infer(&self, f: &Features, current_baseline_id: &str) -> RoomState {
|
||||
let mut state = RoomState {
|
||||
stale: self.bank.is_stale(current_baseline_id),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// Anomaly first — it can veto everything else.
|
||||
state.anomaly = self.bank.anomaly.as_ref().and_then(|a| a.infer(f));
|
||||
let vetoed = state
|
||||
.anomaly
|
||||
.as_ref()
|
||||
.map(|r| r.value >= self.veto_threshold)
|
||||
.unwrap_or(false);
|
||||
state.vetoed = vetoed;
|
||||
|
||||
// Presence gate.
|
||||
state.presence = self.bank.presence.as_ref().and_then(|p| p.infer(f));
|
||||
let present = state
|
||||
.presence
|
||||
.as_ref()
|
||||
.map(|r| r.value > 0.5)
|
||||
// No presence specialist → assume present so vitals still run.
|
||||
.unwrap_or(true);
|
||||
|
||||
// Restlessness is reported regardless of presence (movement implies presence).
|
||||
state.restlessness = self.bank.restlessness.as_ref().and_then(|r| r.infer(f));
|
||||
|
||||
// Vitals + posture only when present and not vetoed.
|
||||
if present && !vetoed {
|
||||
state.posture = self.bank.posture.as_ref().and_then(|p| p.infer(f));
|
||||
state.breathing = self.bank.breathing.infer(f);
|
||||
state.heartbeat = self.bank.heartbeat.infer(f);
|
||||
}
|
||||
|
||||
state
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::anchor::AnchorLabel;
|
||||
use crate::extract::{AnchorFeature, Features};
|
||||
|
||||
fn af(label: AnchorLabel, variance: f32, motion: f32) -> AnchorFeature {
|
||||
AnchorFeature {
|
||||
room_id: "r".into(),
|
||||
label,
|
||||
features: Features {
|
||||
mean: 1.0,
|
||||
variance,
|
||||
motion,
|
||||
breathing_score: 0.0,
|
||||
breathing_hz: 0.0,
|
||||
heart_score: 0.0,
|
||||
heart_hz: 0.0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn bank() -> SpecialistBank {
|
||||
let anchors = vec![
|
||||
af(AnchorLabel::Empty, 1.0, 0.1),
|
||||
af(AnchorLabel::StandStill, 10.0, 0.2),
|
||||
af(AnchorLabel::Sit, 6.0, 0.2),
|
||||
af(AnchorLabel::LieDown, 3.0, 0.2),
|
||||
af(AnchorLabel::SmallMove, 4.0, 1.2),
|
||||
af(AnchorLabel::SleepPosture, 3.0, 0.1),
|
||||
];
|
||||
SpecialistBank::train("r", "base-1", &anchors, 1000).unwrap()
|
||||
}
|
||||
|
||||
fn live(variance: f32, motion: f32, br_hz: f32, br_score: f32) -> Features {
|
||||
Features {
|
||||
mean: 1.0,
|
||||
variance,
|
||||
motion,
|
||||
breathing_score: br_score,
|
||||
breathing_hz: br_hz,
|
||||
heart_score: 0.0,
|
||||
heart_hz: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_room_suppresses_vitals() {
|
||||
let mix = MixtureOfSpecialists::new(bank());
|
||||
let s = mix.infer(&live(1.0, 0.1, 0.3, 0.9), "base-1");
|
||||
assert_eq!(s.presence.unwrap().value, 0.0);
|
||||
assert!(s.breathing.is_none(), "no breathing in an empty room");
|
||||
assert!(s.posture.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn present_room_reports_breathing() {
|
||||
let mix = MixtureOfSpecialists::new(bank());
|
||||
let s = mix.infer(&live(10.0, 0.2, 0.3, 0.9), "base-1");
|
||||
assert_eq!(s.presence.unwrap().value, 1.0);
|
||||
let br = s.breathing.unwrap();
|
||||
assert!((br.value - 18.0).abs() < 0.2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anomaly_vetoes_vitals() {
|
||||
let mix = MixtureOfSpecialists::new(bank());
|
||||
// Wildly out-of-distribution window → anomaly veto.
|
||||
let s = mix.infer(&live(5000.0, 200.0, 0.3, 0.9), "base-1");
|
||||
assert!(s.vetoed);
|
||||
assert!(s.breathing.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_bank_flagged() {
|
||||
let mix = MixtureOfSpecialists::new(bank());
|
||||
let s = mix.infer(&live(10.0, 0.2, 0.3, 0.9), "base-2");
|
||||
assert!(s.stale);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,525 @@
|
||||
//! Specialist models (ADR-151 Stage 4).
|
||||
//!
|
||||
//! One small, room-calibrated model per biological signal — *specialisation over
|
||||
//! scale*. Each is fit from the labelled enrollment anchors and is tiny: a
|
||||
//! threshold, a handful of nearest-prototype vectors, or a band-limited
|
||||
//! periodicity read. Faster, cheaper, more private, and — because it is tuned to
|
||||
//! this room's fingerprint — often better than one oversized general model.
|
||||
//!
|
||||
//! (ADR-151's frozen Hugging-Face RF Foundation Encoder backbone is the planned
|
||||
//! upgrade path: these heads would then sit over a shared embedding. The
|
||||
//! statistical heads here make the pipeline runnable and validatable today.)
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::anchor::{AnchorLabel, Posture};
|
||||
use crate::extract::{AnchorFeature, Features};
|
||||
|
||||
/// Which biological signal a specialist estimates.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum SpecialistKind {
|
||||
/// Respiration rate.
|
||||
Breathing,
|
||||
/// Heart rate (experimental on commodity CSI).
|
||||
Heartbeat,
|
||||
/// Sleep restlessness / movement intensity.
|
||||
Restlessness,
|
||||
/// Body posture (standing / sitting / lying).
|
||||
Posture,
|
||||
/// Presence (room occupied or not).
|
||||
Presence,
|
||||
/// Physically-implausible / out-of-distribution signal.
|
||||
Anomaly,
|
||||
}
|
||||
|
||||
/// A single specialist's output.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SpecialistReading {
|
||||
/// Which specialist.
|
||||
pub kind: SpecialistKind,
|
||||
/// Numeric value (BPM, score, or class index — see [`SpecialistReading::label`]).
|
||||
pub value: f32,
|
||||
/// Confidence in `[0, 1]`.
|
||||
pub confidence: f32,
|
||||
/// Optional human-readable label (e.g. posture class).
|
||||
pub label: Option<String>,
|
||||
}
|
||||
|
||||
/// Common specialist behaviour.
|
||||
pub trait Specialist {
|
||||
/// Which signal this estimates.
|
||||
fn kind(&self) -> SpecialistKind;
|
||||
/// Infer from a live feature window; `None` when not applicable / no confidence.
|
||||
fn infer(&self, f: &Features) -> Option<SpecialistReading>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Presence
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Binary presence gate learned from empty vs occupied anchors.
|
||||
///
|
||||
/// Two complementary signals (ADR-152 finding, "variance-only presence"):
|
||||
/// - **variance** — motion/occupancy energy; catches a moving person but is
|
||||
/// blind to a *motionless* one, whose body raises the scalar *mean* (extra
|
||||
/// multipath energy) while barely raising variance;
|
||||
/// - **mean shift** — |mean − empty-room mean|; catches the motionless person
|
||||
/// the variance channel misses. Symmetric (abs) because a body can shadow
|
||||
/// paths and *lower* the mean too.
|
||||
///
|
||||
/// Present when EITHER channel fires.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PresenceSpecialist {
|
||||
/// Decision threshold on series variance.
|
||||
pub threshold: f32,
|
||||
/// Occupied-anchor mean variance (for confidence scaling).
|
||||
pub occupied_var: f32,
|
||||
/// Empty-room mean of the scalar series (mean-shift reference).
|
||||
#[serde(default)]
|
||||
pub empty_mean: f32,
|
||||
/// |mean − empty_mean| beyond which the mean alone indicates presence.
|
||||
/// `None` disables the channel — both for banks persisted before the
|
||||
/// channel existed (serde default) and for rooms where the empty/occupied
|
||||
/// means don't separate at train time.
|
||||
#[serde(default)]
|
||||
pub mean_dist_threshold: Option<f32>,
|
||||
}
|
||||
|
||||
impl PresenceSpecialist {
|
||||
/// Fit from anchors: variance threshold at the midpoint between the empty
|
||||
/// variance and the mean occupied variance; mean-shift threshold at half
|
||||
/// the empty→occupied mean distance (inert when the means don't separate).
|
||||
pub fn train(anchors: &[AnchorFeature]) -> Option<Self> {
|
||||
let empty = anchors.iter().find(|a| a.label == AnchorLabel::Empty)?;
|
||||
let occ: Vec<&Features> = anchors
|
||||
.iter()
|
||||
.filter(|a| a.label.expects_presence())
|
||||
.map(|a| &a.features)
|
||||
.collect();
|
||||
if occ.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let occ_var = occ.iter().map(|f| f.variance).sum::<f32>() / occ.len() as f32;
|
||||
let occ_mean = occ.iter().map(|f| f.mean).sum::<f32>() / occ.len() as f32;
|
||||
let empty_var = empty.features.variance;
|
||||
let empty_mean = empty.features.mean;
|
||||
|
||||
let mean_dist = (occ_mean - empty_mean).abs();
|
||||
let mean_dist_threshold = (mean_dist > 1e-4).then(|| 0.5 * mean_dist);
|
||||
|
||||
Some(Self {
|
||||
threshold: 0.5 * (empty_var + occ_var),
|
||||
occupied_var: occ_var.max(empty_var + 1e-3),
|
||||
empty_mean,
|
||||
mean_dist_threshold,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Specialist for PresenceSpecialist {
|
||||
fn kind(&self) -> SpecialistKind {
|
||||
SpecialistKind::Presence
|
||||
}
|
||||
fn infer(&self, f: &Features) -> Option<SpecialistReading> {
|
||||
let by_variance = f.variance > self.threshold;
|
||||
let mean_dist = (f.mean - self.empty_mean).abs();
|
||||
let by_mean = self
|
||||
.mean_dist_threshold
|
||||
.is_some_and(|thr| mean_dist > thr);
|
||||
let present = by_variance || by_mean;
|
||||
|
||||
// Confidence: strongest margin among the channels that are enabled.
|
||||
let var_span = (self.occupied_var - self.threshold).max(1e-3);
|
||||
let var_conf = ((f.variance - self.threshold).abs() / var_span).clamp(0.0, 1.0);
|
||||
let mean_conf = self
|
||||
.mean_dist_threshold
|
||||
.map(|thr| ((mean_dist - thr).abs() / thr.max(1e-3)).clamp(0.0, 1.0))
|
||||
.unwrap_or(0.0);
|
||||
let confidence = var_conf.max(mean_conf);
|
||||
|
||||
Some(SpecialistReading {
|
||||
kind: SpecialistKind::Presence,
|
||||
value: if present { 1.0 } else { 0.0 },
|
||||
confidence,
|
||||
label: Some(if present { "present" } else { "absent" }.into()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Posture (nearest-prototype)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Posture classifier: nearest prototype over the feature embedding.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PostureSpecialist {
|
||||
/// `(posture, embedding)` prototypes from the posture anchors.
|
||||
pub prototypes: Vec<(Posture, [f32; 5])>,
|
||||
}
|
||||
|
||||
impl PostureSpecialist {
|
||||
/// Fit prototypes from any anchor that establishes a posture.
|
||||
pub fn train(anchors: &[AnchorFeature]) -> Option<Self> {
|
||||
let prototypes: Vec<(Posture, [f32; 5])> = anchors
|
||||
.iter()
|
||||
.filter_map(|a| a.label.posture().map(|p| (p, a.features.embedding())))
|
||||
.collect();
|
||||
if prototypes.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(Self { prototypes })
|
||||
}
|
||||
}
|
||||
|
||||
fn posture_str(p: Posture) -> &'static str {
|
||||
match p {
|
||||
Posture::Standing => "standing",
|
||||
Posture::Sitting => "sitting",
|
||||
Posture::Lying => "lying",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Specialist for PostureSpecialist {
|
||||
fn kind(&self) -> SpecialistKind {
|
||||
SpecialistKind::Posture
|
||||
}
|
||||
fn infer(&self, f: &Features) -> Option<SpecialistReading> {
|
||||
let emb = f.embedding();
|
||||
let mut best = (f32::MAX, Posture::Standing);
|
||||
let mut second = f32::MAX;
|
||||
for (p, proto) in &self.prototypes {
|
||||
let d: f32 = emb.iter().zip(proto).map(|(a, b)| (a - b) * (a - b)).sum();
|
||||
if d < best.0 {
|
||||
second = best.0;
|
||||
best = (d, *p);
|
||||
} else if d < second {
|
||||
second = d;
|
||||
}
|
||||
}
|
||||
// Confidence from the margin between nearest and runner-up.
|
||||
let confidence = if second.is_finite() && (best.0 + second) > 1e-6 {
|
||||
((second - best.0) / (second + best.0)).clamp(0.0, 1.0)
|
||||
} else {
|
||||
0.5
|
||||
};
|
||||
Some(SpecialistReading {
|
||||
kind: SpecialistKind::Posture,
|
||||
value: best.1 as u8 as f32,
|
||||
confidence,
|
||||
label: Some(Self::posture_str(best.1).into()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Breathing / Heartbeat (band-limited periodicity)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Respiration-rate read from the breathing-band periodicity.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct BreathingSpecialist {
|
||||
/// Minimum periodicity score to report a rate.
|
||||
pub min_score: f32,
|
||||
}
|
||||
|
||||
impl Specialist for BreathingSpecialist {
|
||||
fn kind(&self) -> SpecialistKind {
|
||||
SpecialistKind::Breathing
|
||||
}
|
||||
fn infer(&self, f: &Features) -> Option<SpecialistReading> {
|
||||
let min = if self.min_score > 0.0 { self.min_score } else { 0.25 };
|
||||
if f.breathing_score < min || f.breathing_hz <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
Some(SpecialistReading {
|
||||
kind: SpecialistKind::Breathing,
|
||||
value: f.breathing_hz * 60.0,
|
||||
confidence: f.breathing_score,
|
||||
label: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Heart-rate read from the HR-band periodicity (experimental on CSI).
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct HeartbeatSpecialist {
|
||||
/// Minimum periodicity score to report a rate.
|
||||
pub min_score: f32,
|
||||
}
|
||||
|
||||
impl Specialist for HeartbeatSpecialist {
|
||||
fn kind(&self) -> SpecialistKind {
|
||||
SpecialistKind::Heartbeat
|
||||
}
|
||||
fn infer(&self, f: &Features) -> Option<SpecialistReading> {
|
||||
let min = if self.min_score > 0.0 { self.min_score } else { 0.3 };
|
||||
if f.heart_score < min || f.heart_hz <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
Some(SpecialistReading {
|
||||
kind: SpecialistKind::Heartbeat,
|
||||
value: f.heart_hz * 60.0,
|
||||
confidence: f.heart_score,
|
||||
label: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Restlessness
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Restlessness: live motion normalized between the calm (sleep) and active
|
||||
/// (small-move) anchors.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RestlessnessSpecialist {
|
||||
/// Motion at rest (sleep posture).
|
||||
pub calm_motion: f32,
|
||||
/// Motion when actively moving.
|
||||
pub active_motion: f32,
|
||||
}
|
||||
|
||||
impl RestlessnessSpecialist {
|
||||
/// Fit from the sleep-posture (calm) and small-move (active) anchors.
|
||||
pub fn train(anchors: &[AnchorFeature]) -> Option<Self> {
|
||||
let calm = anchors
|
||||
.iter()
|
||||
.find(|a| a.label == AnchorLabel::SleepPosture)
|
||||
.or_else(|| anchors.iter().find(|a| a.label == AnchorLabel::LieDown))?
|
||||
.features
|
||||
.motion;
|
||||
let active = anchors
|
||||
.iter()
|
||||
.find(|a| a.label == AnchorLabel::SmallMove)?
|
||||
.features
|
||||
.motion;
|
||||
if active <= calm {
|
||||
return None;
|
||||
}
|
||||
Some(Self {
|
||||
calm_motion: calm,
|
||||
active_motion: active,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Specialist for RestlessnessSpecialist {
|
||||
fn kind(&self) -> SpecialistKind {
|
||||
SpecialistKind::Restlessness
|
||||
}
|
||||
fn infer(&self, f: &Features) -> Option<SpecialistReading> {
|
||||
let span = (self.active_motion - self.calm_motion).max(1e-3);
|
||||
let r = ((f.motion - self.calm_motion) / span).clamp(0.0, 1.0);
|
||||
Some(SpecialistReading {
|
||||
kind: SpecialistKind::Restlessness,
|
||||
value: r,
|
||||
confidence: 0.7,
|
||||
label: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Anomaly (novelty vs anchor prototypes)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Anomaly detector: distance from the manifold of enrolled anchors. A live
|
||||
/// window far from every anchor prototype is out-of-distribution.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AnomalySpecialist {
|
||||
/// Anchor embeddings (the in-distribution manifold).
|
||||
pub prototypes: Vec<[f32; 5]>,
|
||||
/// Distance scale (typical inter-anchor spread) for normalization.
|
||||
pub scale: f32,
|
||||
}
|
||||
|
||||
impl AnomalySpecialist {
|
||||
/// Fit from all anchor embeddings.
|
||||
pub fn train(anchors: &[AnchorFeature]) -> Option<Self> {
|
||||
if anchors.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
let prototypes: Vec<[f32; 5]> = anchors.iter().map(|a| a.features.embedding()).collect();
|
||||
// Scale = mean nearest-neighbour distance among prototypes.
|
||||
let mut nn_sum = 0.0f32;
|
||||
for (i, p) in prototypes.iter().enumerate() {
|
||||
let mut best = f32::MAX;
|
||||
for (j, q) in prototypes.iter().enumerate() {
|
||||
if i == j {
|
||||
continue;
|
||||
}
|
||||
let d: f32 = p.iter().zip(q).map(|(a, b)| (a - b) * (a - b)).sum();
|
||||
best = best.min(d);
|
||||
}
|
||||
if best.is_finite() {
|
||||
nn_sum += best.sqrt();
|
||||
}
|
||||
}
|
||||
let scale = (nn_sum / prototypes.len() as f32).max(1e-3);
|
||||
Some(Self { prototypes, scale })
|
||||
}
|
||||
}
|
||||
|
||||
impl Specialist for AnomalySpecialist {
|
||||
fn kind(&self) -> SpecialistKind {
|
||||
SpecialistKind::Anomaly
|
||||
}
|
||||
fn infer(&self, f: &Features) -> Option<SpecialistReading> {
|
||||
let emb = f.embedding();
|
||||
let mut best = f32::MAX;
|
||||
for proto in &self.prototypes {
|
||||
let d: f32 = emb
|
||||
.iter()
|
||||
.zip(proto)
|
||||
.map(|(a, b)| (a - b) * (a - b))
|
||||
.sum::<f32>()
|
||||
.sqrt();
|
||||
best = best.min(d);
|
||||
}
|
||||
// >2× the typical spread → anomalous.
|
||||
let score = (best / (2.0 * self.scale)).clamp(0.0, 1.0);
|
||||
Some(SpecialistReading {
|
||||
kind: SpecialistKind::Anomaly,
|
||||
value: score,
|
||||
confidence: 0.6,
|
||||
label: Some(if score > 0.5 { "anomalous" } else { "normal" }.into()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn feat(variance: f32, motion: f32, br_hz: f32, br_score: f32) -> Features {
|
||||
Features {
|
||||
mean: 1.0,
|
||||
variance,
|
||||
motion,
|
||||
breathing_score: br_score,
|
||||
breathing_hz: br_hz,
|
||||
heart_score: 0.0,
|
||||
heart_hz: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
fn af(label: AnchorLabel, variance: f32, motion: f32) -> AnchorFeature {
|
||||
AnchorFeature {
|
||||
room_id: "r".into(),
|
||||
label,
|
||||
features: feat(variance, motion, 0.0, 0.0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Like `feat` but with an explicit series mean (the presence mean-gate input).
|
||||
fn feat_mean(mean: f32, variance: f32, motion: f32) -> Features {
|
||||
Features {
|
||||
mean,
|
||||
variance,
|
||||
motion,
|
||||
breathing_score: 0.0,
|
||||
breathing_hz: 0.0,
|
||||
heart_score: 0.0,
|
||||
heart_hz: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
fn af_mean(label: AnchorLabel, mean: f32, variance: f32, motion: f32) -> AnchorFeature {
|
||||
AnchorFeature {
|
||||
room_id: "r".into(),
|
||||
label,
|
||||
features: feat_mean(mean, variance, motion),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presence_learns_threshold_and_classifies() {
|
||||
let anchors = vec![
|
||||
af(AnchorLabel::Empty, 1.0, 0.1),
|
||||
af(AnchorLabel::StandStill, 10.0, 0.2),
|
||||
];
|
||||
let p = PresenceSpecialist::train(&anchors).unwrap();
|
||||
assert!(p.infer(&feat(12.0, 0.2, 0.0, 0.0)).unwrap().value == 1.0);
|
||||
assert!(p.infer(&feat(1.0, 0.1, 0.0, 0.0)).unwrap().value == 0.0);
|
||||
}
|
||||
|
||||
/// ADR-152 "variance-only presence" regression: a MOTIONLESS person raises
|
||||
/// the scalar mean (extra multipath energy) but barely the variance — the
|
||||
/// mean channel must still detect them, and a window matching the empty
|
||||
/// room on BOTH channels must still read absent.
|
||||
#[test]
|
||||
fn presence_detects_motionless_person_via_mean_shift() {
|
||||
let anchors = vec![
|
||||
af_mean(AnchorLabel::Empty, 1.0, 1.0, 0.1),
|
||||
af_mean(AnchorLabel::StandStill, 1.6, 10.0, 0.2),
|
||||
af_mean(AnchorLabel::LieDown, 1.5, 8.0, 0.15),
|
||||
];
|
||||
let p = PresenceSpecialist::train(&anchors).unwrap();
|
||||
// Motionless person: variance at the empty level, mean shifted.
|
||||
let r = p.infer(&feat_mean(1.55, 1.0, 0.05)).unwrap();
|
||||
assert_eq!(r.value, 1.0, "motionless person must read present");
|
||||
// Truly empty window: both channels quiet.
|
||||
let r = p.infer(&feat_mean(1.0, 1.0, 0.05)).unwrap();
|
||||
assert_eq!(r.value, 0.0, "empty room must still read absent");
|
||||
}
|
||||
|
||||
/// Banks persisted BEFORE the mean gate existed must deserialize to the
|
||||
/// inert (+∞) gate and keep their original variance-only behavior.
|
||||
#[test]
|
||||
fn presence_old_bank_json_stays_variance_only() {
|
||||
let old_json = r#"{"threshold":5.5,"occupied_var":10.0}"#;
|
||||
let p: PresenceSpecialist = serde_json::from_str(old_json).unwrap();
|
||||
assert!(p.mean_dist_threshold.is_none());
|
||||
// Mean wildly shifted but variance below threshold → still absent
|
||||
// (old behavior preserved; the mean channel is disabled).
|
||||
let r = p.infer(&feat_mean(99.0, 1.0, 0.05)).unwrap();
|
||||
assert_eq!(r.value, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn posture_nearest_prototype() {
|
||||
let anchors = vec![
|
||||
af(AnchorLabel::StandStill, 10.0, 0.2),
|
||||
af(AnchorLabel::Sit, 6.0, 0.2),
|
||||
af(AnchorLabel::LieDown, 3.0, 0.2),
|
||||
];
|
||||
let post = PostureSpecialist::train(&anchors).unwrap();
|
||||
// A window close to the standing prototype.
|
||||
let r = post.infer(&feat(10.1, 0.2, 0.0, 0.0)).unwrap();
|
||||
assert_eq!(r.label.as_deref(), Some("standing"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn breathing_reports_bpm() {
|
||||
let b = BreathingSpecialist::default();
|
||||
let r = b.infer(&feat(5.0, 0.2, 0.3, 0.8)).unwrap();
|
||||
assert!((r.value - 18.0).abs() < 0.1); // 0.3 Hz = 18 BPM
|
||||
assert!(r.confidence > 0.5);
|
||||
assert!(b.infer(&feat(5.0, 0.2, 0.3, 0.1)).is_none()); // low score → none
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restlessness_normalizes() {
|
||||
let anchors = vec![
|
||||
af(AnchorLabel::SleepPosture, 3.0, 0.1),
|
||||
af(AnchorLabel::SmallMove, 3.0, 1.1),
|
||||
];
|
||||
let rs = RestlessnessSpecialist::train(&anchors).unwrap();
|
||||
assert!(rs.infer(&feat(3.0, 0.1, 0.0, 0.0)).unwrap().value < 0.1);
|
||||
assert!(rs.infer(&feat(3.0, 1.1, 0.0, 0.0)).unwrap().value > 0.9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anomaly_flags_outliers() {
|
||||
let anchors = vec![
|
||||
af(AnchorLabel::Empty, 1.0, 0.1),
|
||||
af(AnchorLabel::StandStill, 10.0, 0.2),
|
||||
af(AnchorLabel::Sit, 6.0, 0.2),
|
||||
];
|
||||
let a = AnomalySpecialist::train(&anchors).unwrap();
|
||||
// Far-out window.
|
||||
let r = a.infer(&feat(500.0, 50.0, 0.0, 0.0)).unwrap();
|
||||
assert!(r.value > 0.5, "score {}", r.value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,437 @@
|
||||
//! Full-loop integration test for the ADR-151 calibration pipeline (software half
|
||||
//! of the §7 validation gap): a clean empty-room **baseline → enroll → extract →
|
||||
//! train → infer** loop, driven end-to-end through the crates' public API in the
|
||||
//! exact order the CLI (`calibrate` → `enroll` → `train-room` → `room-watch`)
|
||||
//! wires the stages.
|
||||
//!
|
||||
//! CSI is synthetic but physically plausible:
|
||||
//! - **empty room**: stable per-subcarrier amplitudes + small complex Gaussian
|
||||
//! noise (the ADR-135 roundtrip-test fingerprint) — never motion-flagged;
|
||||
//! - **person present**: a common amplitude offset (extra multipath energy),
|
||||
//! small body sway, and a constant phase shift. Presence strength is free to
|
||||
//! exceed z = 2.0 — since the ADR-152 z-band-squeeze fix, anchor motion is
|
||||
//! measured from frame-to-frame deltas, not from the absolute deviation, so
|
||||
//! a strongly-reflecting *still* person is no longer misread as "moving";
|
||||
//! - **breathing**: a few-percent periodic amplitude modulation (0.125–0.3 Hz)
|
||||
//! on a subset of subcarriers — visible in the mean-amplitude scalar the CLI
|
||||
//! uses, invisible to the per-frame *median* z (so still anchors stay still);
|
||||
//! - **small movement**: per-frame amplitude jitter + a phase wobble that swings
|
||||
//! past the π/6 drift threshold.
|
||||
//!
|
||||
//! Deterministic (xorshift32, fixed seeds), no I/O, no hardware. What remains
|
||||
//! hardware-only is the on-target run with real ESP32 CSI and a live operator.
|
||||
|
||||
use std::f32::consts::PI;
|
||||
|
||||
use ndarray::Array2;
|
||||
use num_complex::Complex64;
|
||||
use wifi_densepose_calibration::extract::Features;
|
||||
use wifi_densepose_calibration::{
|
||||
AnchorFeature, AnchorLabel, AnchorQualityGate, AnchorRecorder, EnrollmentEvent,
|
||||
EnrollmentSession, MixtureOfSpecialists, SpecialistBank, SpecialistKind,
|
||||
};
|
||||
use wifi_densepose_core::types::{AntennaConfig, CsiFrame, CsiMetadata, DeviceId, FrequencyBand};
|
||||
use wifi_densepose_signal::{BaselineCalibration, CalibrationConfig, CalibrationRecorder};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Deterministic PRNG (xorshift32 + Box-Muller) — same pattern as
|
||||
// wifi-densepose-signal/tests/calibration_roundtrip.rs.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct Rng(u32);
|
||||
|
||||
impl Rng {
|
||||
fn new(seed: u32) -> Self {
|
||||
assert_ne!(seed, 0, "xorshift seed must be non-zero");
|
||||
Self(seed)
|
||||
}
|
||||
fn next_u32(&mut self) -> u32 {
|
||||
let mut x = self.0;
|
||||
x ^= x << 13;
|
||||
x ^= x >> 17;
|
||||
x ^= x << 5;
|
||||
self.0 = x;
|
||||
x
|
||||
}
|
||||
fn next_normal(&mut self) -> f32 {
|
||||
let u1 = (self.next_u32() as f32 + 1.0) / (u32::MAX as f32 + 2.0);
|
||||
let u2 = (self.next_u32() as f32 + 1.0) / (u32::MAX as f32 + 2.0);
|
||||
(-2.0 * u1.ln()).sqrt() * (2.0 * PI * u2).cos()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Synthetic room (HT20: 52 active subcarriers @ 20 Hz)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const N_SC: usize = 52;
|
||||
const FS_HZ: f32 = 20.0;
|
||||
/// Complex-noise std per quadrature ⇒ amplitude noise std ≈ NOISE_STD.
|
||||
const NOISE_STD: f32 = 0.01;
|
||||
/// Capture length per enrollment anchor (20 s @ 20 Hz; gate needs ≥ 60).
|
||||
const ANCHOR_FRAMES: usize = 400;
|
||||
/// Baseline / runtime window length (30 s @ 20 Hz; recorder needs ≥ 600).
|
||||
const WINDOW_FRAMES: usize = 600;
|
||||
|
||||
/// What the person in the room is doing (None ⇒ empty room).
|
||||
#[derive(Clone, Copy, Default)]
|
||||
struct Person {
|
||||
/// Common amplitude offset in units of NOISE_STD (presence strength).
|
||||
/// Anything ≥ 1.5 reads as present; values above 2.0 are explicitly
|
||||
/// exercised to guard the ADR-152 z-band-squeeze fix (presence strength
|
||||
/// must not read as motion).
|
||||
presence_z: f32,
|
||||
/// Per-frame common amplitude jitter (body sway / fidgeting), in NOISE_STD.
|
||||
sway_z: f32,
|
||||
/// Respiration rate (Hz); 0 = no modulation.
|
||||
breathing_hz: f32,
|
||||
/// Relative amplitude-modulation depth on every 4th subcarrier.
|
||||
breathing_depth: f32,
|
||||
/// Constant phase shift from the body's multipath (radians).
|
||||
phase_shift: f32,
|
||||
/// Phase-wobble amplitude (radians) at 1.5 Hz — drives the motion flag.
|
||||
phase_wobble: f32,
|
||||
}
|
||||
|
||||
/// Deterministic CSI source for one room. Time advances one frame per call.
|
||||
struct RoomSim {
|
||||
rng: Rng,
|
||||
/// Static per-subcarrier amplitude fingerprint.
|
||||
amp: Vec<f32>,
|
||||
/// Static per-subcarrier phase fingerprint.
|
||||
phase: Vec<f32>,
|
||||
/// Frame counter (continuous room clock).
|
||||
t: u64,
|
||||
}
|
||||
|
||||
impl RoomSim {
|
||||
fn new(seed: u32) -> Self {
|
||||
// Same HT20 fingerprint as the ADR-135 roundtrip test.
|
||||
let amp = (0..N_SC)
|
||||
.map(|k| 0.3 + 0.7 * (k as f32 * PI / N_SC as f32).sin().abs())
|
||||
.collect();
|
||||
let phase = (0..N_SC)
|
||||
.map(|k| (k as f32 * 0.1).rem_euclid(2.0 * PI) - PI)
|
||||
.collect();
|
||||
Self { rng: Rng::new(seed), amp, phase, t: 0 }
|
||||
}
|
||||
|
||||
/// Generate the next CSI frame for the given occupancy.
|
||||
fn frame(&mut self, person: Option<&Person>) -> CsiFrame {
|
||||
let secs = self.t as f32 / FS_HZ;
|
||||
let (offset, wobble) = match person {
|
||||
Some(p) => {
|
||||
let sway = p.sway_z * NOISE_STD * self.rng.next_normal();
|
||||
(
|
||||
p.presence_z * NOISE_STD + sway,
|
||||
p.phase_shift + p.phase_wobble * (2.0 * PI * 1.5 * secs).sin(),
|
||||
)
|
||||
}
|
||||
None => (0.0, 0.0),
|
||||
};
|
||||
|
||||
let mut data = Array2::<Complex64>::zeros((1, N_SC));
|
||||
for k in 0..N_SC {
|
||||
let mut a = self.amp[k] + offset;
|
||||
if let Some(p) = person {
|
||||
if p.breathing_hz > 0.0 && k % 4 == 0 {
|
||||
a *= 1.0 + p.breathing_depth * (2.0 * PI * p.breathing_hz * secs).sin();
|
||||
}
|
||||
}
|
||||
let th = self.phase[k] + wobble;
|
||||
let re = a * th.cos() + NOISE_STD * self.rng.next_normal();
|
||||
let im = a * th.sin() + NOISE_STD * self.rng.next_normal();
|
||||
data[(0, k)] = Complex64::new(re as f64, im as f64);
|
||||
}
|
||||
|
||||
let mut meta =
|
||||
CsiMetadata::new(DeviceId::new("full-loop-test"), FrequencyBand::Band2_4GHz, 6);
|
||||
meta.bandwidth_mhz = 20;
|
||||
meta.antenna_config = AntennaConfig::new(1, 1);
|
||||
self.t += 1;
|
||||
CsiFrame::new(meta, data)
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-frame scalar — mean amplitude across subcarriers/streams, the same
|
||||
/// carrier the CLI's `frame_scalar` feeds into `Features::from_series`.
|
||||
fn frame_scalar(frame: &CsiFrame) -> f32 {
|
||||
frame.mean_amplitude() as f32
|
||||
}
|
||||
|
||||
/// Synthetic occupancy for each guided anchor in the canonical sequence.
|
||||
fn anchor_person(label: AnchorLabel) -> Option<Person> {
|
||||
let p = match label {
|
||||
AnchorLabel::Empty => return None,
|
||||
// Strong reflector at z = 3.0 — every frame exceeds the baseline's
|
||||
// absolute motion threshold (z > 2.0). Pre-ADR-152 this anchor was
|
||||
// unenrollable ("too much motion"); the delta-based gate must accept it.
|
||||
AnchorLabel::StandStill => Person {
|
||||
presence_z: 3.0, sway_z: 0.25, phase_shift: 0.10, ..Default::default()
|
||||
},
|
||||
AnchorLabel::Sit => Person {
|
||||
presence_z: 1.65, sway_z: 0.25, phase_shift: 0.08, ..Default::default()
|
||||
},
|
||||
AnchorLabel::LieDown => Person {
|
||||
presence_z: 1.6, sway_z: 0.25, phase_shift: 0.06, ..Default::default()
|
||||
},
|
||||
AnchorLabel::BreatheSlow => Person {
|
||||
presence_z: 1.7, sway_z: 0.2, breathing_hz: 0.125, breathing_depth: 0.03,
|
||||
phase_shift: 0.08, ..Default::default()
|
||||
},
|
||||
AnchorLabel::BreatheNormal => Person {
|
||||
presence_z: 1.7, sway_z: 0.2, breathing_hz: 0.25, breathing_depth: 0.03,
|
||||
phase_shift: 0.08, ..Default::default()
|
||||
},
|
||||
AnchorLabel::SmallMove => Person {
|
||||
presence_z: 1.7, sway_z: 1.0, phase_shift: 0.10, phase_wobble: 1.0,
|
||||
..Default::default()
|
||||
},
|
||||
AnchorLabel::SleepPosture => Person {
|
||||
presence_z: 1.6, sway_z: 0.2, breathing_hz: 0.2, breathing_depth: 0.03,
|
||||
phase_shift: 0.06, ..Default::default()
|
||||
},
|
||||
};
|
||||
Some(p)
|
||||
}
|
||||
|
||||
/// Capture one anchor exactly as the CLI's `enroll` does: per-frame deviation
|
||||
/// into the `AnchorRecorder`, scalar series for feature extraction, then the
|
||||
/// quality-gate verdict.
|
||||
fn capture_anchor(
|
||||
sim: &mut RoomSim,
|
||||
baseline: &BaselineCalibration,
|
||||
gate: &AnchorQualityGate,
|
||||
label: AnchorLabel,
|
||||
room_id: &str,
|
||||
at_unix_s: i64,
|
||||
) -> (Option<AnchorFeature>, wifi_densepose_calibration::Anchor, Option<String>) {
|
||||
let person = anchor_person(label);
|
||||
let mut recorder = AnchorRecorder::new(label);
|
||||
let mut series = Vec::with_capacity(ANCHOR_FRAMES);
|
||||
for _ in 0..ANCHOR_FRAMES {
|
||||
let frame = sim.frame(person.as_ref());
|
||||
recorder.record_frame(baseline, &frame);
|
||||
series.push(frame_scalar(&frame));
|
||||
}
|
||||
let (anchor, reason) = recorder.finalize(gate, at_unix_s);
|
||||
let feature = anchor
|
||||
.quality
|
||||
.accepted
|
||||
.then(|| AnchorFeature::from_series(room_id, label, &series, FS_HZ));
|
||||
(feature, anchor, reason)
|
||||
}
|
||||
|
||||
/// Generate a live feature window (Stage-5 runtime input).
|
||||
fn live_window(sim: &mut RoomSim, person: Option<&Person>) -> Features {
|
||||
let series: Vec<f32> = (0..WINDOW_FRAMES)
|
||||
.map(|_| frame_scalar(&sim.frame(person)))
|
||||
.collect();
|
||||
Features::from_series(&series, FS_HZ)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The full loop
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn full_loop_baseline_enroll_extract_train_infer() {
|
||||
let room_id = "living-room";
|
||||
let mut sim = RoomSim::new(42);
|
||||
|
||||
// -- Stage 1: clean empty-room baseline capture (ADR-135) ----------------
|
||||
let mut recorder = CalibrationRecorder::new(CalibrationConfig::ht20());
|
||||
let mut flagged_after_warmup = 0u32;
|
||||
for i in 0..WINDOW_FRAMES {
|
||||
let frame = sim.frame(None);
|
||||
let score = recorder.record(&frame).expect("baseline record");
|
||||
// Welford stats need a short warmup before the partial z is meaningful.
|
||||
if i >= 100 && score.motion_flagged {
|
||||
flagged_after_warmup += 1;
|
||||
}
|
||||
}
|
||||
assert_eq!(recorder.frames_recorded(), WINDOW_FRAMES as u32);
|
||||
assert_eq!(
|
||||
flagged_after_warmup, 0,
|
||||
"a static empty room must never be motion-flagged after warmup"
|
||||
);
|
||||
let baseline = recorder.finalize().expect("baseline finalize");
|
||||
assert_eq!(baseline.subcarriers.len(), N_SC);
|
||||
let baseline_id = baseline.calibration_uuid().to_string();
|
||||
|
||||
// A fresh empty frame deviates negligibly from its own baseline.
|
||||
let check = baseline.deviation(&sim.frame(None)).expect("deviation");
|
||||
assert!(!check.motion_flagged, "empty frame flagged: {check:?}");
|
||||
assert!(
|
||||
check.amplitude_z_median < 1.0,
|
||||
"empty frame z {} should be < 1.0",
|
||||
check.amplitude_z_median
|
||||
);
|
||||
|
||||
// -- Stage 2: guided-anchor enrollment with the quality gate -------------
|
||||
let gate = AnchorQualityGate::default();
|
||||
let mut session = EnrollmentSession::new(room_id, &baseline_id, 1_700_000_000);
|
||||
let mut features: Vec<AnchorFeature> = Vec::new();
|
||||
|
||||
for (i, label) in AnchorLabel::SEQUENCE.into_iter().enumerate() {
|
||||
let at = 1_700_000_000 + (i as i64 + 1) * 30;
|
||||
let (feat, anchor, reason) =
|
||||
capture_anchor(&mut sim, &baseline, &gate, label, room_id, at);
|
||||
assert!(
|
||||
anchor.quality.accepted,
|
||||
"anchor {} rejected: {} (presence_z={:.2} motion={:.0}% frames={})",
|
||||
label.as_str(),
|
||||
reason.unwrap_or_default(),
|
||||
anchor.quality.presence_z,
|
||||
anchor.quality.motion_rate * 100.0,
|
||||
anchor.quality.frames,
|
||||
);
|
||||
match label {
|
||||
AnchorLabel::Empty => assert!(
|
||||
anchor.quality.presence_z < 1.0,
|
||||
"empty room must read empty, got z {}",
|
||||
anchor.quality.presence_z
|
||||
),
|
||||
AnchorLabel::SmallMove => assert!(
|
||||
anchor.quality.motion_rate >= 0.3,
|
||||
"small-move motion {} too low",
|
||||
anchor.quality.motion_rate
|
||||
),
|
||||
_ => assert!(
|
||||
anchor.quality.presence_z >= 1.5,
|
||||
"{} presence_z {} below gate",
|
||||
label.as_str(),
|
||||
anchor.quality.presence_z
|
||||
),
|
||||
}
|
||||
features.push(feat.expect("accepted anchor yields a feature"));
|
||||
session.apply(EnrollmentEvent::AnchorAccepted { anchor });
|
||||
}
|
||||
assert!(session.is_complete(), "missing anchors: {:?}", session.missing());
|
||||
assert_eq!(session.progress(), (8, 8));
|
||||
session.apply(EnrollmentEvent::Completed { at: 1_700_000_300 });
|
||||
|
||||
// -- Stage 3: feature extraction sanity ----------------------------------
|
||||
assert_eq!(features.len(), 8);
|
||||
let by_label = |l: AnchorLabel| {
|
||||
features
|
||||
.iter()
|
||||
.find(|f| f.label == l)
|
||||
.unwrap_or_else(|| panic!("no feature for {}", l.as_str()))
|
||||
};
|
||||
let breathe = by_label(AnchorLabel::BreatheNormal);
|
||||
assert!(
|
||||
(breathe.features.breathing_hz - 0.25).abs() < 0.04,
|
||||
"normal breathing extracted at {} Hz, injected 0.25 Hz",
|
||||
breathe.features.breathing_hz
|
||||
);
|
||||
assert!(
|
||||
breathe.features.breathing_score > 0.25,
|
||||
"breathing score {} too weak",
|
||||
breathe.features.breathing_score
|
||||
);
|
||||
let slow = by_label(AnchorLabel::BreatheSlow);
|
||||
assert!(
|
||||
(slow.features.breathing_hz - 0.125).abs() < 0.04,
|
||||
"slow breathing extracted at {} Hz, injected 0.125 Hz",
|
||||
slow.features.breathing_hz
|
||||
);
|
||||
let empty = by_label(AnchorLabel::Empty);
|
||||
assert!(
|
||||
empty.features.variance < breathe.features.variance,
|
||||
"empty variance {} should be below occupied {}",
|
||||
empty.features.variance,
|
||||
breathe.features.variance
|
||||
);
|
||||
|
||||
// -- Stage 4: train the specialist bank + JSON persistence round-trip ----
|
||||
let bank = SpecialistBank::train(room_id, &baseline_id, &features, 1_700_000_400)
|
||||
.expect("bank training");
|
||||
assert_eq!(bank.room_id, room_id);
|
||||
assert_eq!(bank.anchor_count, 8);
|
||||
let kinds = bank.trained_kinds();
|
||||
for kind in [
|
||||
SpecialistKind::Presence,
|
||||
SpecialistKind::Posture,
|
||||
SpecialistKind::Breathing,
|
||||
SpecialistKind::Heartbeat,
|
||||
SpecialistKind::Restlessness,
|
||||
SpecialistKind::Anomaly,
|
||||
] {
|
||||
assert!(kinds.contains(&kind), "bank missing {kind:?} (got {kinds:?})");
|
||||
}
|
||||
|
||||
// Persist and reload (JSON today) — the runtime below uses the *reloaded*
|
||||
// bank, so the round-trip is proven inside the loop, not as a side check.
|
||||
let json = bank.to_json().expect("bank to_json");
|
||||
let reloaded = SpecialistBank::from_json(&json).expect("bank from_json");
|
||||
assert_eq!(reloaded.room_id, bank.room_id);
|
||||
assert_eq!(reloaded.baseline_id, bank.baseline_id);
|
||||
assert_eq!(reloaded.anchor_count, bank.anchor_count);
|
||||
assert_eq!(
|
||||
reloaded.presence.as_ref().map(|p| p.threshold),
|
||||
bank.presence.as_ref().map(|p| p.threshold),
|
||||
"presence threshold must survive persistence"
|
||||
);
|
||||
|
||||
// -- Stage 5: runtime inference through the mixture ----------------------
|
||||
let mix = MixtureOfSpecialists::new(reloaded);
|
||||
|
||||
// Positive case: a person breathing at a KNOWN 0.30 Hz (18 BPM) — a rate
|
||||
// never used during enrollment.
|
||||
let occupied = Person {
|
||||
presence_z: 1.7,
|
||||
sway_z: 0.25,
|
||||
breathing_hz: 0.30,
|
||||
breathing_depth: 0.04,
|
||||
phase_shift: 0.08,
|
||||
..Default::default()
|
||||
};
|
||||
let f = live_window(&mut sim, Some(&occupied));
|
||||
let state = mix.infer(&f, &baseline_id);
|
||||
assert!(!state.stale, "bank trained against this baseline must be fresh");
|
||||
assert!(!state.vetoed, "plausible occupied window must not be vetoed");
|
||||
let presence = state.presence.expect("presence specialist trained");
|
||||
assert_eq!(presence.value, 1.0, "person in the room must be detected");
|
||||
let breathing = state.breathing.expect("breathing must be reported when present");
|
||||
assert!(
|
||||
(breathing.value - 18.0).abs() <= 2.0,
|
||||
"breathing {} BPM, injected 18 BPM",
|
||||
breathing.value
|
||||
);
|
||||
assert!(state.restlessness.is_some(), "restlessness specialist trained");
|
||||
|
||||
// Motionless-person case (ADR-152 "variance-only presence" regression):
|
||||
// a strong reflector standing perfectly still — variance stays at the
|
||||
// empty-room level, only the scalar MEAN shifts. The mean channel of the
|
||||
// presence specialist must still detect them.
|
||||
let motionless = Person {
|
||||
presence_z: 3.0,
|
||||
sway_z: 0.05,
|
||||
phase_shift: 0.10,
|
||||
..Default::default()
|
||||
};
|
||||
let f_still = live_window(&mut sim, Some(&motionless));
|
||||
let state = mix.infer(&f_still, &baseline_id);
|
||||
let presence = state.presence.expect("presence specialist trained");
|
||||
assert_eq!(
|
||||
presence.value, 1.0,
|
||||
"motionless person must be detected via the mean-shift channel \
|
||||
(variance {:.2e} vs empty-level)",
|
||||
f_still.variance
|
||||
);
|
||||
|
||||
// Negative case: a fresh empty-room window must NOT report presence,
|
||||
// breathing, heartbeat, or posture.
|
||||
let f_empty = live_window(&mut sim, None);
|
||||
let state = mix.infer(&f_empty, &baseline_id);
|
||||
let presence = state.presence.expect("presence specialist trained");
|
||||
assert_eq!(presence.value, 0.0, "empty room must read absent");
|
||||
assert!(state.breathing.is_none(), "no breathing in an empty room");
|
||||
assert!(state.heartbeat.is_none(), "no heartbeat in an empty room");
|
||||
assert!(state.posture.is_none(), "no posture in an empty room");
|
||||
|
||||
// Honest degradation: a drifted baseline flags the bank STALE.
|
||||
let state = mix.infer(&f, "some-other-baseline");
|
||||
assert!(state.stale, "baseline drift must mark readings STALE");
|
||||
}
|
||||
@@ -16,14 +16,18 @@ name = "wifi-densepose"
|
||||
path = "src/main.rs"
|
||||
|
||||
[features]
|
||||
# `mat` pulls wifi-densepose-mat → -nn → ort (ONNX) → openssl-sys, which does NOT
|
||||
# cross-compile to aarch64 and is irrelevant to the calibration path. Build the
|
||||
# Pi/appliance calibration binary with `--no-default-features` to exclude it.
|
||||
default = ["mat"]
|
||||
mat = []
|
||||
mat = ["dep:wifi-densepose-mat"]
|
||||
|
||||
[dependencies]
|
||||
# Internal crates
|
||||
wifi-densepose-mat = { version = "0.3.0", path = "../wifi-densepose-mat" }
|
||||
wifi-densepose-mat = { version = "0.3.0", path = "../wifi-densepose-mat", optional = true }
|
||||
wifi-densepose-signal = { version = "0.3.1", path = "../wifi-densepose-signal", default-features = false }
|
||||
wifi-densepose-core = { version = "0.3.0", path = "../wifi-densepose-core" }
|
||||
wifi-densepose-calibration = { version = "0.3.0", path = "../wifi-densepose-calibration" }
|
||||
|
||||
# Linear algebra / complex numbers (used by calibrate.rs to build CsiFrame)
|
||||
ndarray = { workspace = true }
|
||||
@@ -41,6 +45,10 @@ console = "0.16"
|
||||
# Async runtime
|
||||
tokio = { version = "1.35", features = ["full"] }
|
||||
|
||||
# HTTP API server (calibrate-serve subcommand — drives a future UI)
|
||||
axum = { workspace = true }
|
||||
tower-http = { version = "0.6", features = ["cors", "trace"] }
|
||||
|
||||
# Serialization
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
@@ -64,3 +72,4 @@ tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
||||
assert_cmd = "2.0"
|
||||
predicates = "3.0"
|
||||
tempfile = "3.9"
|
||||
tower = { workspace = true }
|
||||
|
||||
@@ -8,22 +8,24 @@
|
||||
//!
|
||||
//! # Wire format parsed here (option b — local parser, no cross-crate dep)
|
||||
//!
|
||||
//! Authoritative layout: firmware `csi_collector.c` (ADR-018 + ADR-110).
|
||||
//!
|
||||
//! Offset Size Field
|
||||
//! ────── ──── ─────────────────────────────────────────────────────────────
|
||||
//! 0 4 Magic: 0xC511_0001 (LE u32)
|
||||
//! 4 1 node_id (u8)
|
||||
//! 5 1 n_antennas (u8)
|
||||
//! 6 1 n_subcarriers (u8)
|
||||
//! 7 1 (reserved)
|
||||
//! 8 2 freq_mhz (LE u16)
|
||||
//! 10 4 sequence (LE u32)
|
||||
//! 14 1 rssi (i8)
|
||||
//! 15 1 noise_floor (i8)
|
||||
//! 16 4 (reserved / padding)
|
||||
//! 6 2 n_subcarriers (LE u16 — 256 for ESP32-C6 HE-SU frames, #1005)
|
||||
//! 8 4 freq_mhz (LE u32)
|
||||
//! 12 4 sequence (LE u32)
|
||||
//! 16 1 rssi (i8)
|
||||
//! 17 1 noise_floor (i8)
|
||||
//! 18 1 PPDU type (ADR-110: 0=HT/legacy, 1=HE-SU, 2=HE-MU, 3=HE-TB)
|
||||
//! 19 1 flags (ADR-110: bit0 bw40, bit4 time-sync valid)
|
||||
//! 20 2 × n_antennas × n_subcarriers IQ pairs: i_val (i8), q_val (i8)
|
||||
//!
|
||||
//! This parser mirrors `parse_esp32_frame` in
|
||||
//! `wifi-densepose-sensing-server/src/csi.rs` exactly (same magic, same layout).
|
||||
//! `wifi-densepose-sensing-server/src/csi.rs` (same magic, same layout).
|
||||
|
||||
use anyhow::{bail, Result};
|
||||
use clap::Args;
|
||||
@@ -232,7 +234,7 @@ fn finalise_and_save(recorder: CalibrationRecorder, output: &str) -> Result<()>
|
||||
// Tier helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn tier_config(tier: &str) -> CalibrationConfig {
|
||||
pub(crate) fn tier_config(tier: &str) -> CalibrationConfig {
|
||||
match tier.to_ascii_lowercase().as_str() {
|
||||
"ht40" => CalibrationConfig::ht40(),
|
||||
"he20" => CalibrationConfig::he20(),
|
||||
@@ -250,7 +252,7 @@ fn tier_config(tier: &str) -> CalibrationConfig {
|
||||
|
||||
/// Parse a single UDP datagram and return a `CsiFrame` ready for
|
||||
/// `CalibrationRecorder::record()`. Returns `None` on any parse failure.
|
||||
fn parse_csi_packet(buf: &[u8], tier: &str) -> Option<CsiFrame> {
|
||||
pub(crate) fn parse_csi_packet(buf: &[u8], tier: &str) -> Option<CsiFrame> {
|
||||
if buf.len() < 20 {
|
||||
return None;
|
||||
}
|
||||
@@ -261,11 +263,15 @@ fn parse_csi_packet(buf: &[u8], tier: &str) -> Option<CsiFrame> {
|
||||
|
||||
let node_id = buf[4];
|
||||
let n_antennas = buf[5] as usize;
|
||||
let n_subcarriers = buf[6] as usize;
|
||||
let freq_mhz = u16::from_le_bytes([buf[8], buf[9]]);
|
||||
let _sequence = u32::from_le_bytes([buf[10], buf[11], buf[12], buf[13]]);
|
||||
let rssi = buf[14] as i8;
|
||||
let noise_floor = buf[15] as i8;
|
||||
// u16 since ADR-110 / #1005: ESP32-C6 HE-SU frames carry 256 bins
|
||||
// (the old single-byte read decoded 256 = 0x0100 LE as 0 subcarriers).
|
||||
let n_subcarriers = u16::from_le_bytes([buf[6], buf[7]]) as usize;
|
||||
let freq_mhz = u32::from_le_bytes([buf[8], buf[9], buf[10], buf[11]]);
|
||||
let freq_mhz = u16::try_from(freq_mhz).unwrap_or(0);
|
||||
let _sequence = u32::from_le_bytes([buf[12], buf[13], buf[14], buf[15]]);
|
||||
let rssi = buf[16] as i8;
|
||||
let noise_floor = buf[17] as i8;
|
||||
let _ppdu_type = buf[18]; // ADR-110; baseline tier gating is by count
|
||||
|
||||
let n_pairs = n_antennas * n_subcarriers;
|
||||
let iq_start = 20usize;
|
||||
@@ -414,24 +420,53 @@ mod tests {
|
||||
assert!(parse_csi_packet(&buf, "ht20").is_none());
|
||||
}
|
||||
|
||||
/// Build an ADR-018 frame (correct firmware layout, ADR-110 bytes 18-19).
|
||||
fn build_frame(n_subcarriers: u16, ppdu: u8) -> Vec<u8> {
|
||||
let mut buf = vec![0u8; 20 + n_subcarriers as usize * 2];
|
||||
buf[0..4].copy_from_slice(&0xC511_0001u32.to_le_bytes());
|
||||
buf[4] = 12; // node_id
|
||||
buf[5] = 1; // n_antennas
|
||||
buf[6..8].copy_from_slice(&n_subcarriers.to_le_bytes());
|
||||
buf[8..12].copy_from_slice(&2432u32.to_le_bytes()); // freq_mhz
|
||||
buf[12..16].copy_from_slice(&11610u32.to_le_bytes()); // sequence
|
||||
buf[16] = (-40i8) as u8; // rssi
|
||||
buf[17] = (-87i8) as u8; // noise floor
|
||||
buf[18] = ppdu;
|
||||
buf[19] = 0x10; // time-sync valid
|
||||
for k in 0..n_subcarriers as usize {
|
||||
buf[20 + k * 2] = (10 + (k % 100) as i8) as u8;
|
||||
buf[20 + k * 2 + 1] = (k % 50) as u8;
|
||||
}
|
||||
buf
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_csi_packet_valid() {
|
||||
let mut buf = vec![0u8; 24]; // 20-byte header + 2 IQ pairs (1 antenna, 2 subcarriers)
|
||||
// Magic 0xC511_0001 LE
|
||||
buf[0] = 0x01; buf[1] = 0x00; buf[2] = 0x11; buf[3] = 0xC5;
|
||||
buf[5] = 1; // n_antennas
|
||||
buf[6] = 2; // n_subcarriers
|
||||
// freq_mhz = 2437 (channel 6)
|
||||
buf[8] = 0x85; buf[9] = 0x09;
|
||||
// IQ pairs at offset 20: (10, 20), (−5, 15)
|
||||
buf[20] = 10i8 as u8; buf[21] = 20i8 as u8;
|
||||
buf[22] = (-5i8) as u8; buf[23] = 15i8 as u8;
|
||||
|
||||
let buf = build_frame(2, 0);
|
||||
let frame = parse_csi_packet(&buf, "ht20");
|
||||
assert!(frame.is_some());
|
||||
let f = frame.unwrap();
|
||||
assert_eq!(f.num_spatial_streams(), 1);
|
||||
assert_eq!(f.num_subcarriers(), 2);
|
||||
assert_eq!(f.metadata.rssi_dbm, -40);
|
||||
assert_eq!(f.metadata.noise_floor_dbm, -87);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_csi_packet_he_su_256_bins() {
|
||||
// ESP32-C6 HE-SU frame (issue #1005): n_subcarriers = 256 = 0x0100 LE.
|
||||
// The pre-#1005 single-byte read decoded this as 0 subcarriers.
|
||||
let buf = build_frame(256, 1);
|
||||
assert_eq!(buf.len(), 532); // matches the live wire size
|
||||
let f = parse_csi_packet(&buf, "he20").expect("256-bin HE frame must parse");
|
||||
assert_eq!(f.num_subcarriers(), 256);
|
||||
assert_eq!(f.metadata.rssi_dbm, -40);
|
||||
// A 256-bin frame is accepted by the he20 recorder (num_subcarriers
|
||||
// tier total) and rejected by ht20 (52/64) — no HT/HE mixing.
|
||||
let mut he = wifi_densepose_signal::CalibrationRecorder::new(tier_config("he20"));
|
||||
assert!(he.record(&f).is_ok());
|
||||
let mut ht = wifi_densepose_signal::CalibrationRecorder::new(tier_config("ht20"));
|
||||
assert!(ht.record(&f).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -27,6 +27,9 @@
|
||||
use clap::{Parser, Subcommand};
|
||||
|
||||
pub mod calibrate;
|
||||
pub mod calibrate_api;
|
||||
pub mod room;
|
||||
#[cfg(feature = "mat")]
|
||||
pub mod mat;
|
||||
|
||||
/// WiFi-DensePose Command Line Interface
|
||||
@@ -52,7 +55,26 @@ pub enum Commands {
|
||||
/// baseline used for real-time motion z-scoring and CIR reference.
|
||||
Calibrate(calibrate::CalibrateArgs),
|
||||
|
||||
/// Run the calibration HTTP API (ADR-135/151) for a UI to drive.
|
||||
/// Receives ESP32 CSI over UDP and exposes start/status/stop/result
|
||||
/// endpoints at `/api/v1/calibration/*` (CORS-enabled).
|
||||
CalibrateServe(calibrate_api::CalibrateServeArgs),
|
||||
|
||||
/// Guided per-room enrollment (ADR-151 Stage 2) — walk the anchor sequence
|
||||
/// against a baseline, writing labelled features.
|
||||
Enroll(room::EnrollArgs),
|
||||
|
||||
/// Train the per-room specialist bank from an enrollment (ADR-151 Stage 4).
|
||||
TrainRoom(room::TrainRoomArgs),
|
||||
|
||||
/// Show a trained specialist bank's summary.
|
||||
RoomStatus(room::RoomStatusArgs),
|
||||
|
||||
/// Live mixture-of-specialists readout from the CSI stream (ADR-151 Stage 5).
|
||||
RoomWatch(room::RoomWatchArgs),
|
||||
|
||||
/// Mass Casualty Assessment Tool commands
|
||||
#[cfg(feature = "mat")]
|
||||
#[command(subcommand)]
|
||||
Mat(mat::MatCommand),
|
||||
|
||||
|
||||
@@ -21,11 +21,28 @@ async fn main() -> anyhow::Result<()> {
|
||||
Commands::Calibrate(args) => {
|
||||
wifi_densepose_cli::calibrate::execute(args).await?;
|
||||
}
|
||||
Commands::CalibrateServe(args) => {
|
||||
wifi_densepose_cli::calibrate_api::execute(args).await?;
|
||||
}
|
||||
Commands::Enroll(args) => {
|
||||
wifi_densepose_cli::room::enroll(args).await?;
|
||||
}
|
||||
Commands::TrainRoom(args) => {
|
||||
wifi_densepose_cli::room::train_room(args).await?;
|
||||
}
|
||||
Commands::RoomStatus(args) => {
|
||||
wifi_densepose_cli::room::room_status(args).await?;
|
||||
}
|
||||
Commands::RoomWatch(args) => {
|
||||
wifi_densepose_cli::room::room_watch(args).await?;
|
||||
}
|
||||
#[cfg(feature = "mat")]
|
||||
Commands::Mat(mat_cmd) => {
|
||||
wifi_densepose_cli::mat::execute(mat_cmd).await?;
|
||||
}
|
||||
Commands::Version => {
|
||||
println!("wifi-densepose {}", env!("CARGO_PKG_VERSION"));
|
||||
#[cfg(feature = "mat")]
|
||||
println!("MAT module version: {}", wifi_densepose_mat::VERSION);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,458 @@
|
||||
//! `enroll` / `train-room` / `room-status` / `room-watch` — ADR-151 Stages 2–5 CLI.
|
||||
//!
|
||||
//! Drives the `wifi-densepose-calibration` pipeline against a live ESP32 CSI
|
||||
//! stream (requires `edge_tier=0` raw CSI). `enroll` walks the guided anchors and
|
||||
//! writes labelled features; `train-room` fits the specialist bank; `room-watch`
|
||||
//! runs the mixture runtime and prints live room state.
|
||||
|
||||
use anyhow::{bail, Result};
|
||||
use clap::Args;
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
use tokio::net::UdpSocket;
|
||||
use wifi_densepose_calibration::{
|
||||
Anchor, AnchorLabel, AnchorQualityGate, AnchorRecorder, EnrollmentEvent, EnrollmentSession,
|
||||
MixtureOfSpecialists, MultiNodeMixture, SpecialistBank,
|
||||
};
|
||||
use wifi_densepose_calibration::extract::{AnchorFeature, Features};
|
||||
use wifi_densepose_core::types::CsiFrame;
|
||||
use wifi_densepose_signal::BaselineCalibration;
|
||||
|
||||
use crate::calibrate::parse_csi_packet;
|
||||
|
||||
const RECV_BUF: usize = 2048;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn now_unix() -> i64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Per-frame scalar: mean amplitude across all subcarriers/streams.
|
||||
///
|
||||
/// Carries presence/motion energy plus the breathing amplitude modulation.
|
||||
/// (Validated live on the ESP32 — picks up breathing where a max-variance
|
||||
/// subcarrier instead locks onto motion artifacts. A phase-based carrier on a
|
||||
/// *stable* subcarrier is the proper higher-SNR refinement — ADR-151 §4.)
|
||||
fn frame_scalar(frame: &CsiFrame) -> f32 {
|
||||
let a = &frame.amplitude;
|
||||
if a.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
(a.sum() / a.len() as f64) as f32
|
||||
}
|
||||
|
||||
fn load_baseline(path: &str) -> Result<BaselineCalibration> {
|
||||
let bytes = std::fs::read(path)
|
||||
.map_err(|e| anyhow::anyhow!("cannot read baseline {path}: {e} — run `calibrate` first"))?;
|
||||
BaselineCalibration::from_bytes(&bytes)
|
||||
.map_err(|e| anyhow::anyhow!("invalid baseline {path}: {e}"))
|
||||
}
|
||||
|
||||
/// Persisted enrollment output (labelled features + audit log).
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
struct EnrollmentData {
|
||||
room_id: String,
|
||||
baseline_id: String,
|
||||
fs_hz: f32,
|
||||
anchors: Vec<AnchorFeature>,
|
||||
session: EnrollmentSession,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// enroll
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Arguments for `enroll`.
|
||||
#[derive(Args, Debug, Clone)]
|
||||
pub struct EnrollArgs {
|
||||
/// UDP port for ESP32 CSI frames (raw CSI; provision with `--edge-tier 0`).
|
||||
#[arg(long, default_value_t = 5005)]
|
||||
pub udp_port: u16,
|
||||
/// Bind address for the UDP socket.
|
||||
#[arg(long, default_value = "0.0.0.0")]
|
||||
pub bind: String,
|
||||
/// Path to the empty-room baseline produced by `calibrate`.
|
||||
#[arg(long, default_value = "./baseline.bin")]
|
||||
pub baseline: String,
|
||||
/// PHY tier (ht20 / ht40 / he20 / he40).
|
||||
#[arg(long, default_value = "ht20")]
|
||||
pub tier: String,
|
||||
/// Room label.
|
||||
#[arg(long, default_value = "default")]
|
||||
pub room_id: String,
|
||||
/// Output enrollment file.
|
||||
#[arg(long, default_value = "./enrollment.json")]
|
||||
pub output: String,
|
||||
/// CSI sample rate (Hz) used for periodicity extraction.
|
||||
#[arg(long, default_value_t = 15.0)]
|
||||
pub fs_hz: f32,
|
||||
/// Max attempts per anchor before moving on.
|
||||
#[arg(long, default_value_t = 2)]
|
||||
pub attempts: u32,
|
||||
}
|
||||
|
||||
/// Capture one anchor: returns (accepted feature?, anchor verdict, reason).
|
||||
async fn capture_anchor(
|
||||
socket: &UdpSocket,
|
||||
baseline: &BaselineCalibration,
|
||||
gate: &AnchorQualityGate,
|
||||
label: AnchorLabel,
|
||||
tier: &str,
|
||||
fs_hz: f32,
|
||||
room_id: &str,
|
||||
) -> Result<(Option<AnchorFeature>, Anchor, Option<String>)> {
|
||||
eprintln!("\n[enroll] {} — {}", label.as_str(), label.prompt());
|
||||
for c in (1..=3).rev() {
|
||||
eprintln!("[enroll] starting in {c}…");
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
}
|
||||
eprintln!("[enroll] capturing {} s…", label.duration_s());
|
||||
|
||||
let mut recorder = AnchorRecorder::new(label);
|
||||
let mut series: Vec<f32> = Vec::new();
|
||||
let mut buf = vec![0u8; RECV_BUF];
|
||||
let deadline = Instant::now() + Duration::from_secs(label.duration_s() as u64);
|
||||
|
||||
while Instant::now() < deadline {
|
||||
let timeout = Duration::from_millis(500);
|
||||
if let Ok(Ok(n)) = tokio::time::timeout(timeout, socket.recv(&mut buf)).await {
|
||||
if let Some(frame) = parse_csi_packet(&buf[..n], tier) {
|
||||
recorder.record_frame(baseline, &frame);
|
||||
series.push(frame_scalar(&frame));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let (anchor, reason) = recorder.finalize(gate, now_unix());
|
||||
let feature = if anchor.quality.accepted {
|
||||
Some(AnchorFeature::from_series(room_id, label, &series, fs_hz))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok((feature, anchor, reason))
|
||||
}
|
||||
|
||||
/// Execute `enroll`.
|
||||
pub async fn enroll(args: EnrollArgs) -> Result<()> {
|
||||
let baseline = load_baseline(&args.baseline)?;
|
||||
let baseline_id = baseline.calibration_uuid().to_string();
|
||||
let gate = AnchorQualityGate::default();
|
||||
|
||||
let addr = format!("{}:{}", args.bind, args.udp_port);
|
||||
let socket = UdpSocket::bind(&addr)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("cannot bind {addr}: {e}"))?;
|
||||
eprintln!("[enroll] room='{}' baseline={} on udp://{addr}", args.room_id, &baseline_id[..8]);
|
||||
eprintln!("[enroll] follow each prompt; bad captures are re-prompted.");
|
||||
|
||||
let mut session = EnrollmentSession::new(&args.room_id, &baseline_id, now_unix());
|
||||
let mut features: Vec<AnchorFeature> = Vec::new();
|
||||
|
||||
for label in AnchorLabel::SEQUENCE {
|
||||
let mut accepted = false;
|
||||
for attempt in 1..=args.attempts {
|
||||
let (feat, anchor, reason) =
|
||||
capture_anchor(&socket, &baseline, &gate, label, &args.tier, args.fs_hz, &args.room_id)
|
||||
.await?;
|
||||
if anchor.quality.accepted {
|
||||
eprintln!(
|
||||
"[enroll] ✓ accepted (presence_z={:.2} motion={:.0}% frames={})",
|
||||
anchor.quality.presence_z,
|
||||
anchor.quality.motion_rate * 100.0,
|
||||
anchor.quality.frames
|
||||
);
|
||||
if let Some(f) = feat {
|
||||
features.push(f);
|
||||
}
|
||||
session.apply(EnrollmentEvent::AnchorAccepted { anchor });
|
||||
accepted = true;
|
||||
break;
|
||||
} else {
|
||||
let why = reason.unwrap_or_default();
|
||||
eprintln!("[enroll] ✗ rejected: {why}");
|
||||
session.apply(EnrollmentEvent::AnchorRejected {
|
||||
label,
|
||||
reason: why,
|
||||
at: now_unix(),
|
||||
});
|
||||
if attempt < args.attempts {
|
||||
eprintln!("[enroll] retrying ({}/{})…", attempt + 1, args.attempts);
|
||||
}
|
||||
}
|
||||
}
|
||||
if !accepted {
|
||||
eprintln!("[enroll] moving on without '{}'", label.as_str());
|
||||
}
|
||||
}
|
||||
|
||||
if session.is_complete() {
|
||||
session.apply(EnrollmentEvent::Completed { at: now_unix() });
|
||||
}
|
||||
let (got, total) = session.progress();
|
||||
let data = EnrollmentData {
|
||||
room_id: args.room_id.clone(),
|
||||
baseline_id,
|
||||
fs_hz: args.fs_hz,
|
||||
anchors: features,
|
||||
session,
|
||||
};
|
||||
std::fs::write(
|
||||
&args.output,
|
||||
serde_json::to_string_pretty(&data).map_err(|e| anyhow::anyhow!("serialize: {e}"))?,
|
||||
)
|
||||
.map_err(|e| anyhow::anyhow!("cannot write {}: {e}", args.output))?;
|
||||
eprintln!(
|
||||
"\n[enroll] done: {got}/{total} anchors accepted → {} (next: `train-room`)",
|
||||
args.output
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// train-room
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Arguments for `train-room`.
|
||||
#[derive(Args, Debug, Clone)]
|
||||
pub struct TrainRoomArgs {
|
||||
/// Enrollment file from `enroll`.
|
||||
#[arg(long, default_value = "./enrollment.json")]
|
||||
pub enrollment: String,
|
||||
/// Output specialist-bank file.
|
||||
#[arg(long, default_value = "./room-bank.json")]
|
||||
pub output: String,
|
||||
}
|
||||
|
||||
/// Execute `train-room`.
|
||||
pub async fn train_room(args: TrainRoomArgs) -> Result<()> {
|
||||
let raw = std::fs::read_to_string(&args.enrollment)
|
||||
.map_err(|e| anyhow::anyhow!("cannot read {}: {e} — run `enroll` first", args.enrollment))?;
|
||||
let data: EnrollmentData =
|
||||
serde_json::from_str(&raw).map_err(|e| anyhow::anyhow!("invalid enrollment: {e}"))?;
|
||||
if data.anchors.is_empty() {
|
||||
bail!("no accepted anchors in {} — re-run enroll", args.enrollment);
|
||||
}
|
||||
|
||||
let bank = SpecialistBank::train(&data.room_id, &data.baseline_id, &data.anchors, now_unix())
|
||||
.map_err(|e| anyhow::anyhow!("training failed: {e}"))?;
|
||||
std::fs::write(&args.output, bank.to_json().map_err(|e| anyhow::anyhow!("{e}"))?)
|
||||
.map_err(|e| anyhow::anyhow!("cannot write {}: {e}", args.output))?;
|
||||
|
||||
eprintln!(
|
||||
"[train-room] room='{}' trained {} specialists from {} anchors → {}",
|
||||
bank.room_id,
|
||||
bank.trained_kinds().len(),
|
||||
bank.anchor_count,
|
||||
args.output
|
||||
);
|
||||
for k in bank.trained_kinds() {
|
||||
eprintln!("[train-room] • {k:?}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// room-status
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Arguments for `room-status`.
|
||||
#[derive(Args, Debug, Clone)]
|
||||
pub struct RoomStatusArgs {
|
||||
/// Specialist-bank file.
|
||||
#[arg(long, default_value = "./room-bank.json")]
|
||||
pub bank: String,
|
||||
}
|
||||
|
||||
/// Execute `room-status`.
|
||||
pub async fn room_status(args: RoomStatusArgs) -> Result<()> {
|
||||
let raw = std::fs::read_to_string(&args.bank)
|
||||
.map_err(|e| anyhow::anyhow!("cannot read {}: {e}", args.bank))?;
|
||||
let bank = SpecialistBank::from_json(&raw).map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
println!("room: {}", bank.room_id);
|
||||
println!("baseline: {}", bank.baseline_id);
|
||||
println!("trained_at: {}", bank.trained_at_unix_s);
|
||||
println!("anchors: {}", bank.anchor_count);
|
||||
println!("specialists: {:?}", bank.trained_kinds());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// room-watch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Arguments for `room-watch`.
|
||||
#[derive(Args, Debug, Clone)]
|
||||
pub struct RoomWatchArgs {
|
||||
/// Specialist-bank file (single-node mode).
|
||||
#[arg(long, default_value = "./room-bank.json")]
|
||||
pub bank: String,
|
||||
/// Multistatic mode: map a node id to its bank as `N:path` (repeatable).
|
||||
/// When supplied, frames are grouped by node id and fused (ADR-029/151).
|
||||
#[arg(long = "node-bank", value_name = "N:PATH")]
|
||||
pub node_bank: Vec<String>,
|
||||
/// UDP port for ESP32 CSI frames (raw CSI).
|
||||
#[arg(long, default_value_t = 5005)]
|
||||
pub udp_port: u16,
|
||||
/// Bind address.
|
||||
#[arg(long, default_value = "0.0.0.0")]
|
||||
pub bind: String,
|
||||
/// PHY tier.
|
||||
#[arg(long, default_value = "ht20")]
|
||||
pub tier: String,
|
||||
/// CSI sample rate (Hz).
|
||||
#[arg(long, default_value_t = 15.0)]
|
||||
pub fs_hz: f32,
|
||||
/// Rolling window length (frames) for each inference.
|
||||
#[arg(long, default_value_t = 200)]
|
||||
pub window: usize,
|
||||
/// Seconds to run (0 = until Ctrl-C).
|
||||
#[arg(long, default_value_t = 0)]
|
||||
pub seconds: u32,
|
||||
}
|
||||
|
||||
/// Execute `room-watch` — live (multistatic) mixture-of-specialists readout.
|
||||
pub async fn room_watch(args: RoomWatchArgs) -> Result<()> {
|
||||
if !args.node_bank.is_empty() {
|
||||
return room_watch_multi(args).await;
|
||||
}
|
||||
let raw = std::fs::read_to_string(&args.bank)
|
||||
.map_err(|e| anyhow::anyhow!("cannot read {}: {e}", args.bank))?;
|
||||
let bank = SpecialistBank::from_json(&raw).map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
let baseline_id = bank.baseline_id.clone();
|
||||
let mix = MixtureOfSpecialists::new(bank);
|
||||
|
||||
let addr = format!("{}:{}", args.bind, args.udp_port);
|
||||
let socket = UdpSocket::bind(&addr)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("cannot bind {addr}: {e}"))?;
|
||||
eprintln!("[room-watch] inferring on udp://{addr} (window={} frames)", args.window);
|
||||
|
||||
let mut buf = vec![0u8; RECV_BUF];
|
||||
let mut win: std::collections::VecDeque<f32> = std::collections::VecDeque::new();
|
||||
let start = Instant::now();
|
||||
let mut last_print = Instant::now();
|
||||
|
||||
loop {
|
||||
if args.seconds > 0 && start.elapsed() >= Duration::from_secs(args.seconds as u64) {
|
||||
break;
|
||||
}
|
||||
if let Ok(Ok(n)) = tokio::time::timeout(Duration::from_millis(500), socket.recv(&mut buf)).await {
|
||||
if let Some(frame) = parse_csi_packet(&buf[..n], &args.tier) {
|
||||
win.push_back(frame_scalar(&frame));
|
||||
while win.len() > args.window {
|
||||
win.pop_front();
|
||||
}
|
||||
}
|
||||
}
|
||||
if last_print.elapsed() >= Duration::from_secs(1) && win.len() >= 32 {
|
||||
let series: Vec<f32> = win.iter().copied().collect();
|
||||
let f = Features::from_series(&series, args.fs_hz);
|
||||
let s = mix.infer(&f, &baseline_id);
|
||||
let pres = s.presence.as_ref().map(|r| r.label.clone().unwrap_or_default()).unwrap_or("-".into());
|
||||
let post = s.posture.as_ref().and_then(|r| r.label.clone()).unwrap_or("-".into());
|
||||
let br = s.breathing.as_ref().map(|r| format!("{:.1}bpm", r.value)).unwrap_or("-".into());
|
||||
let hr = s.heartbeat.as_ref().map(|r| format!("{:.0}bpm", r.value)).unwrap_or("-".into());
|
||||
let rest = s.restlessness.as_ref().map(|r| format!("{:.2}", r.value)).unwrap_or("-".into());
|
||||
let flags = format!(
|
||||
"{}{}",
|
||||
if s.vetoed { " VETO" } else { "" },
|
||||
if s.stale { " STALE" } else { "" }
|
||||
);
|
||||
println!(
|
||||
"presence={pres:<7} posture={post:<8} breathing={br:<8} heart={hr:<7} restless={rest}{flags}"
|
||||
);
|
||||
last_print = Instant::now();
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Multistatic `room-watch`: fuse several co-located nodes (ADR-029/151).
|
||||
async fn room_watch_multi(args: RoomWatchArgs) -> Result<()> {
|
||||
use std::collections::{BTreeMap, VecDeque};
|
||||
|
||||
let mut mix = MultiNodeMixture::new();
|
||||
let mut node_ids: Vec<u8> = Vec::new();
|
||||
for spec in &args.node_bank {
|
||||
let (id_s, path) = spec
|
||||
.split_once(':')
|
||||
.ok_or_else(|| anyhow::anyhow!("--node-bank must be N:path (got {spec:?})"))?;
|
||||
let id: u8 = id_s
|
||||
.parse()
|
||||
.map_err(|_| anyhow::anyhow!("bad node id in {spec:?}"))?;
|
||||
let raw = std::fs::read_to_string(path)
|
||||
.map_err(|e| anyhow::anyhow!("cannot read {path}: {e}"))?;
|
||||
let bank = SpecialistBank::from_json(&raw).map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
let baseline = bank.baseline_id.clone();
|
||||
mix.add_node(id, bank, baseline);
|
||||
node_ids.push(id);
|
||||
}
|
||||
eprintln!("[room-watch] multistatic over nodes {node_ids:?}");
|
||||
|
||||
let addr = format!("{}:{}", args.bind, args.udp_port);
|
||||
let socket = UdpSocket::bind(&addr)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("cannot bind {addr}: {e}"))?;
|
||||
eprintln!("[room-watch] fusing on udp://{addr} (window={} frames)", args.window);
|
||||
|
||||
let mut buf = vec![0u8; RECV_BUF];
|
||||
let mut wins: BTreeMap<u8, VecDeque<f32>> = BTreeMap::new();
|
||||
let start = Instant::now();
|
||||
let mut last_print = Instant::now();
|
||||
|
||||
loop {
|
||||
if args.seconds > 0 && start.elapsed() >= Duration::from_secs(args.seconds as u64) {
|
||||
break;
|
||||
}
|
||||
if let Ok(Ok(n)) =
|
||||
tokio::time::timeout(Duration::from_millis(500), socket.recv(&mut buf)).await
|
||||
{
|
||||
if n < 5 {
|
||||
continue;
|
||||
}
|
||||
let node_id = buf[4];
|
||||
if !node_ids.contains(&node_id) {
|
||||
continue;
|
||||
}
|
||||
if let Some(frame) = parse_csi_packet(&buf[..n], &args.tier) {
|
||||
let w = wins.entry(node_id).or_default();
|
||||
w.push_back(frame_scalar(&frame));
|
||||
while w.len() > args.window {
|
||||
w.pop_front();
|
||||
}
|
||||
}
|
||||
}
|
||||
if last_print.elapsed() >= Duration::from_secs(1) {
|
||||
let per_node: BTreeMap<u8, Features> = wins
|
||||
.iter()
|
||||
.filter(|(_, w)| w.len() >= 32)
|
||||
.map(|(id, w)| {
|
||||
let series: Vec<f32> = w.iter().copied().collect();
|
||||
(*id, Features::from_series(&series, args.fs_hz))
|
||||
})
|
||||
.collect();
|
||||
if !per_node.is_empty() {
|
||||
let active: Vec<u8> = per_node.keys().copied().collect();
|
||||
let s = mix.infer(&per_node);
|
||||
let pres = s.presence.as_ref().and_then(|r| r.label.clone()).unwrap_or("-".into());
|
||||
let post = s.posture.as_ref().and_then(|r| r.label.clone()).unwrap_or("-".into());
|
||||
let br = s.breathing.as_ref().map(|r| format!("{:.1}bpm", r.value)).unwrap_or("-".into());
|
||||
let flags = format!(
|
||||
"{}{}",
|
||||
if s.vetoed { " VETO" } else { "" },
|
||||
if s.stale { " STALE" } else { "" }
|
||||
);
|
||||
println!(
|
||||
"nodes={active:?} presence={pres:<7} posture={post:<8} breathing={br:<8}{flags}"
|
||||
);
|
||||
}
|
||||
last_print = Instant::now();
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -563,6 +563,12 @@ impl crate::traits::CanonicalFrame for CsiFrame {
|
||||
/// (each fixed-width LE; `device_id` length-prefixed; `calibration_id` as
|
||||
/// 16 UUID bytes or 16 zero bytes for `None`) ‖ `(nrows, ncols)` as u32 LE
|
||||
/// ‖ complex payload as `ComplexSample::to_le_bytes()` in stream-major order.
|
||||
///
|
||||
/// # Panics
|
||||
/// If `calibration_id` is `Some(Uuid::nil())`: the nil UUID is the wire
|
||||
/// sentinel for `None`, so encoding it would alias two distinct frames to
|
||||
/// the same bytes (and the same witness hash) — a non-injective encoding
|
||||
/// is refused rather than silently produced.
|
||||
fn to_canonical_bytes(&self) -> Vec<u8> {
|
||||
let m = &self.metadata;
|
||||
// 16 (id) + ~48 (meta) + 8 (shape) + 16 * n_samples
|
||||
@@ -600,7 +606,17 @@ impl crate::traits::CanonicalFrame for CsiFrame {
|
||||
b.extend_from_slice(&m.noise_floor_dbm.to_le_bytes());
|
||||
b.extend_from_slice(&m.sequence_number.to_le_bytes());
|
||||
match m.calibration_id {
|
||||
Some(id) => b.extend_from_slice(id.as_bytes()),
|
||||
Some(id) => {
|
||||
// Some(nil) would alias the None sentinel on the wire: the
|
||||
// bytes would decode to a *different* frame (calibration_id
|
||||
// None) with the same witness. Refuse the non-injective
|
||||
// encoding (see the trait-impl `# Panics` doc).
|
||||
assert!(
|
||||
id != Uuid::nil(),
|
||||
"calibration_id Some(Uuid::nil()) is unencodable: nil is the None sentinel"
|
||||
);
|
||||
b.extend_from_slice(id.as_bytes());
|
||||
}
|
||||
None => b.extend_from_slice(&[0u8; 16]),
|
||||
}
|
||||
b.extend_from_slice(&m.model_id.to_le_bytes());
|
||||
@@ -616,6 +632,205 @@ impl crate::traits::CanonicalFrame for CsiFrame {
|
||||
}
|
||||
}
|
||||
|
||||
/// Errors decoding a frame from its canonical bytes.
|
||||
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
|
||||
pub enum CanonicalDecodeError {
|
||||
/// The buffer ended before the layout was fully read.
|
||||
#[error("canonical buffer truncated at byte {at} (need {need} more)")]
|
||||
Truncated {
|
||||
/// Byte offset where reading failed.
|
||||
at: usize,
|
||||
/// How many more bytes were needed.
|
||||
need: usize,
|
||||
},
|
||||
/// A discriminant byte held an unknown value.
|
||||
#[error("invalid {field} discriminant {value}")]
|
||||
BadDiscriminant {
|
||||
/// Which field failed.
|
||||
field: &'static str,
|
||||
/// The offending byte.
|
||||
value: u8,
|
||||
},
|
||||
/// The device-id bytes were not UTF-8.
|
||||
#[error("device id is not valid UTF-8")]
|
||||
BadDeviceId,
|
||||
/// Shape (nrows × ncols) disagrees with the remaining payload length.
|
||||
#[error("payload length mismatch: shape {rows}x{cols} needs {expect} bytes, found {found}")]
|
||||
PayloadMismatch {
|
||||
/// Declared rows.
|
||||
rows: usize,
|
||||
/// Declared cols.
|
||||
cols: usize,
|
||||
/// Bytes the shape implies.
|
||||
expect: usize,
|
||||
/// Bytes actually present.
|
||||
found: usize,
|
||||
},
|
||||
/// Trailing bytes after the declared payload.
|
||||
#[error("{0} trailing bytes after payload")]
|
||||
TrailingBytes(usize),
|
||||
/// A reserved region that must be all-zero held nonzero bytes. Accepting
|
||||
/// them would let two distinct byte strings decode to the same frame
|
||||
/// (re-encoding could not reproduce the original — forged bytes would be
|
||||
/// indistinguishable after a replay round-trip).
|
||||
#[error("reserved bytes for {field} must be zero")]
|
||||
ReservedNotZero {
|
||||
/// Which field's reserved region was nonzero.
|
||||
field: &'static str,
|
||||
},
|
||||
}
|
||||
|
||||
/// Byte cursor for the canonical layout.
|
||||
struct Cursor<'a> {
|
||||
b: &'a [u8],
|
||||
at: usize,
|
||||
}
|
||||
|
||||
impl<'a> Cursor<'a> {
|
||||
fn take(&mut self, n: usize) -> Result<&'a [u8], CanonicalDecodeError> {
|
||||
if self.b.len() - self.at < n {
|
||||
return Err(CanonicalDecodeError::Truncated {
|
||||
at: self.at,
|
||||
need: n - (self.b.len() - self.at),
|
||||
});
|
||||
}
|
||||
let s = &self.b[self.at..self.at + n];
|
||||
self.at += n;
|
||||
Ok(s)
|
||||
}
|
||||
fn u8(&mut self) -> Result<u8, CanonicalDecodeError> {
|
||||
Ok(self.take(1)?[0])
|
||||
}
|
||||
fn u16(&mut self) -> Result<u16, CanonicalDecodeError> {
|
||||
Ok(u16::from_le_bytes(self.take(2)?.try_into().unwrap()))
|
||||
}
|
||||
fn u32(&mut self) -> Result<u32, CanonicalDecodeError> {
|
||||
Ok(u32::from_le_bytes(self.take(4)?.try_into().unwrap()))
|
||||
}
|
||||
fn i64(&mut self) -> Result<i64, CanonicalDecodeError> {
|
||||
Ok(i64::from_le_bytes(self.take(8)?.try_into().unwrap()))
|
||||
}
|
||||
fn f32(&mut self) -> Result<f32, CanonicalDecodeError> {
|
||||
Ok(f32::from_le_bytes(self.take(4)?.try_into().unwrap()))
|
||||
}
|
||||
fn i8(&mut self) -> Result<i8, CanonicalDecodeError> {
|
||||
Ok(self.take(1)?[0] as i8)
|
||||
}
|
||||
fn uuid(&mut self) -> Result<Uuid, CanonicalDecodeError> {
|
||||
Ok(Uuid::from_bytes(self.take(16)?.try_into().unwrap()))
|
||||
}
|
||||
}
|
||||
|
||||
impl CsiFrame {
|
||||
/// Reconstruct a frame from its [`to_canonical_bytes`] encoding — the
|
||||
/// replay half of the ADR-136 contract. Round-trip law (tested):
|
||||
/// `from_canonical_bytes(f.to_canonical_bytes())` yields a frame with the
|
||||
/// **same id, metadata, payload, and witness hash** as `f`.
|
||||
///
|
||||
/// Amplitude/phase are recomputed from the complex payload (they are
|
||||
/// projections, not independent state).
|
||||
///
|
||||
/// [`to_canonical_bytes`]: crate::traits::CanonicalFrame::to_canonical_bytes
|
||||
///
|
||||
/// # Errors
|
||||
/// [`CanonicalDecodeError`] on truncation, bad discriminants, non-UTF-8
|
||||
/// device id, nonzero reserved bytes, shape/payload disagreement, or
|
||||
/// trailing bytes — every malformed input fails closed. Strictness
|
||||
/// guarantees injectivity on the accepted domain: any accepted byte
|
||||
/// string re-encodes to exactly itself.
|
||||
pub fn from_canonical_bytes(bytes: &[u8]) -> Result<Self, CanonicalDecodeError> {
|
||||
let mut c = Cursor { b: bytes, at: 0 };
|
||||
|
||||
let id = FrameId::from_uuid(c.uuid()?);
|
||||
|
||||
let seconds = c.i64()?;
|
||||
let nanos = c.u32()?;
|
||||
let dev_len = c.u32()? as usize;
|
||||
let device_id = core::str::from_utf8(c.take(dev_len)?)
|
||||
.map_err(|_| CanonicalDecodeError::BadDeviceId)?
|
||||
.to_string();
|
||||
let frequency_band = match c.u8()? {
|
||||
0 => FrequencyBand::Band2_4GHz,
|
||||
1 => FrequencyBand::Band5GHz,
|
||||
2 => FrequencyBand::Band6GHz,
|
||||
v => {
|
||||
return Err(CanonicalDecodeError::BadDiscriminant {
|
||||
field: "frequency_band",
|
||||
value: v,
|
||||
})
|
||||
}
|
||||
};
|
||||
let channel = c.u8()?;
|
||||
let bandwidth_mhz = c.u16()?;
|
||||
let tx_antennas = c.u8()?;
|
||||
let rx_antennas = c.u8()?;
|
||||
let spacing_mm = match c.u8()? {
|
||||
1 => Some(c.f32()?),
|
||||
0 => {
|
||||
// Reserved padding must be zero (decoder strictness =
|
||||
// injectivity on the accepted domain): otherwise forged
|
||||
// nonzero padding would decode to the same frame as the
|
||||
// canonical encoding and re-encode differently.
|
||||
if c.take(4)? != [0u8; 4] {
|
||||
return Err(CanonicalDecodeError::ReservedNotZero { field: "spacing_mm" });
|
||||
}
|
||||
None
|
||||
}
|
||||
v => {
|
||||
return Err(CanonicalDecodeError::BadDiscriminant {
|
||||
field: "spacing_mm",
|
||||
value: v,
|
||||
})
|
||||
}
|
||||
};
|
||||
let rssi_dbm = c.i8()?;
|
||||
let noise_floor_dbm = c.i8()?;
|
||||
let sequence_number = c.u32()?;
|
||||
let cal = c.uuid()?;
|
||||
let calibration_id = if cal == Uuid::nil() { None } else { Some(cal) };
|
||||
let model_id = c.u16()?;
|
||||
let model_version = c.u16()?;
|
||||
|
||||
let rows = c.u32()? as usize;
|
||||
let cols = c.u32()? as usize;
|
||||
let expect = rows.saturating_mul(cols).saturating_mul(16);
|
||||
let found = bytes.len() - c.at;
|
||||
if found < expect {
|
||||
return Err(CanonicalDecodeError::PayloadMismatch { rows, cols, expect, found });
|
||||
}
|
||||
let mut samples = Vec::with_capacity(rows * cols);
|
||||
for _ in 0..rows * cols {
|
||||
let raw: [u8; 16] = c.take(16)?.try_into().unwrap();
|
||||
samples.push(ComplexSample::from_le_bytes(raw).0);
|
||||
}
|
||||
if c.at != bytes.len() {
|
||||
return Err(CanonicalDecodeError::TrailingBytes(bytes.len() - c.at));
|
||||
}
|
||||
let data = Array2::from_shape_vec((rows, cols), samples).map_err(|_| {
|
||||
CanonicalDecodeError::PayloadMismatch { rows, cols, expect, found }
|
||||
})?;
|
||||
|
||||
let metadata = CsiMetadata {
|
||||
timestamp: Timestamp { seconds, nanos },
|
||||
device_id: DeviceId::new(device_id),
|
||||
frequency_band,
|
||||
channel,
|
||||
bandwidth_mhz,
|
||||
antenna_config: AntennaConfig { tx_antennas, rx_antennas, spacing_mm },
|
||||
rssi_dbm,
|
||||
noise_floor_dbm,
|
||||
sequence_number,
|
||||
calibration_id,
|
||||
model_id,
|
||||
model_version,
|
||||
};
|
||||
|
||||
let amplitude = data.mapv(num_complex::Complex::norm);
|
||||
let phase = data.mapv(num_complex::Complex::arg);
|
||||
Ok(Self { id, metadata, data, amplitude, phase })
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Signal Types
|
||||
// =============================================================================
|
||||
@@ -1307,6 +1522,133 @@ mod tests {
|
||||
assert_ne!(frame.witness_hash(), frame2.witness_hash());
|
||||
}
|
||||
|
||||
/// AC7 — replay: `from_canonical_bytes` is the exact inverse of
|
||||
/// `to_canonical_bytes` — same id, metadata, payload, and witness hash.
|
||||
/// This is the capture-to-claim law: a stored canonical capture replays to
|
||||
/// a frame the pipeline cannot distinguish from the original.
|
||||
#[test]
|
||||
fn ac7_canonical_round_trip_replays_identically() {
|
||||
use ndarray::Array2;
|
||||
let mut meta = CsiMetadata::new(DeviceId::new("node-α"), FrequencyBand::Band6GHz, 37);
|
||||
meta.set_calibration(uuid::Uuid::new_v4());
|
||||
meta.set_model(9, 0x0203);
|
||||
meta.antenna_config.spacing_mm = Some(62.5);
|
||||
meta.rssi_dbm = -41;
|
||||
meta.sequence_number = 123_456;
|
||||
let data = Array2::from_shape_fn((2, 56), |(r, c)| {
|
||||
Complex64::new((r as f64 + 1.0) * (c as f64).cos(), (c as f64 * 0.1).tan())
|
||||
});
|
||||
let frame = CsiFrame::new(meta, data);
|
||||
|
||||
let bytes = frame.to_canonical_bytes();
|
||||
let replayed = CsiFrame::from_canonical_bytes(&bytes).expect("decodes");
|
||||
|
||||
assert_eq!(replayed.id, frame.id);
|
||||
// Field-wise metadata equality (CsiMetadata has no PartialEq; the
|
||||
// byte-identical re-encoding below covers every field regardless).
|
||||
assert_eq!(replayed.metadata.device_id, frame.metadata.device_id);
|
||||
assert_eq!(replayed.metadata.calibration_id, frame.metadata.calibration_id);
|
||||
assert_eq!(replayed.metadata.model_version, frame.metadata.model_version);
|
||||
assert_eq!(replayed.metadata.antenna_config.spacing_mm, Some(62.5));
|
||||
assert_eq!(replayed.data, frame.data);
|
||||
// Witness equality — the strongest statement of equivalence.
|
||||
assert_eq!(replayed.witness_hash(), frame.witness_hash());
|
||||
// Re-encoding is byte-identical.
|
||||
assert_eq!(replayed.to_canonical_bytes(), bytes);
|
||||
// Projections recomputed consistently.
|
||||
assert_eq!(replayed.amplitude, frame.amplitude);
|
||||
}
|
||||
|
||||
/// AC8 — the decoder fails closed on every malformed-input class.
|
||||
#[test]
|
||||
fn ac8_canonical_decode_fails_closed() {
|
||||
use ndarray::Array2;
|
||||
let meta = CsiMetadata::new(DeviceId::new("n"), FrequencyBand::Band2_4GHz, 1);
|
||||
let data = Array2::from_shape_fn((1, 4), |(_, c)| Complex64::new(c as f64, 0.0));
|
||||
let frame = CsiFrame::new(meta, data);
|
||||
let bytes = frame.to_canonical_bytes();
|
||||
|
||||
// Truncation anywhere fails: in the payload it is caught by the
|
||||
// shape-vs-length check (PayloadMismatch); in the header by Truncated.
|
||||
assert!(matches!(
|
||||
CsiFrame::from_canonical_bytes(&bytes[..bytes.len() - 1]),
|
||||
Err(CanonicalDecodeError::PayloadMismatch { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
CsiFrame::from_canonical_bytes(&bytes[..10]),
|
||||
Err(CanonicalDecodeError::Truncated { .. })
|
||||
));
|
||||
|
||||
// Trailing junk fails.
|
||||
let mut padded = bytes.clone();
|
||||
padded.extend_from_slice(&[0u8; 3]);
|
||||
assert!(matches!(
|
||||
CsiFrame::from_canonical_bytes(&padded),
|
||||
Err(CanonicalDecodeError::TrailingBytes(3))
|
||||
));
|
||||
|
||||
// Bad frequency-band discriminant fails. Band byte sits right after
|
||||
// id(16) + seconds(8) + nanos(4) + dev_len(4) + dev("n" = 1).
|
||||
let mut bad = bytes.clone();
|
||||
bad[16 + 8 + 4 + 4 + 1] = 9;
|
||||
assert!(matches!(
|
||||
CsiFrame::from_canonical_bytes(&bad),
|
||||
Err(CanonicalDecodeError::BadDiscriminant { field: "frequency_band", value: 9 })
|
||||
));
|
||||
|
||||
// A nil calibration uuid decodes as None (the documented encoding).
|
||||
let replayed = CsiFrame::from_canonical_bytes(&bytes).unwrap();
|
||||
assert_eq!(replayed.metadata.calibration_id, None);
|
||||
}
|
||||
|
||||
/// AC8b (review finding 7) — decoder strictness = injectivity on the
|
||||
/// accepted domain: forged nonzero bytes in the `spacing_mm` reserved
|
||||
/// region are rejected, so for accepted inputs `re-encode != original`
|
||||
/// is impossible.
|
||||
#[test]
|
||||
fn ac8b_forged_reserved_spacing_bytes_rejected() {
|
||||
use ndarray::Array2;
|
||||
let meta = CsiMetadata::new(DeviceId::new("n"), FrequencyBand::Band2_4GHz, 1);
|
||||
let data = Array2::from_shape_fn((1, 4), |(_, c)| Complex64::new(c as f64, 0.0));
|
||||
let frame = CsiFrame::new(meta, data);
|
||||
let bytes = frame.to_canonical_bytes();
|
||||
|
||||
// Spacing tag sits after id(16)+secs(8)+nanos(4)+dev_len(4)+dev("n"=1)
|
||||
// + band(1)+channel(1)+bw(2)+tx(1)+rx(1); the 4 reserved bytes follow.
|
||||
let tag_off = 16 + 8 + 4 + 4 + 1 + 1 + 1 + 2 + 1 + 1;
|
||||
assert_eq!(bytes[tag_off], 0, "fixture must encode spacing_mm = None");
|
||||
assert_eq!(&bytes[tag_off + 1..tag_off + 5], &[0u8; 4]);
|
||||
|
||||
// Sanity: the canonical bytes decode and re-encode byte-identically.
|
||||
let ok = CsiFrame::from_canonical_bytes(&bytes).unwrap();
|
||||
assert_eq!(ok.to_canonical_bytes(), bytes);
|
||||
|
||||
// Forge each reserved byte: the decoder must fail closed (before the
|
||||
// fix it decoded to the same frame, whose re-encoding differed from
|
||||
// the forged original — a witness-replay ambiguity).
|
||||
for i in 1..=4 {
|
||||
let mut forged = bytes.clone();
|
||||
forged[tag_off + i] = 0xAB;
|
||||
assert!(matches!(
|
||||
CsiFrame::from_canonical_bytes(&forged),
|
||||
Err(CanonicalDecodeError::ReservedNotZero { field: "spacing_mm" })
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// AC8c (review finding 7) — `Some(Uuid::nil())` calibration is an
|
||||
/// encoding error: nil is the wire sentinel for `None`, so encoding it
|
||||
/// would alias two distinct frames to one byte string (and one witness).
|
||||
#[test]
|
||||
#[should_panic(expected = "nil is the None sentinel")]
|
||||
fn ac8c_nil_calibration_id_is_an_encoding_error() {
|
||||
use ndarray::Array2;
|
||||
let mut meta = CsiMetadata::new(DeviceId::new("n"), FrequencyBand::Band2_4GHz, 1);
|
||||
meta.calibration_id = Some(uuid::Uuid::nil());
|
||||
let data = Array2::from_shape_fn((1, 2), |(_, c)| Complex64::new(c as f64, 0.0));
|
||||
let _ = CsiFrame::new(meta, data).to_canonical_bytes();
|
||||
}
|
||||
|
||||
/// AC3 — `serde(default)` forward-read of pre-ADR-136 metadata JSON.
|
||||
#[cfg(feature = "serde")]
|
||||
#[test]
|
||||
|
||||
@@ -108,8 +108,14 @@ pub async fn start_server(
|
||||
cmd.args(["--log-level", log_level]);
|
||||
}
|
||||
|
||||
// Set data source (default to "simulate" if not specified for demo mode)
|
||||
let source = config.source.as_deref().unwrap_or("simulate");
|
||||
// Default to explicit "simulated" demo mode when the desktop user hasn't
|
||||
// chosen a source — this is the *Tauri demo* app, not a production
|
||||
// sensing endpoint, so the demo default is correct here. Critically, the
|
||||
// value passed downstream is the **explicit** "simulated", not "auto",
|
||||
// which means the sensing-server will tag the data as synthetic in its
|
||||
// API responses rather than silently fall back (issue #937 fix in
|
||||
// sensing-server's `auto` handler).
|
||||
let source = config.source.as_deref().unwrap_or("simulated");
|
||||
cmd.args(["--source", source]);
|
||||
|
||||
// Redirect stdout/stderr to pipes for monitoring
|
||||
@@ -317,7 +323,7 @@ pub async fn restart_server(
|
||||
log_level: None,
|
||||
bind_address: None,
|
||||
server_path: None,
|
||||
source: None, // Use default (simulate)
|
||||
source: None, // Falls through to explicit "simulated" — Tauri demo default.
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -19,6 +19,9 @@ wifi-densepose-worldgraph = { version = "0.3.0", path = "../wifi-densepose-world
|
||||
wifi-densepose-geo = { version = "0.1.0", path = "../wifi-densepose-geo" }
|
||||
# Deterministic witness over the trust decision (ADR-137 §2.7 / ADR-028).
|
||||
blake3 = { version = "1.5", default-features = false }
|
||||
# Dynamic min-cut over the live mesh coupling graph (mesh_guard.rs):
|
||||
# incremental partition-risk monitoring + structural recalibration trigger.
|
||||
ruvector-mincut = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
criterion = { version = "0.5", features = ["html_reports"] }
|
||||
|
||||
@@ -48,5 +48,41 @@ fn bench_cycle(c: &mut Criterion) {
|
||||
});
|
||||
}
|
||||
|
||||
criterion_group!(benches, bench_cycle);
|
||||
/// Mesh guard in isolation: cold build (node set appears) vs steady state
|
||||
/// (identical weights next cycle → change-gated, zero graph updates) for a
|
||||
/// 12-node mesh — the full ADR-029 deployment size.
|
||||
fn bench_mesh_guard(c: &mut Criterion) {
|
||||
use wifi_densepose_engine::MeshGuard;
|
||||
let nodes: Vec<u8> = (0..12).collect();
|
||||
let w = |i: usize, j: usize| 0.4 + 0.01 * ((i + j) % 7) as f64;
|
||||
|
||||
c.bench_function("mesh_guard_cold_build_12n", |b| {
|
||||
b.iter_batched(
|
||||
MeshGuard::default,
|
||||
|mut g| g.update(&nodes, w),
|
||||
BatchSize::SmallInput,
|
||||
);
|
||||
});
|
||||
|
||||
c.bench_function("mesh_guard_steady_state_12n", |b| {
|
||||
let mut g = MeshGuard::default();
|
||||
g.update(&nodes, w); // warm
|
||||
b.iter(|| g.update(&nodes, w));
|
||||
});
|
||||
|
||||
c.bench_function("mesh_guard_one_edge_change_12n", |b| {
|
||||
let mut g = MeshGuard::default();
|
||||
g.update(&nodes, w);
|
||||
let mut flip = false;
|
||||
b.iter(|| {
|
||||
flip = !flip;
|
||||
let delta = if flip { 0.2 } else { 0.0 };
|
||||
g.update(&nodes, |i, j| {
|
||||
if (i.min(j), i.max(j)) == (0, 1) { 0.4 + delta } else { w(i, j) }
|
||||
})
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
criterion_group!(benches, bench_cycle, bench_mesh_guard);
|
||||
criterion_main!(benches);
|
||||
|
||||
@@ -46,6 +46,9 @@ use wifi_densepose_worldgraph::{
|
||||
WorldId, WorldNode, ZoneBoundsEnu,
|
||||
};
|
||||
|
||||
pub mod mesh_guard;
|
||||
pub use mesh_guard::{MeshGuard, MeshPartitionReport};
|
||||
|
||||
/// Errors from an engine cycle.
|
||||
#[derive(Debug)]
|
||||
pub enum EngineError {
|
||||
@@ -97,6 +100,15 @@ pub struct TrustedOutput {
|
||||
/// BLAKE3 witness over the trust decision (provenance ‖ class ‖ calibration)
|
||||
/// — a deterministic, signed-belief fingerprint (ADR-137 §2.7 / ADR-028).
|
||||
pub witness: [u8; 32],
|
||||
/// Whether the drift→recalibration advisor recommends re-running the
|
||||
/// ADR-135 baseline / refitting the per-room adapter (ADR-150 §3.4):
|
||||
/// sustained low coherence or an ADR-142 change-point this cycle.
|
||||
pub recalibration_recommended: bool,
|
||||
/// Dynamic min-cut partition report over the live mesh coupling graph
|
||||
/// (None for meshes of fewer than two nodes). `at_risk` counts as a
|
||||
/// structural event for the recalibration advisor and names the nodes
|
||||
/// (`weak_side`) closest to splitting off — failure/jamming triage.
|
||||
pub mesh: Option<MeshPartitionReport>,
|
||||
}
|
||||
|
||||
/// Composition root for the RuView streaming engine.
|
||||
@@ -116,6 +128,74 @@ pub struct StreamingEngine {
|
||||
slam: RfSlam,
|
||||
// ADR-139 live loop: stable track_id -> PersonTrack WorldId.
|
||||
person_tracks: BTreeMap<u64, WorldId>,
|
||||
// WorldGraph belief retention: max live SemanticState nodes. The live loop
|
||||
// appends one belief per cycle (1.7M/day at 20 Hz); durable history is the
|
||||
// recorder's job, so old beliefs are evicted deterministically past this cap.
|
||||
semantic_retention: usize,
|
||||
// Per-room calibration adapter (ADR-150 §3.4: ~11 KB LoRA on a frozen
|
||||
// base). Identity is part of the trust chain: when set, the adapter id is
|
||||
// appended to the provenance model_version, so swapping adapters changes
|
||||
// the witness. None = shared base model.
|
||||
adapter: Option<AdapterInfo>,
|
||||
// Drift→recalibration advisor (ADR-135 trigger for ADR-150 §3.4 refit).
|
||||
recal: RecalibrationAdvisor,
|
||||
// Dynamic min-cut mesh partition guard (incremental, change-gated).
|
||||
mesh: MeshGuard,
|
||||
}
|
||||
|
||||
/// Identity of an active per-room calibration adapter (ADR-150 §3.4). The id
|
||||
/// must be content-derived (e.g. a hash prefix of the adapter file) so the
|
||||
/// provenance/witness chain pins the exact weights that shaped inference.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AdapterInfo {
|
||||
/// Content-derived adapter identity (e.g. first 16 hex of its SHA-256).
|
||||
pub adapter_id: String,
|
||||
/// Number of in-room samples the adapter was fitted on (0 if unknown).
|
||||
pub trained_samples: u32,
|
||||
}
|
||||
|
||||
/// Recommends re-running calibration / adapter refit when the live signal
|
||||
/// degrades persistently (ADR-135 drift → ADR-150 §3.4 few-shot recalibration).
|
||||
///
|
||||
/// Two triggers, both cheap and deterministic:
|
||||
/// - `low_coherence_streak`: N consecutive cycles whose base coherence fell
|
||||
/// below the floor (sustained degradation, not a single bad frame);
|
||||
/// - any ADR-142 change-point this cycle (the environment itself changed).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RecalibrationAdvisor {
|
||||
/// Coherence below this counts toward the streak.
|
||||
pub coherence_floor: f32,
|
||||
/// Consecutive low-coherence cycles required to recommend recalibration.
|
||||
pub streak_threshold: u32,
|
||||
streak: u32,
|
||||
}
|
||||
|
||||
impl Default for RecalibrationAdvisor {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
coherence_floor: 0.5,
|
||||
streak_threshold: 60, // ~3 s at 20 Hz of sustained degradation
|
||||
streak: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RecalibrationAdvisor {
|
||||
/// Feed one cycle's evidence; returns whether recalibration is recommended.
|
||||
fn observe(&mut self, base_coherence: f32, change_point: bool) -> bool {
|
||||
if base_coherence < self.coherence_floor {
|
||||
self.streak = self.streak.saturating_add(1);
|
||||
} else {
|
||||
self.streak = 0;
|
||||
}
|
||||
change_point || self.streak >= self.streak_threshold
|
||||
}
|
||||
|
||||
/// Current consecutive low-coherence cycle count.
|
||||
#[must_use]
|
||||
pub fn streak(&self) -> u32 {
|
||||
self.streak
|
||||
}
|
||||
}
|
||||
|
||||
impl StreamingEngine {
|
||||
@@ -135,9 +215,53 @@ impl StreamingEngine {
|
||||
evolution: None,
|
||||
slam: RfSlam::with_discovery(0.5, 5, 0.6),
|
||||
person_tracks: BTreeMap::new(),
|
||||
semantic_retention: Self::DEFAULT_SEMANTIC_RETENTION,
|
||||
adapter: None,
|
||||
recal: RecalibrationAdvisor::default(),
|
||||
mesh: MeshGuard::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Activate a per-room calibration adapter (ADR-150 §3.4). From the next
|
||||
/// cycle on, the adapter id is part of provenance `model_version` — and
|
||||
/// therefore of the witness — so the exact weights shaping inference are
|
||||
/// pinned in the trust chain. Pass the result of hashing the adapter file.
|
||||
pub fn set_room_adapter(&mut self, info: AdapterInfo) {
|
||||
self.adapter = Some(info);
|
||||
}
|
||||
|
||||
/// Deactivate the adapter (revert to the shared base model).
|
||||
pub fn clear_room_adapter(&mut self) {
|
||||
self.adapter = None;
|
||||
}
|
||||
|
||||
/// The active adapter, if any.
|
||||
#[must_use]
|
||||
pub fn room_adapter(&self) -> Option<&AdapterInfo> {
|
||||
self.adapter.as_ref()
|
||||
}
|
||||
|
||||
/// Tune the drift→recalibration advisor (floor + streak threshold).
|
||||
pub fn set_recalibration_advisor(&mut self, advisor: RecalibrationAdvisor) {
|
||||
self.recal = advisor;
|
||||
}
|
||||
|
||||
/// Mutable access to the mesh partition guard (risk threshold, quantum,
|
||||
/// min-node count). Operators tune the partition-risk sensitivity here.
|
||||
pub fn mesh_guard_mut(&mut self) -> &mut MeshGuard {
|
||||
&mut self.mesh
|
||||
}
|
||||
|
||||
/// Default cap on live `SemanticState` beliefs in the WorldGraph
|
||||
/// (~6 minutes of full-rate history at 20 Hz; older beliefs are evicted —
|
||||
/// durable history belongs to the recorder).
|
||||
pub const DEFAULT_SEMANTIC_RETENTION: usize = 7_200;
|
||||
|
||||
/// Override the `SemanticState` retention cap (minimum 1).
|
||||
pub fn set_semantic_retention(&mut self, max_states: usize) {
|
||||
self.semantic_retention = max_states.max(1);
|
||||
}
|
||||
|
||||
/// ADR-139 live loop: create or update a `PersonTrack` node by stable
|
||||
/// `track_id`, locate it in `room`, and wire an `Observes` edge from
|
||||
/// `sensor` (so the privacy rollup can suppress it under identity-strict
|
||||
@@ -321,21 +445,47 @@ impl StreamingEngine {
|
||||
// 4. Evolution change-point (ADR-142) over per-node mean amplitude.
|
||||
let change_point = self.track_evolution(node_frames, now_ms, room);
|
||||
|
||||
// 5. Privacy control plane (ADR-141): demote on a fusion-level OR an
|
||||
// array-level contradiction (monotonic — information only removed).
|
||||
// 5. Mesh partition guard (ADR-032): dynamic min-cut over the coupling
|
||||
// graph. Coupling between nodes i and j is the product of their
|
||||
// fusion attention weights scaled by the node count, so a node the
|
||||
// fuser down-weights is exactly a node weakly coupled in the graph.
|
||||
// (Change-gated incremental updates: steady state touches 0 edges.)
|
||||
let node_ids: Vec<u8> = node_frames.iter().map(|f| f.node_id).collect();
|
||||
let weights = &quality.per_node_weights;
|
||||
let n = weights.len() as f64;
|
||||
let mesh = self.mesh.update(&node_ids, |i, j| {
|
||||
let wi = weights.get(i).copied().unwrap_or(0.0) as f64;
|
||||
let wj = weights.get(j).copied().unwrap_or(0.0) as f64;
|
||||
wi * wj * n
|
||||
});
|
||||
let mesh_at_risk = mesh.as_ref().is_some_and(|m| m.at_risk);
|
||||
|
||||
// 6. Privacy control plane (ADR-141): demote on a fusion-level OR an
|
||||
// array-level contradiction OR a mesh close to partitioning. The
|
||||
// last is a security/reliability signal (ADR-032): a fragmenting
|
||||
// array makes the fused belief less trustworthy, so we emit at a
|
||||
// more restricted class. Monotonic — information is only ever
|
||||
// removed — and the demotion is part of the witness.
|
||||
let base_class = self.privacy.active_class();
|
||||
let demoted = quality.forces_privacy_demotion() || array_contradiction;
|
||||
let demoted = quality.forces_privacy_demotion() || array_contradiction || mesh_at_risk;
|
||||
let effective_class = if demoted { demote_one(base_class) } else { base_class };
|
||||
|
||||
// 6. Semantic state with mandatory provenance (ADR-139/140). The
|
||||
// 7. Semantic state with mandatory provenance (ADR-139/140). The
|
||||
// calibration version comes from the *agreed* epoch (None on mismatch).
|
||||
// When a per-room adapter is active (ADR-150 §3.4) its content-derived
|
||||
// id is part of model_version — and therefore of the witness — so the
|
||||
// exact weights shaping inference are pinned in the trust chain.
|
||||
let calibration_version = match quality.calibration_id {
|
||||
Some(c) => format!("cal:{:016x}", c.0),
|
||||
None => "cal:none".to_string(),
|
||||
};
|
||||
let model_version = match &self.adapter {
|
||||
Some(a) => format!("rfenc-v{}+adapter:{}", self.model_version, a.adapter_id),
|
||||
None => format!("rfenc-v{}", self.model_version),
|
||||
};
|
||||
let provenance = SemanticProvenance {
|
||||
evidence: quality.evidence_refs.iter().map(|e| format!("{e:?}")).collect(),
|
||||
model_version: format!("rfenc-v{}", self.model_version),
|
||||
model_version,
|
||||
calibration_version,
|
||||
privacy_decision: format!("{:?}/{:?}", self.privacy.active_mode(), effective_class),
|
||||
};
|
||||
@@ -350,10 +500,23 @@ impl StreamingEngine {
|
||||
provenance.clone(),
|
||||
&[room],
|
||||
);
|
||||
// Retention: bound the live belief set (one node is appended per cycle;
|
||||
// without this the graph grows ~1.7M nodes/day at 20 Hz). Deterministic
|
||||
// eviction; the just-added belief is always newest and survives.
|
||||
self.world.prune_semantic_states(self.semantic_retention);
|
||||
|
||||
// 7. Deterministic witness over the trust decision (ADR-137 §2.7).
|
||||
// 8. Deterministic witness over the trust decision (ADR-137 §2.7).
|
||||
// `effective_class` already reflects any mesh-risk demotion, so a
|
||||
// fragmenting array shifts the witness — partition risk is auditable.
|
||||
let witness = witness_of(&provenance, effective_class);
|
||||
|
||||
// 9. Drift→recalibration advisor (ADR-135 → ADR-150 §3.4): sustained
|
||||
// low coherence, an environment change-point, or a mesh close to
|
||||
// partitioning recommends refit.
|
||||
let recalibration_recommended = self
|
||||
.recal
|
||||
.observe(quality.base_coherence, change_point.is_some() || mesh_at_risk);
|
||||
|
||||
self.cycle += 1;
|
||||
Ok(TrustedOutput {
|
||||
semantic_id,
|
||||
@@ -364,6 +527,8 @@ impl StreamingEngine {
|
||||
directional,
|
||||
change_point,
|
||||
witness,
|
||||
recalibration_recommended,
|
||||
mesh,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -547,6 +712,205 @@ mod tests {
|
||||
assert_eq!(o1.quality.per_node_weights, o2.quality.per_node_weights);
|
||||
}
|
||||
|
||||
/// ADR-150 §3.4 adapter provenance: activating a per-room adapter changes
|
||||
/// the provenance model_version AND the witness — the exact weights shaping
|
||||
/// inference are pinned in the trust chain, so an adapter can never swap
|
||||
/// silently. Clearing it restores the base identity (and base witness).
|
||||
#[test]
|
||||
fn adapter_identity_is_witnessed() {
|
||||
let cal = CalibrationId(9);
|
||||
let frames = [node_frame(0, 1000, 56), node_frame(1, 1001, 56)];
|
||||
|
||||
let (mut e, room) = engine();
|
||||
let base = e.process_cycle(&frames, cal, room, 1_000).unwrap();
|
||||
assert_eq!(base.provenance.model_version, "rfenc-v1");
|
||||
|
||||
e.set_room_adapter(AdapterInfo {
|
||||
adapter_id: "a1b2c3d4e5f60718".into(),
|
||||
trained_samples: 150,
|
||||
});
|
||||
let adapted = e.process_cycle(&frames, cal, room, 2_000).unwrap();
|
||||
assert_eq!(
|
||||
adapted.provenance.model_version,
|
||||
"rfenc-v1+adapter:a1b2c3d4e5f60718"
|
||||
);
|
||||
assert_ne!(adapted.witness, base.witness, "adapter must shift the witness");
|
||||
|
||||
// A different adapter id yields a different witness again.
|
||||
e.set_room_adapter(AdapterInfo {
|
||||
adapter_id: "ffffffffffffffff".into(),
|
||||
trained_samples: 150,
|
||||
});
|
||||
let other = e.process_cycle(&frames, cal, room, 3_000).unwrap();
|
||||
assert_ne!(other.witness, adapted.witness);
|
||||
|
||||
// Clearing restores the base identity and the base witness.
|
||||
e.clear_room_adapter();
|
||||
let back = e.process_cycle(&frames, cal, room, 4_000).unwrap();
|
||||
assert_eq!(back.provenance.model_version, "rfenc-v1");
|
||||
assert_eq!(back.witness, base.witness);
|
||||
}
|
||||
|
||||
/// Drift→recalibration advisor logic: a sustained low-coherence streak
|
||||
/// recommends refit; a single healthy cycle resets the streak; a
|
||||
/// change-point recommends immediately regardless of streak.
|
||||
#[test]
|
||||
fn recalibration_advisor_streak_and_change_point() {
|
||||
let mut adv = RecalibrationAdvisor {
|
||||
coherence_floor: 0.5,
|
||||
streak_threshold: 3,
|
||||
..Default::default()
|
||||
};
|
||||
// Healthy cycles never recommend and keep the streak at zero.
|
||||
for _ in 0..5 {
|
||||
assert!(!adv.observe(0.9, false));
|
||||
}
|
||||
assert_eq!(adv.streak(), 0);
|
||||
// Two low cycles: not yet.
|
||||
assert!(!adv.observe(0.2, false));
|
||||
assert!(!adv.observe(0.2, false));
|
||||
// Third consecutive low cycle: fire.
|
||||
assert!(adv.observe(0.2, false));
|
||||
// Recovery resets the streak.
|
||||
assert!(!adv.observe(0.9, false));
|
||||
assert_eq!(adv.streak(), 0);
|
||||
// A change-point recommends immediately, even at full coherence.
|
||||
assert!(adv.observe(0.9, true));
|
||||
}
|
||||
|
||||
/// Engine-level: clean coherent cycles never recommend recalibration (the
|
||||
/// advisor is wired into process_cycle and stays quiet on healthy input).
|
||||
#[test]
|
||||
fn healthy_cycles_do_not_recommend_recalibration() {
|
||||
let (mut e, room) = engine();
|
||||
e.set_recalibration_advisor(RecalibrationAdvisor {
|
||||
coherence_floor: 0.5,
|
||||
streak_threshold: 3,
|
||||
..Default::default()
|
||||
});
|
||||
let cal = CalibrationId(2);
|
||||
for i in 0..5u64 {
|
||||
let frames = [
|
||||
node_frame(0, 1_000 + i * 50_000, 56),
|
||||
node_frame(1, 1_001 + i * 50_000, 56),
|
||||
];
|
||||
let out = e.process_cycle(&frames, cal, room, i as i64).unwrap();
|
||||
assert!(!out.recalibration_recommended);
|
||||
}
|
||||
}
|
||||
|
||||
/// Maximum total coupling mass of an n-node mesh whose attention weights
|
||||
/// sum to 1 (coupling = wᵢ·wⱼ·n): Σ_{i<j} wᵢwⱼ·n = n(1−Σwᵢ²)/2 ≤ (n−1)/2.
|
||||
/// Any cut is a subset of the edges, so every achievable cut value is
|
||||
/// bounded by this mass — a risk threshold at or above it is *guaranteed*
|
||||
/// to be crossed (deterministic fixture, review finding 4).
|
||||
fn max_coupling_mass(n_nodes: usize) -> f64 {
|
||||
(n_nodes as f64 - 1.0) / 2.0
|
||||
}
|
||||
|
||||
/// Mesh guard wiring: a balanced 2-node cycle reports a mesh (cut exists)
|
||||
/// but never flags risk (min_nodes=3); a 3-node mesh whose cut value
|
||||
/// *deterministically* falls at or below the configured risk threshold
|
||||
/// (threshold = the provable upper bound on any achievable cut) is flagged
|
||||
/// at_risk, and the structural event feeds the recalibration advisor
|
||||
/// immediately — no conditional assertions (review finding 4).
|
||||
#[test]
|
||||
fn mesh_partition_risk_feeds_recalibration() {
|
||||
let (mut e, room) = engine();
|
||||
let cal = CalibrationId(3);
|
||||
|
||||
// Balanced 2-node mesh: report present, no risk.
|
||||
let out = e
|
||||
.process_cycle(&[node_frame(0, 1000, 56), node_frame(1, 1001, 56)], cal, room, 1)
|
||||
.unwrap();
|
||||
let mesh = out.mesh.expect("2-node mesh reports");
|
||||
assert!(!mesh.at_risk);
|
||||
assert!(!out.recalibration_recommended);
|
||||
|
||||
// 3-node mesh with the operator risk threshold set to the provable
|
||||
// cut upper bound: the crossing is deterministic regardless of the
|
||||
// fuser's exact weighting.
|
||||
e.mesh_guard_mut().risk_threshold = max_coupling_mass(3);
|
||||
let frames = [
|
||||
node_frame(0, 10_000_000, 56),
|
||||
node_frame(1, 10_000_001, 56),
|
||||
node_frame(2, 10_000_002, 56),
|
||||
];
|
||||
let out3 = e.process_cycle(&frames, cal, room, 2).unwrap();
|
||||
let m3 = out3.mesh.expect("3-node mesh reports");
|
||||
assert!(m3.at_risk, "cut ≤ threshold must flag partition risk");
|
||||
assert!(
|
||||
out3.recalibration_recommended,
|
||||
"mesh risk is a structural event — the advisor must fire immediately, no streak"
|
||||
);
|
||||
assert!(m3.cut_value.is_finite() && m3.cut_value >= 0.0);
|
||||
}
|
||||
|
||||
/// Mesh partition risk demotes the privacy class and shifts the witness —
|
||||
/// a fragmenting array makes the fused belief less trustworthy, so it is
|
||||
/// emitted at a more restricted class, and that demotion is auditable.
|
||||
/// Both cycles use the *same 3-node topology and frames*; the engines
|
||||
/// differ only in the forced mesh risk, so the witness delta is
|
||||
/// attributable to the risk demotion alone (review finding 4).
|
||||
#[test]
|
||||
fn mesh_risk_demotes_privacy_and_shifts_witness() {
|
||||
let cal = CalibrationId(8);
|
||||
let frames3 = [
|
||||
node_frame(0, 1000, 56),
|
||||
node_frame(1, 1001, 56),
|
||||
node_frame(2, 1002, 56),
|
||||
];
|
||||
|
||||
// Baseline: same topology, default risk threshold — clean cycle, not
|
||||
// demoted (PrivateHome → Anonymous), mesh healthy.
|
||||
let (mut e1, r1) = engine();
|
||||
let base = e1.process_cycle(&frames3, cal, r1, 5_000).unwrap();
|
||||
assert!(!base.mesh.as_ref().unwrap().at_risk);
|
||||
assert!(!base.demoted);
|
||||
assert_eq!(base.effective_class, PrivacyClass::Anonymous);
|
||||
|
||||
// Forced risk: identical frames/topology, threshold at the provable
|
||||
// cut upper bound so the crossing is deterministic.
|
||||
let (mut e2, r2) = engine();
|
||||
e2.mesh_guard_mut().risk_threshold = max_coupling_mass(3);
|
||||
let risky = e2.process_cycle(&frames3, cal, r2, 5_000).unwrap();
|
||||
assert!(risky.mesh.as_ref().unwrap().at_risk);
|
||||
assert!(risky.demoted, "mesh risk must demote");
|
||||
// PrivateHome base Anonymous(2) → demoted to Restricted(3).
|
||||
assert_eq!(risky.effective_class, PrivacyClass::Restricted);
|
||||
assert!(risky.provenance.privacy_decision.contains("Restricted"));
|
||||
assert_ne!(
|
||||
risky.witness, base.witness,
|
||||
"same topology, risk-only delta must shift the witness"
|
||||
);
|
||||
}
|
||||
|
||||
/// WorldGraph belief retention: the live loop appends one SemanticState per
|
||||
/// cycle; past the cap the oldest beliefs are evicted so graph memory is
|
||||
/// bounded, while structural nodes and the newest belief always survive.
|
||||
#[test]
|
||||
fn semantic_state_growth_is_bounded() {
|
||||
let (mut e, room) = engine();
|
||||
e.set_semantic_retention(5);
|
||||
let cal = CalibrationId(1);
|
||||
let mut last_id = None;
|
||||
let baseline_nodes = 2; // room + sensor
|
||||
for i in 0..20u64 {
|
||||
let frames = [
|
||||
node_frame(0, 1000 + i * 50_000, 56),
|
||||
node_frame(1, 1001 + i * 50_000, 56),
|
||||
];
|
||||
let out = e.process_cycle(&frames, cal, room, 5_000 + i as i64).unwrap();
|
||||
last_id = Some(out.semantic_id);
|
||||
assert!(e.world().node_count() <= baseline_nodes + 5);
|
||||
}
|
||||
// 20 cycles ran, only 5 beliefs remain, newest is still present.
|
||||
assert_eq!(e.world().node_count(), baseline_nodes + 5);
|
||||
assert!(e.world().node(last_id.unwrap()).is_some());
|
||||
// Structural nodes survive eviction.
|
||||
assert!(e.world().node(room).is_some());
|
||||
}
|
||||
|
||||
fn node_frame_scaled(node_id: u8, ts_us: u64, n_sub: usize, scale: f32) -> MultiBandCsiFrame {
|
||||
MultiBandCsiFrame {
|
||||
node_id,
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
//! Mesh partition guard: dynamic min-cut over the live multistatic node graph.
|
||||
//!
|
||||
//! The fusion mesh (nodes = sensing nodes, edge weights = fusion coupling
|
||||
//! derived from per-node attention weights) changes *incrementally* at cycle
|
||||
//! rate — one node's coupling drifts, a node joins or drops. This module
|
||||
//! maintains a [`ruvector_mincut::DynamicMinCut`] over that graph and exposes,
|
||||
//! per cycle:
|
||||
//!
|
||||
//! - the **min-cut value** — the cheapest set of couplings whose loss splits
|
||||
//! the mesh in two: a principled, global "how close is the array to
|
||||
//! partitioning" number (vs per-node heuristics that miss multi-node
|
||||
//! structure);
|
||||
//! - the **weak side** — which specific nodes are about to partition (feeds
|
||||
//! failure/jamming triage, ADR-032 posture);
|
||||
//! - an **at-risk flag** consumed by the engine: it counts as a structural
|
||||
//! event for the drift→recalibration advisor.
|
||||
//!
|
||||
//! ## Cost model (the optimization)
|
||||
//!
|
||||
//! Weights are quantized (default 1/64; a *nonzero* coupling below one quantum
|
||||
//! saturates to quantum 1 so a live coupling is never erased — see
|
||||
//! [`MeshGuard::weight_quantum`]) and updates are **change-gated**: an
|
||||
//! edge is touched only when its quantized weight actually moves, so the
|
||||
//! steady-state cycle applies *zero* graph updates and reuses the cached cut —
|
||||
//! O(active-changes) per cycle, not O(n²) rebuilds. The exact (deterministic)
|
||||
//! algorithm is used; mesh sizes are ≤ tens of nodes, far inside its budget.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use ruvector_mincut::{DynamicMinCut, MinCutBuilder};
|
||||
|
||||
/// Per-cycle report from the mesh guard.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct MeshPartitionReport {
|
||||
/// Current min-cut value over the coupling graph (higher = more robust).
|
||||
pub cut_value: f64,
|
||||
/// True when the mesh has ≥ `min_nodes` nodes and the cut value fell to or
|
||||
/// below the risk threshold — the array is close to splitting.
|
||||
pub at_risk: bool,
|
||||
/// The smaller side of the min-cut partition (node ids): the nodes that
|
||||
/// would be isolated if the weak couplings failed.
|
||||
pub weak_side: Vec<u8>,
|
||||
/// Incremental edge updates applied this cycle (0 in steady state).
|
||||
pub updates_applied: usize,
|
||||
}
|
||||
|
||||
/// Dynamic min-cut guard over the live mesh.
|
||||
pub struct MeshGuard {
|
||||
mincut: Option<DynamicMinCut>,
|
||||
/// Node set the structure was built over (sorted). A change forces rebuild.
|
||||
nodes: Vec<u8>,
|
||||
/// Quantized edge weights currently installed, keyed `(u, v)` with `u < v`.
|
||||
edges: BTreeMap<(u8, u8), i64>,
|
||||
/// Weight quantum: weights are snapped to multiples of this before
|
||||
/// comparison/installation, gating out sub-quantum jitter.
|
||||
///
|
||||
/// Policy: a **nonzero** coupling below one quantum saturates to quantum 1
|
||||
/// instead of quantizing to 0 — quantization never erases a live coupling.
|
||||
/// (Without the floor, a balanced mesh of ≥ 65 nodes — attention weights
|
||||
/// ~1/n ⇒ couplings ~1/n < 1/64 — had every edge erased and was reported
|
||||
/// permanently "already partitioned"/at-risk.) Exact zero stays zero: a
|
||||
/// truly absent coupling *is* a partition. Relative weakness below one
|
||||
/// quantum is not resolved; lower this quantum if that resolution matters.
|
||||
pub weight_quantum: f64,
|
||||
/// Cut value at or below which the mesh counts as at partition risk.
|
||||
pub risk_threshold: f64,
|
||||
/// Minimum node count for risk to be meaningful (a 2-node mesh always has
|
||||
/// a trivial cut; default 3).
|
||||
pub min_nodes: usize,
|
||||
}
|
||||
|
||||
impl Default for MeshGuard {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
mincut: None,
|
||||
nodes: Vec::new(),
|
||||
edges: BTreeMap::new(),
|
||||
weight_quantum: 1.0 / 64.0,
|
||||
risk_threshold: 0.25,
|
||||
min_nodes: 3,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MeshGuard {
|
||||
/// Quantize a raw weight to the guard's grid (floor; weights are ≥ 0).
|
||||
/// Nonzero sub-quantum weights saturate to quantum 1 — see the
|
||||
/// [`Self::weight_quantum`] policy (review finding: sub-quantum couplings
|
||||
/// must not produce a false "already partitioned").
|
||||
fn quantize(&self, w: f64) -> i64 {
|
||||
let w = w.max(0.0);
|
||||
let q = (w / self.weight_quantum).floor() as i64;
|
||||
if q == 0 && w > 0.0 {
|
||||
1
|
||||
} else {
|
||||
q
|
||||
}
|
||||
}
|
||||
|
||||
/// Update the guard with this cycle's mesh: `nodes` are the contributing
|
||||
/// node ids and `coupling(i, j)` returns the fusion coupling between
|
||||
/// `nodes[i]` and `nodes[j]` (symmetric, ≥ 0).
|
||||
///
|
||||
/// Returns `None` for meshes of fewer than 2 nodes (no cut exists).
|
||||
pub fn update(
|
||||
&mut self,
|
||||
nodes: &[u8],
|
||||
coupling: impl Fn(usize, usize) -> f64,
|
||||
) -> Option<MeshPartitionReport> {
|
||||
if nodes.len() < 2 {
|
||||
// Mesh degenerated: drop state so a later rebuild starts clean.
|
||||
self.mincut = None;
|
||||
self.nodes.clear();
|
||||
self.edges.clear();
|
||||
return None;
|
||||
}
|
||||
let mut sorted: Vec<u8> = nodes.to_vec();
|
||||
sorted.sort_unstable();
|
||||
sorted.dedup();
|
||||
|
||||
// Desired quantized edge set for this cycle.
|
||||
let mut desired: BTreeMap<(u8, u8), i64> = BTreeMap::new();
|
||||
for i in 0..nodes.len() {
|
||||
for j in (i + 1)..nodes.len() {
|
||||
let (a, b) = if nodes[i] < nodes[j] {
|
||||
(nodes[i], nodes[j])
|
||||
} else {
|
||||
(nodes[j], nodes[i])
|
||||
};
|
||||
if a == b {
|
||||
continue;
|
||||
}
|
||||
let q = self.quantize(coupling(i, j));
|
||||
desired.insert((a, b), q);
|
||||
}
|
||||
}
|
||||
|
||||
// Change detection: count quantized-weight moves vs the installed set.
|
||||
let changed = if self.mincut.is_none() || self.nodes != sorted {
|
||||
usize::MAX // node set changed / first cycle: rebuild unconditionally
|
||||
} else {
|
||||
desired
|
||||
.iter()
|
||||
.filter(|(k, &q)| self.edges.get(k).copied().unwrap_or(0) != q)
|
||||
.count()
|
||||
};
|
||||
|
||||
let mut updates = 0usize;
|
||||
if changed > 0 {
|
||||
// Measured policy (criterion, 12-node mesh): a full exact rebuild
|
||||
// is ~170 µs while ONE DynamicMinCut delete+insert is ~240 µs —
|
||||
// the incremental machinery's overheads target much larger graphs.
|
||||
// At mesh scale the optimum is: change-gate aggressively (the
|
||||
// steady state below is ~7 µs and covers almost every cycle) and
|
||||
// rebuild whenever anything actually moved.
|
||||
let edges: Vec<(u64, u64, f64)> = desired
|
||||
.iter()
|
||||
.filter(|(_, &q)| q > 0)
|
||||
.map(|(&(a, b), &q)| {
|
||||
(u64::from(a), u64::from(b), q as f64 * self.weight_quantum)
|
||||
})
|
||||
.collect();
|
||||
updates = if changed == usize::MAX { edges.len() } else { changed };
|
||||
self.mincut = MinCutBuilder::new().exact().with_edges(edges).build().ok();
|
||||
self.nodes = sorted;
|
||||
self.edges = desired;
|
||||
}
|
||||
// changed == 0: steady state — zero graph work, cached cut reused.
|
||||
|
||||
// Nodes with no positive coupling never enter the cut structure (zero
|
||||
// edges are not installed) — they are already partitioned. Report them
|
||||
// as the degenerate cut before consulting the structure.
|
||||
let mut isolated: Vec<u8> = self
|
||||
.nodes
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|&v| {
|
||||
!self
|
||||
.edges
|
||||
.iter()
|
||||
.any(|(&(a, b), &q)| q > 0 && (a == v || b == v))
|
||||
})
|
||||
.collect();
|
||||
if !isolated.is_empty() {
|
||||
isolated.sort_unstable();
|
||||
return Some(MeshPartitionReport {
|
||||
cut_value: 0.0,
|
||||
at_risk: self.nodes.len() >= self.min_nodes,
|
||||
weak_side: isolated,
|
||||
updates_applied: updates,
|
||||
});
|
||||
}
|
||||
|
||||
let mc = self.mincut.as_ref()?;
|
||||
// A disconnected coupling graph is the degenerate cut: value 0.
|
||||
let cut_value = if mc.is_connected() { mc.min_cut_value() } else { 0.0 };
|
||||
let (side_a, side_b) = mc.partition();
|
||||
let weak_raw = if side_a.len() <= side_b.len() { side_a } else { side_b };
|
||||
let mut weak_side: Vec<u8> = weak_raw.into_iter().map(|v| v as u8).collect();
|
||||
weak_side.sort_unstable();
|
||||
let at_risk = self.nodes.len() >= self.min_nodes && cut_value <= self.risk_threshold;
|
||||
|
||||
Some(MeshPartitionReport { cut_value, at_risk, weak_side, updates_applied: updates })
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Triangle with one weakly-attached node: the cut isolates that node and
|
||||
/// the cut value equals its total coupling.
|
||||
#[test]
|
||||
fn weakly_attached_node_is_the_weak_side() {
|
||||
let mut g = MeshGuard::default();
|
||||
let nodes = [0u8, 1, 2];
|
||||
// 0–1 strongly coupled; node 2 hangs on by 0.05 + 0.05.
|
||||
let w = |i: usize, j: usize| match (i.min(j), i.max(j)) {
|
||||
(0, 1) => 1.0,
|
||||
_ => 0.05,
|
||||
};
|
||||
let r = g.update(&nodes, w).expect("3-node mesh");
|
||||
assert!(r.cut_value <= 0.13, "cut {} should be ~0.10", r.cut_value);
|
||||
assert_eq!(r.weak_side, vec![2]);
|
||||
assert!(r.at_risk, "weak coupling must flag partition risk");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strong_mesh_is_not_at_risk() {
|
||||
let mut g = MeshGuard::default();
|
||||
let r = g.update(&[0, 1, 2, 3], |_, _| 0.9).expect("mesh");
|
||||
assert!(r.cut_value > g.risk_threshold);
|
||||
assert!(!r.at_risk);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_node_mesh_reports_but_never_risks() {
|
||||
let mut g = MeshGuard::default();
|
||||
let r = g.update(&[0, 1], |_, _| 0.01).expect("2-node mesh");
|
||||
// Trivial cut exists but min_nodes=3 keeps the flag off.
|
||||
assert!(!r.at_risk);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fewer_than_two_nodes_yields_none() {
|
||||
let mut g = MeshGuard::default();
|
||||
assert!(g.update(&[7], |_, _| 1.0).is_none());
|
||||
assert!(g.update(&[], |_, _| 1.0).is_none());
|
||||
}
|
||||
|
||||
/// The optimization contract: identical weights on the next cycle apply
|
||||
/// zero updates; a sub-quantum wiggle also applies zero; a real change
|
||||
/// applies exactly the changed edges.
|
||||
#[test]
|
||||
fn steady_state_applies_zero_updates() {
|
||||
let mut g = MeshGuard::default();
|
||||
let nodes = [0u8, 1, 2, 3];
|
||||
let first = g.update(&nodes, |_, _| 0.5).unwrap();
|
||||
assert_eq!(first.updates_applied, 6); // cold build installs all edges
|
||||
|
||||
let second = g.update(&nodes, |_, _| 0.5).unwrap();
|
||||
assert_eq!(second.updates_applied, 0);
|
||||
|
||||
// Sub-quantum jitter (quantum is 1/64 ≈ 0.0156) is gated out.
|
||||
let third = g.update(&nodes, |_, _| 0.5 + 0.004).unwrap();
|
||||
assert_eq!(third.updates_applied, 0);
|
||||
|
||||
// One genuinely changed edge touches exactly one edge.
|
||||
let fourth = g
|
||||
.update(&nodes, |i, j| if (i.min(j), i.max(j)) == (0, 1) { 0.1 } else { 0.5 })
|
||||
.unwrap();
|
||||
assert_eq!(fourth.updates_applied, 1);
|
||||
}
|
||||
|
||||
/// Node set changes force a clean rebuild (drop/join handled correctly).
|
||||
#[test]
|
||||
fn node_join_and_drop_rebuild() {
|
||||
let mut g = MeshGuard::default();
|
||||
g.update(&[0, 1, 2], |_, _| 0.8).unwrap();
|
||||
// Node 3 joins.
|
||||
let joined = g.update(&[0, 1, 2, 3], |_, _| 0.8).unwrap();
|
||||
assert_eq!(joined.updates_applied, 6); // rebuild over 4 nodes
|
||||
// Node 0 drops.
|
||||
let dropped = g.update(&[1, 2, 3], |_, _| 0.8).unwrap();
|
||||
assert_eq!(dropped.updates_applied, 3);
|
||||
assert!(!dropped.at_risk);
|
||||
}
|
||||
|
||||
/// Determinism: same inputs, same report (cut value + weak side).
|
||||
#[test]
|
||||
fn reports_are_deterministic() {
|
||||
let run = || {
|
||||
let mut g = MeshGuard::default();
|
||||
let w = |i: usize, j: usize| match (i.min(j), i.max(j)) {
|
||||
(0, 1) => 0.9,
|
||||
(1, 2) => 0.6,
|
||||
_ => 0.07,
|
||||
};
|
||||
g.update(&[0, 1, 2], w).unwrap()
|
||||
};
|
||||
let a = run();
|
||||
let b = run();
|
||||
assert_eq!(a.cut_value.to_bits(), b.cut_value.to_bits());
|
||||
assert_eq!(a.weak_side, b.weak_side);
|
||||
}
|
||||
|
||||
/// Regression (review finding 3): a balanced mesh of ≥ 65 nodes has every
|
||||
/// pairwise coupling at ~1/n < quantum (1/64). The old floor-to-zero
|
||||
/// quantization erased all edges and reported the mesh permanently
|
||||
/// "already partitioned" (cut 0, at_risk). Nonzero sub-quantum couplings
|
||||
/// now saturate to one quantum, so the mesh reports a healthy cut.
|
||||
#[test]
|
||||
fn large_balanced_mesh_is_not_at_risk() {
|
||||
let mut g = MeshGuard::default();
|
||||
let nodes: Vec<u8> = (0..70u8).collect();
|
||||
// Attention-weight product coupling: (1/n)·(1/n)·n = 1/n ≈ 0.0143 < 1/64.
|
||||
let n = nodes.len() as f64;
|
||||
let r = g.update(&nodes, |_, _| 1.0 / n).expect("70-node mesh");
|
||||
assert!(
|
||||
r.cut_value > 0.0,
|
||||
"live couplings must not quantize to zero"
|
||||
);
|
||||
// Min cut isolates one node: 69 edges × one quantum (1/64) ≈ 1.08,
|
||||
// well above the 0.25 default risk threshold.
|
||||
assert!(r.cut_value > g.risk_threshold);
|
||||
assert!(
|
||||
!r.at_risk,
|
||||
"balanced large mesh must not be at partition risk"
|
||||
);
|
||||
assert!(r.weak_side.len() < nodes.len(), "no false full partition");
|
||||
}
|
||||
|
||||
/// Sub-quantum couplings saturate to one quantum but exact zero is still a
|
||||
/// real partition (the floor must not invent couplings).
|
||||
#[test]
|
||||
fn sub_quantum_saturates_but_zero_stays_zero() {
|
||||
let mut g = MeshGuard::default();
|
||||
// 0.001 < 1/64 everywhere: connected, tiny cut, flagged at risk
|
||||
// (cut = 2 × 1/64 ≈ 0.031 ≤ 0.25) — but NOT "already partitioned".
|
||||
let r = g.update(&[0, 1, 2], |_, _| 0.001).expect("mesh");
|
||||
assert!(r.cut_value > 0.0);
|
||||
assert!(r.at_risk);
|
||||
// Exact zero to node 2: degenerate cut 0, node 2 isolated.
|
||||
let mut g2 = MeshGuard::default();
|
||||
let r2 = g2
|
||||
.update(&[0, 1, 2], |i, j| if i == 2 || j == 2 { 0.0 } else { 0.5 })
|
||||
.expect("mesh");
|
||||
assert_eq!(r2.cut_value, 0.0);
|
||||
assert_eq!(r2.weak_side, vec![2]);
|
||||
}
|
||||
|
||||
/// A fully partitioned mesh (zero coupling to one node) reports cut 0.
|
||||
#[test]
|
||||
fn disconnected_mesh_is_cut_zero() {
|
||||
let mut g = MeshGuard::default();
|
||||
let w = |i: usize, j: usize| {
|
||||
if i == 2 || j == 2 { 0.0 } else { 0.9 }
|
||||
};
|
||||
let r = g.update(&[0, 1, 2], w).unwrap();
|
||||
assert_eq!(r.cut_value, 0.0);
|
||||
assert!(r.at_risk);
|
||||
assert_eq!(r.weak_side, vec![2]);
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,8 @@
|
||||
//! 12 4 Sequence number (LE u32)
|
||||
//! 16 1 RSSI (i8)
|
||||
//! 17 1 Noise floor (i8)
|
||||
//! 18 2 Reserved
|
||||
//! 18 1 PPDU type (ADR-110: 0=HT/legacy, 1=HE-SU, 2=HE-MU, 3=HE-TB)
|
||||
//! 19 1 Flags (ADR-110: bit0 bw40, bit2 STBC, bit3 LDPC, bit4 15.4-sync)
|
||||
//! 20 N*2 I/Q pairs (n_antennas * n_subcarriers * 2 bytes)
|
||||
//! ```
|
||||
//!
|
||||
@@ -240,12 +241,31 @@ impl Esp32CsiParser {
|
||||
}
|
||||
}
|
||||
|
||||
// Determine bandwidth from subcarrier count
|
||||
let bandwidth = match n_subcarriers {
|
||||
0..=56 => Bandwidth::Bw20,
|
||||
57..=114 => Bandwidth::Bw40,
|
||||
115..=242 => Bandwidth::Bw80,
|
||||
_ => Bandwidth::Bw160,
|
||||
// Determine bandwidth from PPDU type + subcarrier count (ADR-110).
|
||||
//
|
||||
// HE-LTF uses a 4x denser tone grid than HT-LTF on the same channel
|
||||
// width: HE20 = 256-FFT (242 active tones), HE40 = 512-FFT (484
|
||||
// active). So a 256-bin frame on an HE PPDU is *20 MHz*, not 160.
|
||||
// For HE frames the firmware also writes the bandwidth into byte 19
|
||||
// bit 0 (see Adr018Flags::bw40) — prefer that when set.
|
||||
//
|
||||
// HT/legacy keeps the count heuristic, with 64 included in the 20 MHz
|
||||
// bucket: ESP32 HT20 CSI delivers the full 64-bin FFT grid (live
|
||||
// capture evidence: 148-byte frames = 64 subcarriers on a 20 MHz
|
||||
// channel, issue #1005).
|
||||
let bandwidth = if ppdu_type.is_he() {
|
||||
if adr018_flags.bw40 || n_subcarriers > 256 {
|
||||
Bandwidth::Bw40
|
||||
} else {
|
||||
Bandwidth::Bw20
|
||||
}
|
||||
} else {
|
||||
match n_subcarriers {
|
||||
0..=64 => Bandwidth::Bw20,
|
||||
65..=128 => Bandwidth::Bw40,
|
||||
129..=242 => Bandwidth::Bw80,
|
||||
_ => Bandwidth::Bw160,
|
||||
}
|
||||
};
|
||||
|
||||
let frame = CsiFrame {
|
||||
|
||||
@@ -49,7 +49,9 @@ pub mod sync_packet;
|
||||
pub mod radio_ops;
|
||||
|
||||
pub use bridge::CsiData;
|
||||
pub use csi_frame::{AntennaConfig, Bandwidth, CsiFrame, CsiMetadata, SubcarrierData};
|
||||
pub use csi_frame::{
|
||||
Adr018Flags, AntennaConfig, Bandwidth, CsiFrame, CsiMetadata, PpduType, SubcarrierData,
|
||||
};
|
||||
pub use error::ParseError;
|
||||
pub use esp32_parser::{
|
||||
ruview_sibling_packet_name, Esp32CsiParser, ESP32_CSI_MAGIC, RUVIEW_COMPRESSED_CSI_MAGIC,
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
//! ADR-110 / issue #1005: real ESP32-C6 HE-LTF CSI frames captured live.
|
||||
//!
|
||||
//! Both fixtures below are verbatim UDP payloads captured on 2026-06-11 from
|
||||
//! an ESP32-C6 (node_id 12, IDF v5.5 build) streaming to UDP :5005 — the
|
||||
//! same node, same link, seconds apart. The 532-byte frame is an HE-SU
|
||||
//! capture (256 subcarrier bins = 242 active HE20 tones); the 148-byte frame
|
||||
//! is the HT fallback grid (64 bins) the same firmware emits for non-HE
|
||||
//! traffic. They are the canonical regression fixtures for the non-fixed
|
||||
//! subcarrier count introduced by HE-LTF.
|
||||
|
||||
use wifi_densepose_hardware::{Bandwidth, Esp32CsiParser, PpduType};
|
||||
|
||||
/// 532-byte HE-SU frame: header + 256 subcarrier I/Q pairs.
|
||||
/// magic=0xC5110001 node=12 ant=1 nsub=256 freq=2432 seq=11610
|
||||
/// rssi=-40 noise=-87 byte18=0x01 (HE-SU) byte19=0x10 (15.4-sync valid)
|
||||
const HE_FRAME_HEX: &str = "010011c50c010001800900005a2d0000d8a9011000000000000000000000f70ef70ef50cf30bf209f108f006ef03ee02ee00eefdeffbeff8f0f7f1f4f2f3f4f1f5f0f7eef8edfaecfdecffeb01ea03ea05e908ea0aeb0deb0fec11ee13f015f216f318f519f71afa1bfd1bff1c021c051b071b0a1a0c190f1811161315161218101a0e1b0c1c091d071e041f0120ff20fc20f91ff71ff41ef11def1cec1be919e717e615e413e311e10edf0cde09dd06dc04dc01dcffdcfbdcf9ddf6def3dff0e0ede2eae4e8e6e6e8e4eae2ebe0eedef1dcf4dbf7dafad9fdd900d903d806d909d90cda0fdc12dc14dd17df1ae11ce31ee520e722e924ed25f127f328f629f929fd2900290329062809270c260e26122516061a00001c201c1f1a211722142411250e260c27082804280129fe29fb28f927f627f426f125ef23ec22ea20e81eea20e81e891b53a82951565d4ffafbfebe9abddb10222aa47b3b371fd2c0860cd4d86ea2f35faccd46b0b66f6ff0050f2da27d1c92f7f8e1017cb545afd3e3fe60db6f478dc85a33b3454cf6df9061194a0a0fc3e0eedf76f1d292cb25c8f541dfcc4109f9f1a34955520ad8ffa3694ac395cbf6c19073a4aefb1ebf47c76730458431805d9f18ff2e81955e8752b29757f66e289f72f8e35309a737547c040444cbda1a81d221d950037ec38fd9d1dd0f56c3dc707a7bbfe66ca5a97ab7cc17d68d38ba43a1806f91f5911a5967e2c9f7f07186";
|
||||
|
||||
/// 148-byte HT frame from the same node: header + 64 subcarrier I/Q pairs.
|
||||
/// magic=0xC5110001 node=12 ant=1 nsub=64 freq=2432 seq=11622
|
||||
/// rssi=-79 noise=-87 byte18=0x00 (HT/legacy) byte19=0x10
|
||||
const HT_FRAME_HEX: &str = "010011c50c01400080090000662d0000b1a900100000000000000000fcfaf909f013f112f213f212f311f410f511f510f610f510f411f410f411f312f213f214f214f212f313f513f512f611f610f80ef90df90c0000010eff11fe13ff11fe1300000000ff01000001010002000200020204000301040103000400040002ff03ff03fe02fe02fe01fd00edfc03fa000000000000";
|
||||
|
||||
fn unhex(s: &str) -> Vec<u8> {
|
||||
(0..s.len())
|
||||
.step_by(2)
|
||||
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_he_su_frame_532_bytes_parses_with_256_subcarriers() {
|
||||
let data = unhex(HE_FRAME_HEX);
|
||||
assert_eq!(data.len(), 532);
|
||||
|
||||
let (frame, consumed) = Esp32CsiParser::parse_frame(&data).expect("HE frame must parse");
|
||||
assert_eq!(consumed, 532);
|
||||
assert_eq!(frame.metadata.node_id, 12);
|
||||
assert_eq!(frame.metadata.n_antennas, 1);
|
||||
assert_eq!(frame.metadata.n_subcarriers, 256);
|
||||
assert_eq!(frame.subcarrier_count(), 256);
|
||||
assert_eq!(frame.metadata.channel_freq_mhz, 2432);
|
||||
assert_eq!(frame.metadata.sequence, 11610);
|
||||
assert_eq!(frame.metadata.rssi_dbm, -40);
|
||||
assert_eq!(frame.metadata.noise_floor_dbm, -87);
|
||||
// ADR-110 byte 18: HE-SU PPDU. Byte 19 bit 4: ESP-NOW time-sync valid.
|
||||
assert_eq!(frame.metadata.ppdu_type, PpduType::HeSu);
|
||||
assert!(frame.metadata.ppdu_type.is_he());
|
||||
assert!(frame.metadata.adr018_flags.ieee802154_sync_valid);
|
||||
assert!(!frame.metadata.adr018_flags.bw40);
|
||||
// 256-FFT HE-LTF on a 20 MHz channel — NOT 160 MHz.
|
||||
assert_eq!(frame.metadata.bandwidth, Bandwidth::Bw20);
|
||||
assert!(frame.is_valid());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_ht_frame_148_bytes_parses_with_64_subcarriers() {
|
||||
let data = unhex(HT_FRAME_HEX);
|
||||
assert_eq!(data.len(), 148);
|
||||
|
||||
let (frame, consumed) = Esp32CsiParser::parse_frame(&data).expect("HT frame must parse");
|
||||
assert_eq!(consumed, 148);
|
||||
assert_eq!(frame.metadata.node_id, 12);
|
||||
assert_eq!(frame.metadata.n_subcarriers, 64);
|
||||
assert_eq!(frame.metadata.channel_freq_mhz, 2432);
|
||||
assert_eq!(frame.metadata.sequence, 11622);
|
||||
assert_eq!(frame.metadata.rssi_dbm, -79);
|
||||
assert_eq!(frame.metadata.noise_floor_dbm, -87);
|
||||
assert_eq!(frame.metadata.ppdu_type, PpduType::HtLegacy);
|
||||
assert!(!frame.metadata.ppdu_type.is_he());
|
||||
// 64-bin full HT20 FFT grid on a 20 MHz channel — NOT 40 MHz.
|
||||
assert_eq!(frame.metadata.bandwidth, Bandwidth::Bw20);
|
||||
assert!(frame.is_valid());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_interleaved_stream_parses_both_grids() {
|
||||
// The live node interleaves HE (84%) and HT (16%) frames on one socket.
|
||||
let mut stream = unhex(HE_FRAME_HEX);
|
||||
stream.extend_from_slice(&unhex(HT_FRAME_HEX));
|
||||
stream.extend_from_slice(&unhex(HE_FRAME_HEX));
|
||||
|
||||
let (frames, consumed) = Esp32CsiParser::parse_stream(&stream);
|
||||
assert_eq!(frames.len(), 3);
|
||||
assert_eq!(consumed, 532 + 148 + 532);
|
||||
assert_eq!(frames[0].metadata.n_subcarriers, 256);
|
||||
assert_eq!(frames[1].metadata.n_subcarriers, 64);
|
||||
assert_eq!(frames[2].metadata.n_subcarriers, 256);
|
||||
assert_eq!(frames[0].metadata.ppdu_type, PpduType::HeSu);
|
||||
assert_eq!(frames[1].metadata.ppdu_type, PpduType::HtLegacy);
|
||||
}
|
||||
@@ -15,7 +15,12 @@ readme = "README.md"
|
||||
default = ["std", "api", "ruvector"]
|
||||
ruvector = ["dep:ruvector-solver", "dep:ruvector-temporal-tensor"]
|
||||
std = []
|
||||
api = ["dep:serde", "chrono/serde", "geo/use-serde"]
|
||||
# REST/WebSocket surface. Pulls the web stack (axum, futures-util) only when
|
||||
# enabled, and enables the `serde` FEATURE (not just `dep:serde`) so the
|
||||
# `cfg_attr(feature = "serde", ...)` derives on domain types are actually
|
||||
# active when the API is on (review finding 5: `api = ["dep:serde"]` enabled
|
||||
# the dependency but left every `feature = "serde"` cfg dead).
|
||||
api = ["serde", "dep:axum", "dep:futures-util"]
|
||||
portable = ["low-power"]
|
||||
low-power = []
|
||||
distributed = ["tokio/sync"]
|
||||
@@ -30,13 +35,15 @@ wifi-densepose-nn = { version = "0.3.0", path = "../wifi-densepose-nn" }
|
||||
ruvector-solver = { workspace = true, optional = true }
|
||||
ruvector-temporal-tensor = { workspace = true, optional = true }
|
||||
|
||||
# Async runtime
|
||||
# Async runtime — required by the core integration layer (UDP CSI receiver,
|
||||
# hardware adapter, scan loop in `DisasterResponse::start_scanning`), not just
|
||||
# the REST API, so it is deliberately NOT gated behind `api`.
|
||||
tokio = { version = "1.35", features = ["rt", "sync", "time"] }
|
||||
async-trait = "0.1"
|
||||
|
||||
# Web framework (REST API)
|
||||
axum = { version = "0.7", features = ["ws"] }
|
||||
futures-util = "0.3"
|
||||
# Web framework (REST API) — only compiled with the `api` feature.
|
||||
axum = { version = "0.7", features = ["ws"], optional = true }
|
||||
futures-util = { version = "0.3", optional = true }
|
||||
|
||||
# Error handling
|
||||
thiserror = "2.0"
|
||||
|
||||
@@ -78,6 +78,10 @@
|
||||
#![warn(rustdoc::missing_crate_level_docs)]
|
||||
|
||||
pub mod alerting;
|
||||
/// REST API surface (Axum). Requires the `api` feature — its DTOs derive
|
||||
/// serde, which is an optional dependency gated behind that feature.
|
||||
#[cfg(feature = "api")]
|
||||
#[cfg_attr(docsrs, doc(cfg(feature = "api")))]
|
||||
pub mod api;
|
||||
pub mod detection;
|
||||
pub mod domain;
|
||||
@@ -122,6 +126,8 @@ pub use integration::{
|
||||
AdapterError, HardwareAdapter, IntegrationConfig, NeuralAdapter, SignalAdapter,
|
||||
};
|
||||
|
||||
#[cfg(feature = "api")]
|
||||
#[cfg_attr(docsrs, doc(cfg(feature = "api")))]
|
||||
pub use api::{create_router, AppState};
|
||||
|
||||
pub use ml::{
|
||||
|
||||
@@ -53,6 +53,16 @@ wifi-densepose-signal = { version = "0.3.1", path = "../wifi-densepose-signal",
|
||||
# Hardware crate — SyncPacket decoder for ADR-110 §A0.12 mesh-aligned timestamps.
|
||||
wifi-densepose-hardware = { version = "0.3.0", path = "../wifi-densepose-hardware" }
|
||||
|
||||
# Governed streaming engine (ADR-135..146): fusion + privacy demotion +
|
||||
# WorldGraph belief + deterministic witness. The live server data runs through
|
||||
# this as a governed path whose Restricted-class decision strips per-node raw
|
||||
# amplitudes from the live publish; full output gating is a tracked follow-up —
|
||||
# see engine_bridge.rs ("Honest scope of the live-path governance").
|
||||
wifi-densepose-engine = { version = "0.3.0", path = "../wifi-densepose-engine" }
|
||||
wifi-densepose-worldgraph = { version = "0.3.0", path = "../wifi-densepose-worldgraph" }
|
||||
wifi-densepose-bfld = { version = "0.3.1", path = "../wifi-densepose-bfld", default-features = false }
|
||||
wifi-densepose-geo = { version = "0.1.0", path = "../wifi-densepose-geo" }
|
||||
|
||||
# midstream — real-time introspection / low-latency tap (ADR-099 D1).
|
||||
# Two crates only, on purpose: scheduler / neural-solver / strange-loop are
|
||||
# explicitly out of scope of ADR-099 (D5).
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
use ruvector_mincut::{DynamicMinCut, MinCutBuilder};
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use wifi_densepose_hardware::PpduType;
|
||||
|
||||
use crate::adaptive_classifier;
|
||||
use crate::types::*;
|
||||
@@ -45,13 +46,14 @@ pub fn parse_esp32_vitals(buf: &[u8]) -> Option<Esp32VitalsPacket> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse a WASM output packet (magic 0xC511_0004).
|
||||
/// Parse a WASM output packet (magic 0xC511_0007 — reassigned per issue #928;
|
||||
/// the original 0xC511_0004 collided with ADR-063 fused vitals).
|
||||
pub fn parse_wasm_output(buf: &[u8]) -> Option<WasmOutputPacket> {
|
||||
if buf.len() < 8 {
|
||||
return None;
|
||||
}
|
||||
let magic = u32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]);
|
||||
if magic != 0xC511_0004 {
|
||||
if magic != 0xC511_0007 {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -83,6 +85,18 @@ pub fn parse_wasm_output(buf: &[u8]) -> Option<WasmOutputPacket> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse an ADR-018 raw CSI frame (magic 0xC511_0001).
|
||||
///
|
||||
/// Header layout (authoritative: firmware `csi_collector.c` / ADR-018):
|
||||
/// magic u32 LE @0, node_id u8 @4, n_antennas u8 @5, n_subcarriers u16 LE
|
||||
/// @6-7, freq_mhz u32 LE @8-11, sequence u32 LE @12-15, rssi i8 @16,
|
||||
/// noise_floor i8 @17, PPDU type u8 @18 (ADR-110), flags u8 @19 (ADR-110),
|
||||
/// I/Q pairs from @20.
|
||||
///
|
||||
/// Until issue #1005 this function read `n_subcarriers` from byte 6 alone
|
||||
/// (an ESP32-C6 HE-SU frame's 256 = 0x0100 LE decoded as 0 — the frame
|
||||
/// parsed "successfully" with zero subcarriers) and read sequence/rssi/
|
||||
/// noise at stale offsets 10/14/15 (rssi landed on sequence bytes ⇒ 0).
|
||||
pub fn parse_esp32_frame(buf: &[u8]) -> Option<Esp32Frame> {
|
||||
if buf.len() < 20 {
|
||||
return None;
|
||||
@@ -94,16 +108,18 @@ pub fn parse_esp32_frame(buf: &[u8]) -> Option<Esp32Frame> {
|
||||
|
||||
let node_id = buf[4];
|
||||
let n_antennas = buf[5];
|
||||
let n_subcarriers = buf[6];
|
||||
let freq_mhz = u16::from_le_bytes([buf[8], buf[9]]);
|
||||
let sequence = u32::from_le_bytes([buf[10], buf[11], buf[12], buf[13]]);
|
||||
let rssi_raw = buf[14] as i8;
|
||||
let n_subcarriers = u16::from_le_bytes([buf[6], buf[7]]);
|
||||
let freq_mhz_u32 = u32::from_le_bytes([buf[8], buf[9], buf[10], buf[11]]);
|
||||
let freq_mhz = u16::try_from(freq_mhz_u32).unwrap_or(0);
|
||||
let sequence = u32::from_le_bytes([buf[12], buf[13], buf[14], buf[15]]);
|
||||
let rssi_raw = buf[16] as i8;
|
||||
let rssi = if rssi_raw > 0 {
|
||||
rssi_raw.saturating_neg()
|
||||
} else {
|
||||
rssi_raw
|
||||
};
|
||||
let noise_floor = buf[15] as i8;
|
||||
let noise_floor = buf[17] as i8;
|
||||
let ppdu_type = PpduType::from_byte(buf[18]);
|
||||
|
||||
let iq_start = 20;
|
||||
let n_pairs = n_antennas as usize * n_subcarriers as usize;
|
||||
@@ -130,6 +146,7 @@ pub fn parse_esp32_frame(buf: &[u8]) -> Option<Esp32Frame> {
|
||||
sequence,
|
||||
rssi,
|
||||
noise_floor,
|
||||
ppdu_type,
|
||||
amplitudes,
|
||||
phases,
|
||||
})
|
||||
@@ -963,11 +980,12 @@ pub fn generate_simulated_frame(tick: u64) -> Esp32Frame {
|
||||
magic: 0xC511_0001,
|
||||
node_id: 1,
|
||||
n_antennas: 1,
|
||||
n_subcarriers: n_sub as u8,
|
||||
n_subcarriers: n_sub as u16,
|
||||
freq_mhz: 2437,
|
||||
sequence: tick as u32,
|
||||
rssi: (-40.0 + 5.0 * (t * 0.2).sin()) as i8,
|
||||
noise_floor: -90,
|
||||
ppdu_type: PpduType::HtLegacy,
|
||||
amplitudes,
|
||||
phases,
|
||||
}
|
||||
@@ -980,3 +998,76 @@ pub fn chrono_timestamp() -> u64 {
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
// ── ADR-110 / issue #1005 tests: live ESP32-C6 HE-LTF frames ────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod adr110_tests {
|
||||
use super::*;
|
||||
use crate::types::NodeState;
|
||||
|
||||
/// Verbatim 532-byte HE-SU UDP payload captured live 2026-06-11 from an
|
||||
/// ESP32-C6 (node 12, IDF v5.5): 256 subcarrier bins, byte18=0x01.
|
||||
const HE_FRAME_HEX: &str = "010011c50c010001800900005a2d0000d8a9011000000000000000000000f70ef70ef50cf30bf209f108f006ef03ee02ee00eefdeffbeff8f0f7f1f4f2f3f4f1f5f0f7eef8edfaecfdecffeb01ea03ea05e908ea0aeb0deb0fec11ee13f015f216f318f519f71afa1bfd1bff1c021c051b071b0a1a0c190f1811161315161218101a0e1b0c1c091d071e041f0120ff20fc20f91ff71ff41ef11def1cec1be919e717e615e413e311e10edf0cde09dd06dc04dc01dcffdcfbdcf9ddf6def3dff0e0ede2eae4e8e6e6e8e4eae2ebe0eedef1dcf4dbf7dafad9fdd900d903d806d909d90cda0fdc12dc14dd17df1ae11ce31ee520e722e924ed25f127f328f629f929fd2900290329062809270c260e26122516061a00001c201c1f1a211722142411250e260c27082804280129fe29fb28f927f627f426f125ef23ec22ea20e81eea20e81e891b53a82951565d4ffafbfebe9abddb10222aa47b3b371fd2c0860cd4d86ea2f35faccd46b0b66f6ff0050f2da27d1c92f7f8e1017cb545afd3e3fe60db6f478dc85a33b3454cf6df9061194a0a0fc3e0eedf76f1d292cb25c8f541dfcc4109f9f1a34955520ad8ffa3694ac395cbf6c19073a4aefb1ebf47c76730458431805d9f18ff2e81955e8752b29757f66e289f72f8e35309a737547c040444cbda1a81d221d950037ec38fd9d1dd0f56c3dc707a7bbfe66ca5a97ab7cc17d68d38ba43a1806f91f5911a5967e2c9f7f07186";
|
||||
|
||||
/// Verbatim 148-byte HT payload from the same node seconds later:
|
||||
/// 64 bins, byte18=0x00.
|
||||
const HT_FRAME_HEX: &str = "010011c50c01400080090000662d0000b1a900100000000000000000fcfaf909f013f112f213f212f311f410f511f510f610f510f411f410f411f312f213f214f214f212f313f513f512f611f610f80ef90df90c0000010eff11fe13ff11fe1300000000ff01000001010002000200020204000301040103000400040002ff03ff03fe02fe02fe01fd00edfc03fa000000000000";
|
||||
|
||||
fn unhex(s: &str) -> Vec<u8> {
|
||||
(0..s.len())
|
||||
.step_by(2)
|
||||
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_he_su_frame_parses_with_256_subcarriers() {
|
||||
let buf = unhex(HE_FRAME_HEX);
|
||||
assert_eq!(buf.len(), 532);
|
||||
let f = parse_esp32_frame(&buf).expect("532-byte HE frame must parse");
|
||||
assert_eq!(f.node_id, 12);
|
||||
assert_eq!(f.n_subcarriers, 256);
|
||||
assert_eq!(f.amplitudes.len(), 256);
|
||||
assert_eq!(f.freq_mhz, 2432);
|
||||
assert_eq!(f.sequence, 11610);
|
||||
assert_eq!(f.rssi, -40);
|
||||
assert_eq!(f.noise_floor, -87);
|
||||
assert_eq!(f.ppdu_type, PpduType::HeSu);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_ht_frame_parses_with_64_subcarriers() {
|
||||
let buf = unhex(HT_FRAME_HEX);
|
||||
assert_eq!(buf.len(), 148);
|
||||
let f = parse_esp32_frame(&buf).expect("148-byte HT frame must parse");
|
||||
assert_eq!(f.node_id, 12);
|
||||
assert_eq!(f.n_subcarriers, 64);
|
||||
assert_eq!(f.amplitudes.len(), 64);
|
||||
assert_eq!(f.rssi, -79);
|
||||
assert_eq!(f.ppdu_type, PpduType::HtLegacy);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_gate_never_mixes_ht_and_he_windows() {
|
||||
let he = parse_esp32_frame(&unhex(HE_FRAME_HEX)).unwrap();
|
||||
let ht = parse_esp32_frame(&unhex(HT_FRAME_HEX)).unwrap();
|
||||
let mut ns = NodeState::new();
|
||||
|
||||
// First frame locks the grid.
|
||||
assert!(ns.accept_grid(ht.grid()));
|
||||
ns.frame_history.push_back(ht.amplitudes.clone());
|
||||
|
||||
// HE upgrade: accepted, denser grid wins, history re-keyed.
|
||||
assert!(ns.accept_grid(he.grid()));
|
||||
assert!(ns.frame_history.is_empty(), "upgrade must clear HT history");
|
||||
ns.frame_history.push_back(he.amplitudes.clone());
|
||||
|
||||
// Interleaved HT minority frames are rejected from the feature path.
|
||||
assert!(!ns.accept_grid(ht.grid()));
|
||||
assert_eq!(ns.frame_history.len(), 1, "HT frame must not touch window");
|
||||
|
||||
// Steady-state HE frames keep flowing.
|
||||
assert!(ns.accept_grid(he.grid()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,469 @@
|
||||
//! Live trust-path bridge: drive the governed [`StreamingEngine`] from the
|
||||
//! sensing-server's live `NodeState` map.
|
||||
//!
|
||||
//! `multistatic_bridge.rs` already converts `NodeState` → `MultiBandCsiFrame`
|
||||
//! and runs the *bare* `MultistaticFuser`. That path produces fused amplitudes
|
||||
//! but skips the trust control plane: privacy demotion on contradiction, the
|
||||
//! WorldGraph belief with mandatory provenance, and the deterministic witness
|
||||
//! (ADR-135..146). This bridge routes the same live frames through
|
||||
//! [`StreamingEngine::process_cycle`], so every governed belief carries
|
||||
//! evidence + model + calibration + privacy decision and a BLAKE3 witness
|
||||
//! (narrowing the gap called out in ADR-136 §8 and the beyond-SOTA system
|
||||
//! review).
|
||||
//!
|
||||
//! ## Honest scope of the live-path governance
|
||||
//!
|
||||
//! The engine runs *alongside* the bare fusion path that feeds the live
|
||||
//! `SensingUpdate`; it does not replace it. What the engine's decision **does**
|
||||
//! gate on the live wire today: when a cycle is emitted at
|
||||
//! [`PrivacyClass::Restricted`] (base mode or contradiction/mesh-risk
|
||||
//! demotion), [`EngineBridge::suppress_raw_outputs`] is true and `main.rs`
|
||||
//! strips the per-node raw amplitude vectors from the published update — the
|
||||
//! same field mapping `wifi-densepose-bfld`'s privacy gate applies at
|
||||
//! `Restricted` (drop amplitude/phase proxies). Trust state (latest witness,
|
||||
//! effective class, recalibration flag, engine-error count) is readable on
|
||||
//! `GET /api/v1/status`. Gating of the remaining *derived* outputs
|
||||
//! (person count, classification, signal field) by privacy class is tracked
|
||||
//! as a follow-up; until then those fields are published ungoverned.
|
||||
//!
|
||||
//! Determinism: this module reads server state and forwards explicit
|
||||
//! timestamps/calibration ids; it introduces no wall-clock reads of its own, so
|
||||
//! a given `(frames, calibration, now_ms)` always yields the same
|
||||
//! [`TrustedOutput`] witness.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use wifi_densepose_bfld::{PrivacyClass, PrivacyMode};
|
||||
use wifi_densepose_engine::{AdapterInfo, EngineError, StreamingEngine, TrustedOutput};
|
||||
use wifi_densepose_geo::types::GeoRegistration;
|
||||
use wifi_densepose_signal::ruvsense::fusion_quality::CalibrationId;
|
||||
use wifi_densepose_worldgraph::WorldId;
|
||||
|
||||
use super::multistatic_bridge::node_frames_from_states;
|
||||
use super::NodeState;
|
||||
|
||||
/// Minimum spacing between engine-error warn logs (errors are still counted
|
||||
/// every cycle; only the log line is rate-limited — a 20 Hz loop must not
|
||||
/// emit 20 warns/s).
|
||||
const ENGINE_ERROR_WARN_INTERVAL: Duration = Duration::from_secs(10);
|
||||
|
||||
/// Owns a [`StreamingEngine`] and the WorldGraph scope (one room + sensor) the
|
||||
/// live sensing loop publishes beliefs into.
|
||||
pub struct EngineBridge {
|
||||
engine: StreamingEngine,
|
||||
room: WorldId,
|
||||
/// Nodes already wired into the WorldGraph as sensors (by `node_id`).
|
||||
registered_nodes: HashMap<u8, WorldId>,
|
||||
/// Calibration epoch applied to live frames until the ADR-135 baseline
|
||||
/// stage supplies a real per-node id. Stable so witnesses are reproducible.
|
||||
calibration: CalibrationId,
|
||||
// ── Trust state observed from the most recent cycles (review finding 1:
|
||||
// previously write-only fields on AppState; now recorded here and
|
||||
// exposed via the status endpoint + output gating). ──────────────────
|
||||
/// BLAKE3 witness of the most recent successful governed cycle.
|
||||
last_witness: Option<[u8; 32]>,
|
||||
/// Latest drift→recalibration recommendation (ADR-135 → ADR-150 §3.4).
|
||||
recalibration_recommended: bool,
|
||||
/// Privacy class the most recent cycle was emitted under (post-demotion).
|
||||
effective_class: Option<PrivacyClass>,
|
||||
/// Whether the most recent cycle was demoted (contradiction / mesh risk).
|
||||
demoted: bool,
|
||||
/// Total engine cycles that returned an error (previously swallowed by
|
||||
/// `if let Some(Ok(..))` at the call sites).
|
||||
engine_error_count: u64,
|
||||
/// Last time an engine error was actually logged (rate limiter).
|
||||
last_error_warn_at: Option<Instant>,
|
||||
}
|
||||
|
||||
impl EngineBridge {
|
||||
/// Build a bridge for one installation. `room_area_id`/`room_name` name the
|
||||
/// observation scope; `mode` is the starting privacy mode.
|
||||
pub fn new(mode: PrivacyMode, model_version: u16, room_area_id: &str, room_name: &str) -> Self {
|
||||
let mut engine = StreamingEngine::new(mode, model_version, GeoRegistration::default());
|
||||
let room = engine.add_room(room_area_id, room_name);
|
||||
Self {
|
||||
engine,
|
||||
room,
|
||||
registered_nodes: HashMap::new(),
|
||||
calibration: CalibrationId(0x5256_0001), // "RV\0\x01" — placeholder epoch
|
||||
last_witness: None,
|
||||
recalibration_recommended: false,
|
||||
effective_class: None,
|
||||
demoted: false,
|
||||
engine_error_count: 0,
|
||||
last_error_warn_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Override the calibration epoch stamped onto live frames (ADR-135).
|
||||
pub fn set_calibration(&mut self, calibration: CalibrationId) {
|
||||
self.calibration = calibration;
|
||||
}
|
||||
|
||||
/// Override the WorldGraph belief-retention cap (bounds memory on the live
|
||||
/// loop; see `WorldGraph::prune_semantic_states`).
|
||||
pub fn set_semantic_retention(&mut self, max_states: usize) {
|
||||
self.engine.set_semantic_retention(max_states);
|
||||
}
|
||||
|
||||
/// Switch the active privacy mode (operator/control-plane action).
|
||||
pub fn set_privacy_mode(&mut self, mode: PrivacyMode) {
|
||||
self.engine.set_privacy_mode(mode);
|
||||
}
|
||||
|
||||
/// Activate a per-room calibration adapter (ADR-150 §3.4). The adapter's
|
||||
/// content-derived id becomes part of provenance/witness from the next
|
||||
/// cycle — weights can never swap silently on the live path.
|
||||
pub fn set_room_adapter(&mut self, info: AdapterInfo) {
|
||||
self.engine.set_room_adapter(info);
|
||||
}
|
||||
|
||||
/// Deactivate the per-room adapter (revert to the shared base model).
|
||||
pub fn clear_room_adapter(&mut self) {
|
||||
self.engine.clear_room_adapter();
|
||||
}
|
||||
|
||||
/// Borrow the engine (queries, WorldGraph snapshot, privacy audit).
|
||||
pub fn engine(&self) -> &StreamingEngine {
|
||||
&self.engine
|
||||
}
|
||||
|
||||
/// Number of sensor nodes wired into the WorldGraph so far.
|
||||
pub fn registered_node_count(&self) -> usize {
|
||||
self.registered_nodes.len()
|
||||
}
|
||||
|
||||
/// Run one governed trust cycle over the current live node states.
|
||||
///
|
||||
/// Returns `None` when no active node yields a frame (nothing to fuse —
|
||||
/// the engine is not invoked, so no spurious belief is published). On a
|
||||
/// real cycle it lazily wires any newly-seen node as a WorldGraph sensor,
|
||||
/// then returns the witnessed [`TrustedOutput`] (or a fusion error).
|
||||
///
|
||||
/// `now_ms` is supplied by the caller (the sensing loop's clock), keeping
|
||||
/// the bridge deterministic and replayable.
|
||||
pub fn process_cycle_from_states(
|
||||
&mut self,
|
||||
node_states: &HashMap<u8, NodeState>,
|
||||
now_ms: i64,
|
||||
) -> Option<Result<TrustedOutput, EngineError>> {
|
||||
let frames = node_frames_from_states(node_states);
|
||||
if frames.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// Lazily register each contributing node as a sensor observing the room,
|
||||
// so the privacy rollup can suppress it under identity-strict modes.
|
||||
for f in &frames {
|
||||
self.registered_nodes.entry(f.node_id).or_insert_with(|| {
|
||||
self.engine
|
||||
.add_sensor(&format!("node-{}", f.node_id), self.room)
|
||||
});
|
||||
}
|
||||
Some(
|
||||
self.engine
|
||||
.process_cycle(&frames, self.calibration, self.room, now_ms),
|
||||
)
|
||||
}
|
||||
|
||||
/// Run one governed cycle **and record the trust state** (review finding
|
||||
/// 1): on success the witness / effective class / demotion /
|
||||
/// recalibration flag are stored for the status endpoint and output
|
||||
/// gating; on error the error counter is incremented and a rate-limited
|
||||
/// warning is logged (never silently swallowed). Returns the trusted
|
||||
/// output on success, `None` when there was nothing to fuse or the cycle
|
||||
/// errored.
|
||||
pub fn observe_cycle(
|
||||
&mut self,
|
||||
node_states: &HashMap<u8, NodeState>,
|
||||
now_ms: i64,
|
||||
) -> Option<TrustedOutput> {
|
||||
match self.process_cycle_from_states(node_states, now_ms)? {
|
||||
Ok(trust) => {
|
||||
self.last_witness = Some(trust.witness);
|
||||
self.recalibration_recommended = trust.recalibration_recommended;
|
||||
self.effective_class = Some(trust.effective_class);
|
||||
self.demoted = trust.demoted;
|
||||
Some(trust)
|
||||
}
|
||||
Err(e) => {
|
||||
self.engine_error_count += 1;
|
||||
let now = Instant::now();
|
||||
let warn_due = self.last_error_warn_at.map_or(true, |t| {
|
||||
now.duration_since(t) >= ENGINE_ERROR_WARN_INTERVAL
|
||||
});
|
||||
if warn_due {
|
||||
self.last_error_warn_at = Some(now);
|
||||
tracing::warn!(
|
||||
total_engine_errors = self.engine_error_count,
|
||||
"governed trust cycle failed (warn rate-limited to one per {:?}): {e}",
|
||||
ENGINE_ERROR_WARN_INTERVAL
|
||||
);
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// BLAKE3 witness of the most recent successful governed cycle.
|
||||
pub fn last_trust_witness(&self) -> Option<[u8; 32]> {
|
||||
self.last_witness
|
||||
}
|
||||
|
||||
/// Latest drift→recalibration recommendation from the governed engine.
|
||||
pub fn recalibration_recommended(&self) -> bool {
|
||||
self.recalibration_recommended
|
||||
}
|
||||
|
||||
/// Privacy class the most recent cycle was emitted under (post-demotion);
|
||||
/// `None` until a governed cycle has run.
|
||||
pub fn effective_class(&self) -> Option<PrivacyClass> {
|
||||
self.effective_class
|
||||
}
|
||||
|
||||
/// Whether the most recent cycle was demoted (contradiction / mesh risk).
|
||||
pub fn demoted(&self) -> bool {
|
||||
self.demoted
|
||||
}
|
||||
|
||||
/// Engine cycles that returned an error since startup.
|
||||
pub fn engine_error_count(&self) -> u64 {
|
||||
self.engine_error_count
|
||||
}
|
||||
|
||||
/// ADR-141 output mapping for the live publish path (review finding 1c):
|
||||
/// at effective class [`PrivacyClass::Restricted`] the bfld privacy gate
|
||||
/// drops the amplitude + phase proxies; the live `SensingUpdate` applies
|
||||
/// the same field mapping by suppressing the per-node raw amplitude
|
||||
/// vectors when this returns true. Classes below `Restricted` leave the
|
||||
/// publish unchanged.
|
||||
pub fn suppress_raw_outputs(&self) -> bool {
|
||||
self.effective_class
|
||||
.is_some_and(|c| c.as_u8() >= PrivacyClass::Restricted.as_u8())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::VecDeque;
|
||||
use std::time::Instant;
|
||||
use wifi_densepose_bfld::PrivacyClass;
|
||||
|
||||
fn node_state_with_history(amp: f64, n_sub: usize) -> NodeState {
|
||||
let mut ns = NodeState::new();
|
||||
let frame: Vec<f64> = (0..n_sub).map(|i| amp + 0.1 * i as f64).collect();
|
||||
ns.frame_history = VecDeque::from(vec![frame]);
|
||||
ns.last_frame_time = Some(Instant::now());
|
||||
ns
|
||||
}
|
||||
|
||||
fn two_node_states() -> HashMap<u8, NodeState> {
|
||||
let mut m = HashMap::new();
|
||||
m.insert(0u8, node_state_with_history(1.0, 56));
|
||||
m.insert(1u8, node_state_with_history(1.05, 56));
|
||||
m
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_states_produce_no_belief() {
|
||||
let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "living_room", "Living Room");
|
||||
let out = bridge.process_cycle_from_states(&HashMap::new(), 1_000);
|
||||
assert!(out.is_none());
|
||||
// No belief published, no sensor wired.
|
||||
assert_eq!(bridge.registered_node_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_cycle_produces_witnessed_belief_with_provenance() {
|
||||
let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "living_room", "Living Room");
|
||||
let states = two_node_states();
|
||||
let out = bridge
|
||||
.process_cycle_from_states(&states, 10_000)
|
||||
.expect("frames present")
|
||||
.expect("fusion succeeds");
|
||||
|
||||
// Full provenance: evidence + model + calibration + privacy decision.
|
||||
assert!(!out.provenance.evidence.is_empty());
|
||||
assert_eq!(out.provenance.model_version, "rfenc-v1");
|
||||
assert!(out.provenance.calibration_version.starts_with("cal:"));
|
||||
assert!(out.provenance.privacy_decision.starts_with("PrivateHome/"));
|
||||
// A witness was produced and the belief is in the WorldGraph.
|
||||
assert_ne!(out.witness, [0u8; 32]);
|
||||
assert!(bridge.engine().world().node(out.semantic_id).is_some());
|
||||
// Both nodes are now wired as sensors.
|
||||
assert_eq!(bridge.registered_node_count(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_path_is_deterministic() {
|
||||
let states = two_node_states_fixed();
|
||||
let run = || {
|
||||
let mut b = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R");
|
||||
b.process_cycle_from_states(&states, 5_000).unwrap().unwrap()
|
||||
};
|
||||
let a = run();
|
||||
let b = run();
|
||||
assert_eq!(a.witness, b.witness);
|
||||
assert_eq!(a.provenance.calibration_version, b.provenance.calibration_version);
|
||||
assert_eq!(a.effective_class, b.effective_class);
|
||||
}
|
||||
|
||||
// Deterministic node states (no wall-clock in amplitude/history).
|
||||
fn two_node_states_fixed() -> HashMap<u8, NodeState> {
|
||||
let mut m = HashMap::new();
|
||||
for (id, amp) in [(0u8, 1.0_f64), (1u8, 1.05)] {
|
||||
let mut ns = NodeState::new();
|
||||
ns.frame_history = VecDeque::from(vec![(0..56)
|
||||
.map(|i| amp + 0.1 * i as f64)
|
||||
.collect::<Vec<f64>>()]);
|
||||
ns.last_frame_time = Some(Instant::now());
|
||||
m.insert(id, ns);
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nodes_registered_once_across_cycles() {
|
||||
let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R");
|
||||
let states = two_node_states();
|
||||
bridge.process_cycle_from_states(&states, 1_000);
|
||||
bridge.process_cycle_from_states(&states, 2_000);
|
||||
bridge.process_cycle_from_states(&states, 3_000);
|
||||
// Still exactly two sensors — idempotent registration.
|
||||
assert_eq!(bridge.registered_node_count(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retention_bounds_world_graph_growth() {
|
||||
let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R");
|
||||
bridge.set_semantic_retention(5);
|
||||
let states = two_node_states();
|
||||
for i in 0..20i64 {
|
||||
bridge.process_cycle_from_states(&states, 1_000 + i * 50);
|
||||
}
|
||||
// room + 2 sensors + at most 5 retained beliefs.
|
||||
assert!(bridge.engine().world().node_count() <= 3 + 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adapter_identity_flows_into_live_witness() {
|
||||
let states = two_node_states_fixed();
|
||||
let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R");
|
||||
let base = bridge
|
||||
.process_cycle_from_states(&states, 1_000)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
bridge.set_room_adapter(AdapterInfo {
|
||||
adapter_id: "deadbeefcafef00d".into(),
|
||||
trained_samples: 120,
|
||||
});
|
||||
let adapted = bridge
|
||||
.process_cycle_from_states(&states, 2_000)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(adapted
|
||||
.provenance
|
||||
.model_version
|
||||
.ends_with("+adapter:deadbeefcafef00d"));
|
||||
assert_ne!(adapted.witness, base.witness);
|
||||
// Clearing reverts to the base model identity.
|
||||
bridge.clear_room_adapter();
|
||||
let back = bridge
|
||||
.process_cycle_from_states(&states, 3_000)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(back.provenance.model_version, "rfenc-v1");
|
||||
}
|
||||
|
||||
/// Wiring (review finding 1): a live frame in → trust state recorded on
|
||||
/// the bridge (witness, effective class, recalibration flag), readable by
|
||||
/// the status endpoint, with a zero error count on the happy path.
|
||||
#[test]
|
||||
fn observe_cycle_records_trust_state() {
|
||||
let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R");
|
||||
assert!(bridge.last_trust_witness().is_none());
|
||||
assert_eq!(bridge.effective_class(), None);
|
||||
|
||||
let out = bridge
|
||||
.observe_cycle(&two_node_states(), 1_000)
|
||||
.expect("two fresh nodes → governed cycle runs");
|
||||
|
||||
assert_eq!(bridge.last_trust_witness(), Some(out.witness));
|
||||
assert_eq!(bridge.effective_class(), Some(out.effective_class));
|
||||
assert_eq!(
|
||||
bridge.recalibration_recommended(),
|
||||
out.recalibration_recommended
|
||||
);
|
||||
assert_eq!(bridge.demoted(), out.demoted);
|
||||
assert_eq!(bridge.engine_error_count(), 0);
|
||||
// PrivateHome clean cycle → Anonymous → raw outputs NOT suppressed.
|
||||
assert_eq!(bridge.effective_class(), Some(PrivacyClass::Anonymous));
|
||||
assert!(!bridge.suppress_raw_outputs());
|
||||
}
|
||||
|
||||
/// Error wiring (review finding 1a): two live nodes with mismatched
|
||||
/// subcarrier counts make fusion return a `DimensionMismatch` →
|
||||
/// `EngineError` — previously dropped by `if let Some(Ok(..))` at the
|
||||
/// call sites. The counter must increment and the last good trust state
|
||||
/// must survive a later failure.
|
||||
#[test]
|
||||
fn observe_cycle_counts_engine_errors() {
|
||||
let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R");
|
||||
let mut mismatched = HashMap::new();
|
||||
mismatched.insert(0u8, node_state_with_history(1.0, 56));
|
||||
mismatched.insert(1u8, node_state_with_history(1.05, 30)); // 30 ≠ 56 subcarriers
|
||||
|
||||
assert!(bridge.observe_cycle(&mismatched, 1_000).is_none());
|
||||
assert_eq!(bridge.engine_error_count(), 1);
|
||||
assert!(
|
||||
bridge.last_trust_witness().is_none(),
|
||||
"no witness from a failed cycle"
|
||||
);
|
||||
|
||||
assert!(bridge.observe_cycle(&mismatched, 2_000).is_none());
|
||||
assert_eq!(bridge.engine_error_count(), 2);
|
||||
|
||||
// A later good cycle records trust state; the audit count is kept.
|
||||
let out = bridge.observe_cycle(&two_node_states(), 3_000);
|
||||
assert!(out.is_some());
|
||||
assert!(bridge.last_trust_witness().is_some());
|
||||
assert_eq!(bridge.engine_error_count(), 2);
|
||||
|
||||
// And a subsequent failure keeps the last good witness readable.
|
||||
assert!(bridge.observe_cycle(&mismatched, 4_000).is_none());
|
||||
assert_eq!(bridge.engine_error_count(), 3);
|
||||
assert!(bridge.last_trust_witness().is_some());
|
||||
}
|
||||
|
||||
/// ADR-141 mapping (review finding 1c): a cycle emitted at class
|
||||
/// Restricted flips `suppress_raw_outputs`, which `main.rs` uses to strip
|
||||
/// per-node raw amplitude vectors from the live publish — the same field
|
||||
/// mapping bfld's privacy gate applies at `Restricted`.
|
||||
#[test]
|
||||
fn restricted_class_suppresses_raw_outputs() {
|
||||
let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R");
|
||||
bridge.set_privacy_mode(PrivacyMode::StrictNoIdentity); // base = Restricted
|
||||
bridge
|
||||
.observe_cycle(&two_node_states(), 1_000)
|
||||
.expect("cycle runs");
|
||||
assert_eq!(bridge.effective_class(), Some(PrivacyClass::Restricted));
|
||||
assert!(bridge.suppress_raw_outputs());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_strict_mode_is_carried_into_provenance() {
|
||||
let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R");
|
||||
bridge.set_privacy_mode(PrivacyMode::StrictNoIdentity);
|
||||
let out = bridge
|
||||
.process_cycle_from_states(&two_node_states(), 7_000)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(out.provenance.privacy_decision.starts_with("StrictNoIdentity/"));
|
||||
// Effective class is a valid privacy class (sanity).
|
||||
let _ = matches!(
|
||||
out.effective_class,
|
||||
PrivacyClass::Raw | PrivacyClass::Derived | PrivacyClass::Anonymous | PrivacyClass::Restricted
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@
|
||||
mod adaptive_classifier;
|
||||
pub mod cli;
|
||||
pub mod csi;
|
||||
mod engine_bridge;
|
||||
mod field_bridge;
|
||||
mod multistatic_bridge;
|
||||
pub mod pose;
|
||||
@@ -226,15 +227,28 @@ struct Esp32Frame {
|
||||
magic: u32,
|
||||
node_id: u8,
|
||||
n_antennas: u8,
|
||||
n_subcarriers: u8,
|
||||
/// u16 since ADR-110 / issue #1005: ESP32-C6 HE-SU frames carry 256
|
||||
/// subcarrier bins (242 active HE20 tones). HT frames stay ≤128.
|
||||
n_subcarriers: u16,
|
||||
freq_mhz: u16,
|
||||
sequence: u32,
|
||||
rssi: i8,
|
||||
noise_floor: i8,
|
||||
/// ADR-110 byte 18: PPDU type the CSI was sampled from. Pre-ADR-110
|
||||
/// firmware sends 0 ⇒ `PpduType::HtLegacy`.
|
||||
ppdu_type: wifi_densepose_hardware::PpduType,
|
||||
amplitudes: Vec<f64>,
|
||||
phases: Vec<f64>,
|
||||
}
|
||||
|
||||
impl Esp32Frame {
|
||||
/// The `(n_subcarriers, ppdu_type)` symbol-grid identity of this frame.
|
||||
/// HT-LTF and HE-LTF grids are not bin-comparable (ADR-110 / #1005).
|
||||
fn grid(&self) -> (u16, wifi_densepose_hardware::PpduType) {
|
||||
(self.n_subcarriers, self.ppdu_type)
|
||||
}
|
||||
}
|
||||
|
||||
/// Sensing update broadcast to WebSocket clients
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct SensingUpdate {
|
||||
@@ -442,6 +456,12 @@ struct NodeState {
|
||||
/// Most recent novelty score in [0.0, 1.0] (0 = exact-match in bank,
|
||||
/// 1 = no overlap). Consumed by the model-wake gate downstream.
|
||||
pub(crate) last_novelty_score: Option<f32>,
|
||||
/// ADR-110 / issue #1005: the `(n_subcarriers, ppdu_type)` grid this
|
||||
/// node's rolling windows were built on. ESP32-C6 nodes interleave
|
||||
/// HE-SU 256-bin frames with HT 64-bin frames on one socket; mixing
|
||||
/// the two symbol grids in `frame_history` corrupts variance/baseline
|
||||
/// statistics. See [`NodeState::accept_grid`].
|
||||
active_grid: Option<(u16, wifi_densepose_hardware::PpduType)>,
|
||||
}
|
||||
|
||||
/// Default EMA alpha for temporal keypoint smoothing (RuVector Phase 2).
|
||||
@@ -647,6 +667,35 @@ impl NodeState {
|
||||
),
|
||||
),
|
||||
last_novelty_score: None,
|
||||
active_grid: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// ADR-110 / issue #1005 grid gate: decide whether a frame on `grid`
|
||||
/// may enter this node's feature path, and update `active_grid`.
|
||||
///
|
||||
/// Returns `true` to accept. Policy: lock onto the densest grid seen.
|
||||
/// On a grid *upgrade* (more subcarriers — e.g. the first HE-SU 256-bin
|
||||
/// frame after HT 64-bin history) the rolling amplitude history and
|
||||
/// motion baseline are cleared so HT and HE symbol grids are never
|
||||
/// mixed in one window. Sparser-grid frames (the ~16% HT minority an
|
||||
/// ESP32-C6 keeps emitting alongside HE) are rejected from the feature
|
||||
/// path; the caller still records the arrival for fps/liveness.
|
||||
fn accept_grid(&mut self, grid: (u16, wifi_densepose_hardware::PpduType)) -> bool {
|
||||
match self.active_grid {
|
||||
None => {
|
||||
self.active_grid = Some(grid);
|
||||
true
|
||||
}
|
||||
Some(active) if active == grid => true,
|
||||
Some((active_n, _)) if grid.0 > active_n => {
|
||||
self.active_grid = Some(grid);
|
||||
self.frame_history.clear();
|
||||
self.baseline_motion = 0.0;
|
||||
self.baseline_frames = 0;
|
||||
true
|
||||
}
|
||||
Some(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -988,6 +1037,13 @@ struct AppStateInner {
|
||||
last_tracker_instant: Option<std::time::Instant>,
|
||||
/// Attention-weighted multi-node CSI fusion engine.
|
||||
multistatic_fuser: MultistaticFuser,
|
||||
/// Governed trust-path bridge (ADR-135..146): runs the same live frames
|
||||
/// through the privacy/provenance/witness control plane. Does not alter
|
||||
/// person-count behavior; its trust state (witness, effective class,
|
||||
/// recalibration flag, error count) is recorded on the bridge itself and
|
||||
/// exposed via `GET /api/v1/status`, and a Restricted-class cycle strips
|
||||
/// per-node raw amplitudes from the live publish (review finding 1).
|
||||
engine_bridge: engine_bridge::EngineBridge,
|
||||
/// SVD-based room field model for eigenvalue person counting (None until calibration).
|
||||
field_model: Option<FieldModel>,
|
||||
// ── ADR-044 §5.2: adaptive rolling-p95 normalization ─────────────────────
|
||||
@@ -1114,7 +1170,7 @@ fn parse_esp32_vitals(buf: &[u8]) -> Option<Esp32VitalsPacket> {
|
||||
})
|
||||
}
|
||||
|
||||
// ── ADR-040: WASM Output Packet (magic 0xC511_0004) ───────────────────────────
|
||||
// ── ADR-040: WASM Output Packet (magic 0xC511_0007 — reassigned per #928) ─────
|
||||
|
||||
/// Single WASM event (type + value).
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
@@ -1131,13 +1187,14 @@ struct WasmOutputPacket {
|
||||
events: Vec<WasmEvent>,
|
||||
}
|
||||
|
||||
/// Parse a WASM output packet (magic 0xC511_0004).
|
||||
/// Parse a WASM output packet (magic 0xC511_0007 — reassigned per issue #928;
|
||||
/// the original 0xC511_0004 was a collision with ADR-063 fused vitals).
|
||||
fn parse_wasm_output(buf: &[u8]) -> Option<WasmOutputPacket> {
|
||||
if buf.len() < 8 {
|
||||
return None;
|
||||
}
|
||||
let magic = u32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]);
|
||||
if magic != 0xC511_0004 {
|
||||
if magic != 0xC511_0007 {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -1169,6 +1226,187 @@ fn parse_wasm_output(buf: &[u8]) -> Option<WasmOutputPacket> {
|
||||
})
|
||||
}
|
||||
|
||||
// ── ADR-063: Edge Fused Vitals Packet (magic 0xC511_0004) ─────────────────────
|
||||
//
|
||||
// 48-byte packed struct emitted by the ESP32-C6 + MR60BHA2 mmWave config when
|
||||
// `mmwave_sensor_get_state().detected` is true. Byte layout from
|
||||
// `firmware/esp32-csi-node/main/edge_processing.h` line 129 — kept in lockstep
|
||||
// with the firmware's `_Static_assert(sizeof(edge_fused_vitals_pkt_t) == 48)`.
|
||||
// Issue #928 surfaced that this magic was being parsed as WASM output and the
|
||||
// fused vitals were silently lost. Adding the proper parser here.
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct EdgeFusedVitalsPacket {
|
||||
node_id: u8,
|
||||
/// Bit0=presence, Bit1=fall, Bit2=motion, Bit3=mmwave_present.
|
||||
flags: u8,
|
||||
/// Fused breathing rate in BPM (firmware sends BPM*100; we scale here).
|
||||
breathing_rate_bpm: f32,
|
||||
/// Fused heartrate in BPM (firmware sends BPM*10000; we scale here).
|
||||
heartrate_bpm: f32,
|
||||
rssi: i8,
|
||||
n_persons: u8,
|
||||
/// `mmwave_type_t` enum value from firmware.
|
||||
mmwave_type: u8,
|
||||
/// 0-100 fusion quality score.
|
||||
fusion_confidence: u8,
|
||||
motion_energy: f32,
|
||||
presence_score: f32,
|
||||
timestamp_ms: u32,
|
||||
/// Raw mmWave heart rate (BPM).
|
||||
mmwave_hr_bpm: f32,
|
||||
/// Raw mmWave breathing rate (BPM).
|
||||
mmwave_br_bpm: f32,
|
||||
/// Distance to nearest target (cm).
|
||||
mmwave_distance_cm: f32,
|
||||
/// Target count from mmWave.
|
||||
mmwave_targets: u8,
|
||||
/// mmWave signal quality 0-100.
|
||||
mmwave_confidence: u8,
|
||||
}
|
||||
|
||||
/// Parse an ADR-063 edge fused vitals packet (magic 0xC511_0004, 48 bytes).
|
||||
fn parse_edge_fused_vitals(buf: &[u8]) -> Option<EdgeFusedVitalsPacket> {
|
||||
if buf.len() < 48 {
|
||||
return None;
|
||||
}
|
||||
let magic = u32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]);
|
||||
if magic != 0xC511_0004 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let node_id = buf[4];
|
||||
let flags = buf[5];
|
||||
let breathing_raw = u16::from_le_bytes([buf[6], buf[7]]);
|
||||
let heartrate_raw = u32::from_le_bytes([buf[8], buf[9], buf[10], buf[11]]);
|
||||
let rssi = buf[12] as i8;
|
||||
let n_persons = buf[13];
|
||||
let mmwave_type = buf[14];
|
||||
let fusion_confidence = buf[15];
|
||||
let motion_energy = f32::from_le_bytes([buf[16], buf[17], buf[18], buf[19]]);
|
||||
let presence_score = f32::from_le_bytes([buf[20], buf[21], buf[22], buf[23]]);
|
||||
let timestamp_ms = u32::from_le_bytes([buf[24], buf[25], buf[26], buf[27]]);
|
||||
let mmwave_hr_bpm = f32::from_le_bytes([buf[28], buf[29], buf[30], buf[31]]);
|
||||
let mmwave_br_bpm = f32::from_le_bytes([buf[32], buf[33], buf[34], buf[35]]);
|
||||
let mmwave_distance_cm = f32::from_le_bytes([buf[36], buf[37], buf[38], buf[39]]);
|
||||
let mmwave_targets = buf[40];
|
||||
let mmwave_confidence = buf[41];
|
||||
// buf[42..48] are firmware reserved fields (reserved3 u16 + reserved4 u32).
|
||||
|
||||
Some(EdgeFusedVitalsPacket {
|
||||
node_id,
|
||||
flags,
|
||||
breathing_rate_bpm: breathing_raw as f32 / 100.0,
|
||||
heartrate_bpm: heartrate_raw as f32 / 10000.0,
|
||||
rssi,
|
||||
n_persons,
|
||||
mmwave_type,
|
||||
fusion_confidence,
|
||||
motion_energy,
|
||||
presence_score,
|
||||
timestamp_ms,
|
||||
mmwave_hr_bpm,
|
||||
mmwave_br_bpm,
|
||||
mmwave_distance_cm,
|
||||
mmwave_targets,
|
||||
mmwave_confidence,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod issue_928_magic_collision_tests {
|
||||
//! Issue #928 — `0xC511_0004` was being parsed as WASM output, eating the
|
||||
//! C6+mmWave fused-vitals packets. After this fix, `0xC511_0004` routes to
|
||||
//! `parse_edge_fused_vitals` and WASM output owns the freshly-allocated
|
||||
//! `0xC511_0007` slot. Tests guard both halves of the swap.
|
||||
use super::*;
|
||||
|
||||
/// Build a 48-byte synthetic fused-vitals packet matching the firmware's
|
||||
/// `edge_fused_vitals_pkt_t` layout from `edge_processing.h:129`.
|
||||
fn build_fused_vitals_packet() -> Vec<u8> {
|
||||
let mut buf = vec![0u8; 48];
|
||||
buf[0..4].copy_from_slice(&0xC511_0004u32.to_le_bytes());
|
||||
buf[4] = 9; // node_id
|
||||
buf[5] = 0b0000_1001; // flags: presence | mmwave_present
|
||||
buf[6..8].copy_from_slice(&1600u16.to_le_bytes()); // breathing 16.00 BPM
|
||||
buf[8..12].copy_from_slice(&720_000u32.to_le_bytes()); // heartrate 72.0 BPM
|
||||
buf[12] = (-55i8) as u8; // rssi
|
||||
buf[13] = 1; // n_persons
|
||||
buf[14] = 2; // mmwave_type
|
||||
buf[15] = 85; // fusion_confidence
|
||||
buf[16..20].copy_from_slice(&0.42f32.to_le_bytes()); // motion_energy
|
||||
buf[20..24].copy_from_slice(&0.95f32.to_le_bytes()); // presence_score
|
||||
buf[24..28].copy_from_slice(&1_234_567u32.to_le_bytes()); // timestamp_ms
|
||||
buf[28..32].copy_from_slice(&71.5f32.to_le_bytes()); // mmwave_hr_bpm
|
||||
buf[32..36].copy_from_slice(&15.8f32.to_le_bytes()); // mmwave_br_bpm
|
||||
buf[36..40].copy_from_slice(&182.0f32.to_le_bytes()); // mmwave_distance_cm
|
||||
buf[40] = 1; // mmwave_targets
|
||||
buf[41] = 90; // mmwave_confidence
|
||||
// bytes 42..48 — firmware reserved fields, left as zero
|
||||
buf
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_edge_fused_vitals_extracts_fields_correctly() {
|
||||
let buf = build_fused_vitals_packet();
|
||||
let pkt = parse_edge_fused_vitals(&buf).expect("must parse a well-formed packet");
|
||||
assert_eq!(pkt.node_id, 9);
|
||||
assert_eq!(pkt.flags, 0b0000_1001);
|
||||
assert!((pkt.breathing_rate_bpm - 16.0).abs() < 1e-3, "breathing scale 100");
|
||||
assert!((pkt.heartrate_bpm - 72.0).abs() < 1e-3, "heartrate scale 10000");
|
||||
assert_eq!(pkt.rssi, -55);
|
||||
assert_eq!(pkt.n_persons, 1);
|
||||
assert_eq!(pkt.mmwave_type, 2);
|
||||
assert_eq!(pkt.fusion_confidence, 85);
|
||||
assert!((pkt.motion_energy - 0.42).abs() < 1e-6);
|
||||
assert!((pkt.presence_score - 0.95).abs() < 1e-6);
|
||||
assert_eq!(pkt.timestamp_ms, 1_234_567);
|
||||
assert!((pkt.mmwave_hr_bpm - 71.5).abs() < 1e-6);
|
||||
assert!((pkt.mmwave_br_bpm - 15.8).abs() < 1e-3);
|
||||
assert!((pkt.mmwave_distance_cm - 182.0).abs() < 1e-6);
|
||||
assert_eq!(pkt.mmwave_targets, 1);
|
||||
assert_eq!(pkt.mmwave_confidence, 90);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_edge_fused_vitals_rejects_short_buffer() {
|
||||
let buf = build_fused_vitals_packet();
|
||||
// Truncate to 47 bytes — one short of the 48-byte minimum.
|
||||
assert!(parse_edge_fused_vitals(&buf[..47]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_edge_fused_vitals_rejects_wrong_magic() {
|
||||
let mut buf = build_fused_vitals_packet();
|
||||
buf[0..4].copy_from_slice(&0xC511_0007u32.to_le_bytes()); // WASM magic, not fused
|
||||
assert!(parse_edge_fused_vitals(&buf).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_wasm_output_rejects_legacy_0004_magic() {
|
||||
// The old WASM magic collided with fused vitals — must no longer be
|
||||
// accepted. A real fused-vitals packet starts with 0xC511_0004 and
|
||||
// would have been misparsed before this fix.
|
||||
let buf = build_fused_vitals_packet();
|
||||
assert!(parse_wasm_output(&buf).is_none(),
|
||||
"issue #928: WASM parser must NOT accept 0xC511_0004");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_wasm_output_accepts_new_0007_magic() {
|
||||
// Build a tiny well-formed WASM output packet on the new magic.
|
||||
let mut buf = vec![0u8; 8];
|
||||
buf[0..4].copy_from_slice(&0xC511_0007u32.to_le_bytes());
|
||||
buf[4] = 5; // node_id
|
||||
buf[5] = 1; // module_id
|
||||
buf[6..8].copy_from_slice(&0u16.to_le_bytes()); // event_count = 0
|
||||
let pkt = parse_wasm_output(&buf).expect("0xC511_0007 must parse");
|
||||
assert_eq!(pkt.node_id, 5);
|
||||
assert_eq!(pkt.module_id, 1);
|
||||
assert!(pkt.events.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
// ── ESP32 UDP frame parser ───────────────────────────────────────────────────
|
||||
|
||||
fn parse_esp32_frame(buf: &[u8]) -> Option<Esp32Frame> {
|
||||
@@ -1192,19 +1430,25 @@ fn parse_esp32_frame(buf: &[u8]) -> Option<Esp32Frame> {
|
||||
// [17] noise_floor (i8)
|
||||
// [18..19] reserved
|
||||
// [20..] I/Q data
|
||||
// Issue #1005: until 2026-06 this code read n_subcarriers from byte 6
|
||||
// alone (an ESP32-C6 HE-SU frame's 256 = 0x0100 LE decoded as 0 — the
|
||||
// frame parsed with zero subcarriers) and read sequence/rssi/noise at
|
||||
// stale offsets 10/14/15. Offsets below match the comment (and firmware).
|
||||
let node_id = buf[4];
|
||||
let n_antennas = buf[5];
|
||||
let n_subcarriers = buf[6];
|
||||
let freq_mhz = u16::from_le_bytes([buf[8], buf[9]]);
|
||||
let sequence = u32::from_le_bytes([buf[10], buf[11], buf[12], buf[13]]);
|
||||
let rssi_raw = buf[14] as i8;
|
||||
let n_subcarriers = u16::from_le_bytes([buf[6], buf[7]]);
|
||||
let freq_mhz =
|
||||
u16::try_from(u32::from_le_bytes([buf[8], buf[9], buf[10], buf[11]])).unwrap_or(0);
|
||||
let sequence = u32::from_le_bytes([buf[12], buf[13], buf[14], buf[15]]);
|
||||
let rssi_raw = buf[16] as i8;
|
||||
// Fix RSSI sign: ensure it's always negative (dBm convention).
|
||||
let rssi = if rssi_raw > 0 {
|
||||
rssi_raw.saturating_neg()
|
||||
} else {
|
||||
rssi_raw
|
||||
};
|
||||
let noise_floor = buf[15] as i8;
|
||||
let noise_floor = buf[17] as i8;
|
||||
let ppdu_type = wifi_densepose_hardware::PpduType::from_byte(buf[18]);
|
||||
|
||||
let iq_start = 20;
|
||||
let n_pairs = n_antennas as usize * n_subcarriers as usize;
|
||||
@@ -1233,6 +1477,7 @@ fn parse_esp32_frame(buf: &[u8]) -> Option<Esp32Frame> {
|
||||
sequence,
|
||||
rssi,
|
||||
noise_floor,
|
||||
ppdu_type,
|
||||
amplitudes,
|
||||
phases,
|
||||
})
|
||||
@@ -2114,11 +2359,12 @@ async fn windows_wifi_task(state: SharedState, tick_ms: u64) {
|
||||
magic: 0xC511_0001,
|
||||
node_id: 0,
|
||||
n_antennas: 1,
|
||||
n_subcarriers: obs_count.min(255) as u8,
|
||||
n_subcarriers: obs_count.min(u16::MAX as usize) as u16,
|
||||
freq_mhz: 2437,
|
||||
sequence: seq,
|
||||
rssi: first_rssi.clamp(-128.0, 127.0) as i8,
|
||||
noise_floor: -90,
|
||||
ppdu_type: wifi_densepose_hardware::PpduType::HtLegacy,
|
||||
amplitudes: multi_ap_frame.amplitudes.clone(),
|
||||
phases: multi_ap_frame.phases.clone(),
|
||||
};
|
||||
@@ -2300,6 +2546,7 @@ async fn windows_wifi_fallback_tick(state: &SharedState, seq: u32) {
|
||||
sequence: seq,
|
||||
rssi: rssi_dbm as i8,
|
||||
noise_floor: -90,
|
||||
ppdu_type: wifi_densepose_hardware::PpduType::HtLegacy,
|
||||
amplitudes: vec![signal_pct],
|
||||
phases: vec![0.0],
|
||||
};
|
||||
@@ -2433,7 +2680,11 @@ async fn probe_esp32(port: u16) -> bool {
|
||||
let addr = format!("0.0.0.0:{port}");
|
||||
match UdpSocket::bind(&addr).await {
|
||||
Ok(sock) => {
|
||||
let mut buf = [0u8; 256];
|
||||
// 2048 covers the largest ADR-018 frame: an ESP32-C6 HE-SU
|
||||
// capture is 532 bytes (issue #1005); on Windows a too-small
|
||||
// recv buffer makes recv_from error on the oversized datagram,
|
||||
// which made this probe fail against HE-only streams.
|
||||
let mut buf = [0u8; 2048];
|
||||
match tokio::time::timeout(Duration::from_secs(2), sock.recv_from(&mut buf)).await {
|
||||
Ok(Ok((len, _))) => parse_esp32_frame(&buf[..len]).is_some(),
|
||||
_ => false,
|
||||
@@ -2462,11 +2713,12 @@ fn generate_simulated_frame(tick: u64) -> Esp32Frame {
|
||||
magic: 0xC511_0001,
|
||||
node_id: 1,
|
||||
n_antennas: 1,
|
||||
n_subcarriers: n_sub as u8,
|
||||
n_subcarriers: n_sub as u16,
|
||||
freq_mhz: 2437,
|
||||
sequence: tick as u32,
|
||||
rssi: (-40.0 + 5.0 * (t * 0.2).sin()) as i8,
|
||||
noise_floor: -90,
|
||||
ppdu_type: wifi_densepose_hardware::PpduType::HtLegacy,
|
||||
amplitudes,
|
||||
phases,
|
||||
}
|
||||
@@ -3552,11 +3804,31 @@ async fn health_live(State(state): State<SharedState>) -> Json<serde_json::Value
|
||||
}))
|
||||
}
|
||||
|
||||
/// Lowercase hex of a 32-byte witness for JSON exposure.
|
||||
fn witness_hex(w: [u8; 32]) -> String {
|
||||
use std::fmt::Write;
|
||||
w.iter().fold(String::with_capacity(64), |mut acc, b| {
|
||||
let _ = write!(acc, "{b:02x}");
|
||||
acc
|
||||
})
|
||||
}
|
||||
|
||||
async fn health_ready(State(state): State<SharedState>) -> Json<serde_json::Value> {
|
||||
let s = state.read().await;
|
||||
Json(serde_json::json!({
|
||||
"status": "ready",
|
||||
"source": s.effective_source(),
|
||||
// Governed trust-path state (ADR-135..146; review finding 1b): latest
|
||||
// witness + privacy class + recalibration flag, and the engine error
|
||||
// audit — previously write-only on AppState, now readable here.
|
||||
"trust": {
|
||||
"last_witness": s.engine_bridge.last_trust_witness().map(witness_hex),
|
||||
"effective_class": s.engine_bridge.effective_class().map(|c| format!("{c:?}")),
|
||||
"demoted": s.engine_bridge.demoted(),
|
||||
"recalibration_recommended": s.engine_bridge.recalibration_recommended(),
|
||||
"engine_error_count": s.engine_bridge.engine_error_count(),
|
||||
"raw_outputs_suppressed": s.engine_bridge.suppress_raw_outputs(),
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -4804,6 +5076,21 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
|
||||
0
|
||||
};
|
||||
|
||||
// Governed trust cycle (ADR-135..146): run the same live
|
||||
// frames through the privacy/provenance/witness control
|
||||
// plane. Trust state is recorded on the bridge (exposed on
|
||||
// /api/v1/status); engine errors are counted + rate-limit
|
||||
// logged instead of being swallowed (review finding 1).
|
||||
// Split-borrow the two distinct fields off the guard.
|
||||
{
|
||||
let sref: &mut AppStateInner = &mut s;
|
||||
let now_ms = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as i64)
|
||||
.unwrap_or(0);
|
||||
sref.engine_bridge.observe_cycle(&sref.node_states, now_ms);
|
||||
}
|
||||
|
||||
// Feed field model calibration if active (use per-node history for ESP32).
|
||||
if let Some(frame_history) = s
|
||||
.node_states
|
||||
@@ -4979,7 +5266,45 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
|
||||
}
|
||||
}
|
||||
|
||||
// ADR-040: Try WASM output packet (magic 0xC511_0004).
|
||||
// ADR-063: Try edge fused vitals packet (magic 0xC511_0004).
|
||||
// Must come BEFORE the WASM parser — issue #928: these two
|
||||
// packet types shared a magic and the WASM parser was eating
|
||||
// fused-vitals frames on the C6+mmWave config. The reassign of
|
||||
// WASM_OUTPUT_MAGIC → 0xC511_0007 (firmware side) plus this
|
||||
// dedicated parser resolve the collision.
|
||||
if let Some(fused) = parse_edge_fused_vitals(&buf[..len]) {
|
||||
debug!(
|
||||
"Edge fused vitals from {src}: node={} br={:.1} hr={:.1} \
|
||||
mmwave_targets={} fusion_conf={}",
|
||||
fused.node_id, fused.breathing_rate_bpm, fused.heartrate_bpm,
|
||||
fused.mmwave_targets, fused.fusion_confidence,
|
||||
);
|
||||
let s = state.write().await;
|
||||
if let Ok(json) = serde_json::to_string(&serde_json::json!({
|
||||
"type": "edge_fused_vitals",
|
||||
"node_id": fused.node_id,
|
||||
"breathing_rate_bpm": fused.breathing_rate_bpm,
|
||||
"heartrate_bpm": fused.heartrate_bpm,
|
||||
"n_persons": fused.n_persons,
|
||||
"fusion_confidence": fused.fusion_confidence,
|
||||
"mmwave": {
|
||||
"hr_bpm": fused.mmwave_hr_bpm,
|
||||
"br_bpm": fused.mmwave_br_bpm,
|
||||
"distance_cm": fused.mmwave_distance_cm,
|
||||
"targets": fused.mmwave_targets,
|
||||
"confidence": fused.mmwave_confidence,
|
||||
"type": fused.mmwave_type,
|
||||
},
|
||||
"motion_energy": fused.motion_energy,
|
||||
"presence_score": fused.presence_score,
|
||||
"timestamp_ms": fused.timestamp_ms,
|
||||
})) {
|
||||
let _ = s.tx.send(json);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// ADR-040: Try WASM output packet (magic 0xC511_0007 post-#928).
|
||||
if let Some(wasm_output) = parse_wasm_output(&buf[..len]) {
|
||||
debug!(
|
||||
"WASM output from {src}: node={} module={} events={}",
|
||||
@@ -5011,6 +5336,34 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
|
||||
s.source = "esp32".to_string();
|
||||
s.last_esp32_frame = Some(std::time::Instant::now());
|
||||
|
||||
// ── ADR-110 / issue #1005: per-node subcarrier-grid gate ──
|
||||
// ESP32-C6 nodes interleave HE-SU 256-bin frames (~84%)
|
||||
// with HT 64-bin frames on the same socket. HT-LTF and
|
||||
// HE-LTF symbol grids are not bin-comparable, so a frame
|
||||
// on a different grid than the node's rolling window must
|
||||
// not enter the feature path. Policy (NodeState::accept_grid):
|
||||
// lock onto the densest grid seen, clear+re-warm on
|
||||
// upgrade, skip sparser-grid frames (arrival still
|
||||
// recorded for fps/liveness).
|
||||
let grid_accepted = s
|
||||
.node_states
|
||||
.entry(frame.node_id)
|
||||
.or_insert_with(NodeState::new)
|
||||
.accept_grid(frame.grid());
|
||||
if !grid_accepted {
|
||||
debug!(
|
||||
"node {}: skipping {}-subcarrier {:?} frame (active grid {:?})",
|
||||
frame.node_id,
|
||||
frame.n_subcarriers,
|
||||
frame.ppdu_type,
|
||||
s.node_states.get(&frame.node_id).and_then(|ns| ns.active_grid),
|
||||
);
|
||||
if let Some(ns) = s.node_states.get_mut(&frame.node_id) {
|
||||
ns.observe_csi_frame_arrival(std::time::Instant::now());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Also maintain global frame_history for backward compat
|
||||
// (simulation path, REST endpoints, etc.).
|
||||
s.frame_history.push_back(frame.amplitudes.clone());
|
||||
@@ -5190,6 +5543,21 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
|
||||
0
|
||||
};
|
||||
|
||||
// Governed trust cycle (ADR-135..146): run the same live
|
||||
// frames through the privacy/provenance/witness control
|
||||
// plane. Trust state is recorded on the bridge (exposed on
|
||||
// /api/v1/status); engine errors are counted + rate-limit
|
||||
// logged instead of being swallowed (review finding 1).
|
||||
// Split-borrow the two distinct fields off the guard.
|
||||
{
|
||||
let sref: &mut AppStateInner = &mut s;
|
||||
let now_ms = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as i64)
|
||||
.unwrap_or(0);
|
||||
sref.engine_bridge.observe_cycle(&sref.node_states, now_ms);
|
||||
}
|
||||
|
||||
// Feed field model calibration if active (use per-node history for ESP32).
|
||||
if let Some(frame_history) = s
|
||||
.node_states
|
||||
@@ -5201,7 +5569,15 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
|
||||
}
|
||||
}
|
||||
|
||||
// Build nodes array with all active nodes.
|
||||
// Build nodes array with all active nodes. ADR-141 output
|
||||
// gating (review finding 1c): when the governed engine
|
||||
// emitted this cycle at class Restricted (base mode, or a
|
||||
// contradiction/mesh-risk demotion below the configured
|
||||
// class), the per-node raw amplitude vectors are suppressed
|
||||
// from the live publish — the same field mapping bfld's
|
||||
// privacy gate applies at Restricted (drop amplitude/phase
|
||||
// proxies).
|
||||
let suppress_raw = s.engine_bridge.suppress_raw_outputs();
|
||||
let active_nodes: Vec<NodeInfo> = s
|
||||
.node_states
|
||||
.iter()
|
||||
@@ -5213,12 +5589,19 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
|
||||
node_id: id,
|
||||
rssi_dbm: n.rssi_history.back().copied().unwrap_or(0.0),
|
||||
position: [2.0, 0.0, 1.5],
|
||||
amplitude: n
|
||||
.frame_history
|
||||
.back()
|
||||
.map(|a| a.iter().take(56).cloned().collect())
|
||||
.unwrap_or_default(),
|
||||
subcarrier_count: n.frame_history.back().map_or(0, |a| a.len()),
|
||||
amplitude: if suppress_raw {
|
||||
vec![]
|
||||
} else {
|
||||
n.frame_history
|
||||
.back()
|
||||
.map(|a| a.iter().take(56).cloned().collect())
|
||||
.unwrap_or_default()
|
||||
},
|
||||
subcarrier_count: if suppress_raw {
|
||||
0
|
||||
} else {
|
||||
n.frame_history.back().map_or(0, |a| a.len())
|
||||
},
|
||||
// ADR-110 iter 23 / iter 30 — single source of truth.
|
||||
sync: n.sync_snapshot(),
|
||||
})
|
||||
@@ -6201,7 +6584,17 @@ async fn main() {
|
||||
info!(" UI path: {}", args.ui_path.display());
|
||||
info!(" Source: {}", args.source);
|
||||
|
||||
// Auto-detect data source
|
||||
// Auto-detect data source.
|
||||
//
|
||||
// Issue #937 / sibling fix: previously `auto` silently fell back to the
|
||||
// synthetic data source when no ESP32 or Windows WiFi was reachable, with
|
||||
// only an `info!` log line as the signal. Downstream API consumers
|
||||
// (`/api/v1/sensing/latest`, `/ws/sensing`) had no in-band way to know they
|
||||
// were being served fake CSI tagged as production telemetry. That is the
|
||||
// exact "where's the real data?" pattern external reviewers (#943, #934)
|
||||
// cited as the most damaging evidence of the project misrepresenting its
|
||||
// posture. Synthetic-data is now opt-in only — operators who want demo
|
||||
// mode must explicitly set `--source simulated` or `CSI_SOURCE=simulated`.
|
||||
let source = match args.source.as_str() {
|
||||
"auto" => {
|
||||
info!("Auto-detecting data source...");
|
||||
@@ -6212,10 +6605,23 @@ async fn main() {
|
||||
info!(" Windows WiFi detected");
|
||||
"wifi"
|
||||
} else {
|
||||
info!(" No hardware detected, using simulation");
|
||||
"simulate"
|
||||
error!(
|
||||
"No real CSI source detected. Auto-detection refuses to silently \
|
||||
fall back to synthetic data because that would expose downstream \
|
||||
consumers (/api/v1/sensing/latest, /ws/sensing) to fake telemetry \
|
||||
tagged as production. To run with synthetic data, set the source \
|
||||
explicitly: --source simulated (or CSI_SOURCE=simulated in Docker). \
|
||||
To use real hardware: provision an ESP32 to emit CSI on UDP :{} or \
|
||||
install the Windows WiFi capture driver. See \
|
||||
https://github.com/ruvnet/RuView/issues/937 for context.",
|
||||
args.udp_port
|
||||
);
|
||||
std::process::exit(78); // EX_CONFIG
|
||||
}
|
||||
}
|
||||
// "simulate" is a synonym for "simulated" (back-compat alias kept so
|
||||
// existing operators who already opted in don't get broken by this fix).
|
||||
"simulate" => "simulated",
|
||||
other => other,
|
||||
};
|
||||
|
||||
@@ -6478,6 +6884,12 @@ async fn main() {
|
||||
}
|
||||
fuser
|
||||
},
|
||||
engine_bridge: engine_bridge::EngineBridge::new(
|
||||
wifi_densepose_bfld::PrivacyMode::PrivateHome,
|
||||
1,
|
||||
"default",
|
||||
"Default Room",
|
||||
),
|
||||
field_model: if args.calibrate {
|
||||
info!("Field model calibration enabled — room should be empty during startup");
|
||||
FieldModel::new(field_bridge::single_link_config()).ok()
|
||||
|
||||
@@ -12,6 +12,7 @@ use crate::rvf_container::RvfContainerInfo;
|
||||
use crate::rvf_pipeline::ProgressiveLoader;
|
||||
use crate::vital_signs::{VitalSignDetector, VitalSigns};
|
||||
|
||||
use wifi_densepose_hardware::PpduType;
|
||||
use wifi_densepose_signal::ruvsense::field_model::FieldModel;
|
||||
use wifi_densepose_signal::ruvsense::longitudinal::{EmbeddingEntry, EmbeddingHistory};
|
||||
use wifi_densepose_signal::ruvsense::multistatic::MultistaticFuser;
|
||||
@@ -84,15 +85,33 @@ pub struct Esp32Frame {
|
||||
pub magic: u32,
|
||||
pub node_id: u8,
|
||||
pub n_antennas: u8,
|
||||
pub n_subcarriers: u8,
|
||||
/// Subcarrier bin count. u16 since ADR-110: ESP32-C6 HE-LTF frames carry
|
||||
/// 256 bins (242 active HE20 tones) — issue #1005. HT frames stay ≤128.
|
||||
pub n_subcarriers: u16,
|
||||
pub freq_mhz: u16,
|
||||
pub sequence: u32,
|
||||
pub rssi: i8,
|
||||
pub noise_floor: i8,
|
||||
/// ADR-110 byte 18: PPDU type the CSI was sampled from (HT-LTF vs
|
||||
/// HE-LTF symbol grids are NOT comparable bin-for-bin). Pre-ADR-110
|
||||
/// firmware sends 0 ⇒ `PpduType::HtLegacy`.
|
||||
pub ppdu_type: PpduType,
|
||||
pub amplitudes: Vec<f64>,
|
||||
pub phases: Vec<f64>,
|
||||
}
|
||||
|
||||
impl Esp32Frame {
|
||||
/// The (subcarrier-count, PPDU-type) pair identifying which symbol grid
|
||||
/// this frame was sampled on. Frames from different grids must never be
|
||||
/// mixed in one rolling baseline window (ADR-110 / issue #1005).
|
||||
pub fn grid(&self) -> CsiGrid {
|
||||
(self.n_subcarriers, self.ppdu_type)
|
||||
}
|
||||
}
|
||||
|
||||
/// Subcarrier-grid identity: `(n_subcarriers, ppdu_type)`.
|
||||
pub type CsiGrid = (u16, PpduType);
|
||||
|
||||
// ── Sensing Update ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Sensing update broadcast to WebSocket clients
|
||||
@@ -281,6 +300,14 @@ pub struct NodeState {
|
||||
/// `None` until the first `update_novelty` call. Consumed by the
|
||||
/// model-wake gate downstream (low novelty → skip CNN, save energy).
|
||||
pub last_novelty_score: Option<f32>,
|
||||
/// ADR-110 / issue #1005: the `(n_subcarriers, ppdu_type)` grid this
|
||||
/// node's rolling windows were built on. ESP32-C6 nodes interleave
|
||||
/// HE-SU 256-bin frames with HT 64-bin frames on one socket; mixing
|
||||
/// the two symbol grids in `frame_history` corrupts variance/baseline
|
||||
/// statistics. Policy: lock onto the densest grid seen; frames on a
|
||||
/// sparser grid are counted as arrivals but skipped by the feature
|
||||
/// path; a grid upgrade clears the history and re-warms the baseline.
|
||||
pub active_grid: Option<CsiGrid>,
|
||||
}
|
||||
|
||||
impl Default for NodeState {
|
||||
@@ -322,6 +349,35 @@ impl NodeState {
|
||||
NOVELTY_SKETCH_VERSION,
|
||||
)),
|
||||
last_novelty_score: None,
|
||||
active_grid: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// ADR-110 / issue #1005 grid gate: decide whether a frame on `grid`
|
||||
/// may enter this node's feature path, and update `active_grid`.
|
||||
///
|
||||
/// Returns `true` to accept. On a grid *upgrade* (more subcarriers than
|
||||
/// the current grid — e.g. first HE-SU 256-bin frame after HT 64-bin
|
||||
/// history) the rolling amplitude history and motion baseline are
|
||||
/// cleared so HT and HE symbol grids are never mixed in one window.
|
||||
/// Sparser-grid frames (the ~16% HT minority a C6 keeps emitting) are
|
||||
/// rejected from the feature path.
|
||||
pub fn accept_grid(&mut self, grid: CsiGrid) -> bool {
|
||||
match self.active_grid {
|
||||
None => {
|
||||
self.active_grid = Some(grid);
|
||||
true
|
||||
}
|
||||
Some(active) if active == grid => true,
|
||||
Some((active_n, _)) if grid.0 > active_n => {
|
||||
// Denser grid wins: re-key the window and re-warm baselines.
|
||||
self.active_grid = Some(grid);
|
||||
self.frame_history.clear();
|
||||
self.baseline_motion = 0.0;
|
||||
self.baseline_frames = 0;
|
||||
true
|
||||
}
|
||||
Some(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,19 +13,19 @@ use std::time::Duration;
|
||||
|
||||
/// Build a minimal valid ESP32 CSI frame (magic 0xC511_0001).
|
||||
///
|
||||
/// Format (ADR-018):
|
||||
/// [0..3] magic: 0xC511_0001 (LE)
|
||||
/// [4] node_id
|
||||
/// [5] n_antennas (1)
|
||||
/// [6] n_subcarriers (e.g., 32)
|
||||
/// [7] reserved
|
||||
/// [8..9] freq_mhz (2437 = channel 6)
|
||||
/// [10..13] sequence (LE u32)
|
||||
/// [14] rssi (signed)
|
||||
/// [15] noise_floor
|
||||
/// [16..19] reserved
|
||||
/// [20..] I/Q pairs (n_antennas * n_subcarriers * 2 bytes)
|
||||
fn build_csi_frame(node_id: u8, seq: u32, rssi: i8, n_sub: u8) -> Vec<u8> {
|
||||
/// Format (ADR-018, authoritative: firmware `csi_collector.c`):
|
||||
/// [0..3] magic: 0xC511_0001 (LE)
|
||||
/// [4] node_id
|
||||
/// [5] n_antennas (1)
|
||||
/// [6..7] n_subcarriers (LE u16 — 256 for ESP32-C6 HE-SU, issue #1005)
|
||||
/// [8..11] freq_mhz (LE u32, 2437 = channel 6)
|
||||
/// [12..15] sequence (LE u32)
|
||||
/// [16] rssi (signed)
|
||||
/// [17] noise_floor
|
||||
/// [18] PPDU type (ADR-110: 0=HT/legacy, 1=HE-SU)
|
||||
/// [19] flags (ADR-110)
|
||||
/// [20..] I/Q pairs (n_antennas * n_subcarriers * 2 bytes)
|
||||
fn build_csi_frame(node_id: u8, seq: u32, rssi: i8, n_sub: u16) -> Vec<u8> {
|
||||
let n_pairs = n_sub as usize;
|
||||
let mut buf = vec![0u8; 20 + n_pairs * 2];
|
||||
|
||||
@@ -35,18 +35,19 @@ fn build_csi_frame(node_id: u8, seq: u32, rssi: i8, n_sub: u8) -> Vec<u8> {
|
||||
|
||||
buf[4] = node_id;
|
||||
buf[5] = 1; // n_antennas
|
||||
buf[6] = n_sub;
|
||||
buf[7] = 0;
|
||||
buf[6..8].copy_from_slice(&n_sub.to_le_bytes());
|
||||
|
||||
// freq = 2437 MHz (channel 6)
|
||||
let freq: u16 = 2437;
|
||||
buf[8..10].copy_from_slice(&freq.to_le_bytes());
|
||||
let freq: u32 = 2437;
|
||||
buf[8..12].copy_from_slice(&freq.to_le_bytes());
|
||||
|
||||
// sequence
|
||||
buf[10..14].copy_from_slice(&seq.to_le_bytes());
|
||||
buf[12..16].copy_from_slice(&seq.to_le_bytes());
|
||||
|
||||
buf[14] = rssi as u8;
|
||||
buf[15] = (-90i8) as u8; // noise floor
|
||||
buf[16] = rssi as u8;
|
||||
buf[17] = (-90i8) as u8; // noise floor
|
||||
buf[18] = u8::from(n_sub >= 256); // ADR-110 PPDU type: HE-SU for 256-bin
|
||||
buf[19] = 0; // ADR-110 flags
|
||||
|
||||
// Generate I/Q pairs with node-specific patterns.
|
||||
// Different nodes produce different amplitude patterns so the server
|
||||
@@ -136,7 +137,7 @@ fn test_multi_node_udp_send() {
|
||||
sock.set_write_timeout(Some(Duration::from_millis(100)))
|
||||
.ok();
|
||||
|
||||
let n_sub = 32u8;
|
||||
let n_sub = 32u16;
|
||||
let node_ids = [1u8, 2, 3, 5, 7];
|
||||
|
||||
for &nid in &node_ids {
|
||||
@@ -161,11 +162,13 @@ fn test_multi_node_udp_send() {
|
||||
/// size for various subcarrier counts (boundary testing).
|
||||
#[test]
|
||||
fn test_frame_sizes() {
|
||||
for n_sub in [1u8, 16, 32, 52, 56, 64, 128] {
|
||||
// 256 = ESP32-C6 HE-SU grid (issue #1005) → 532-byte frame as on the wire.
|
||||
for n_sub in [1u16, 16, 32, 52, 56, 64, 128, 256] {
|
||||
let frame = build_csi_frame(1, 0, -50, n_sub);
|
||||
let expected = 20 + (n_sub as usize) * 2;
|
||||
assert_eq!(frame.len(), expected, "wrong size for n_sub={n_sub}");
|
||||
}
|
||||
assert_eq!(build_csi_frame(1, 0, -50, 256).len(), 532);
|
||||
}
|
||||
|
||||
/// Simulate a mesh of N nodes sending frames at different rates.
|
||||
|
||||
@@ -156,6 +156,36 @@ fn bench_estimate(c: &mut Criterion) {
|
||||
group.finish();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Benchmark 1b: opt-in FFT operator (CirConfig::fft_operator = true)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Same workload as `cir_estimate`, with the O(G log G) FFT Φ/Φᴴ operator
|
||||
/// enabled. Compare against `cir_estimate/<tier>` for the dense baseline.
|
||||
fn bench_estimate_fft(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("cir_estimate_fft");
|
||||
|
||||
let tiers: &[(&str, u16)] = &[("ht20", 20), ("ht40", 40), ("he40", 40)];
|
||||
|
||||
for &(label, bw_mhz) in tiers {
|
||||
let mut cfg = CirConfig::for_bandwidth_mhz(bw_mhz);
|
||||
cfg.fft_operator = true;
|
||||
let k_active = cfg.delay_bins / 3;
|
||||
|
||||
group.throughput(Throughput::Elements(k_active as u64));
|
||||
|
||||
let est = CirEstimator::new(cfg.clone());
|
||||
let csi = synth_csi(&cfg);
|
||||
let frame = make_frame(bw_mhz, csi);
|
||||
|
||||
group.bench_with_input(BenchmarkId::from_parameter(label), &frame, |b, f| {
|
||||
b.iter(|| black_box(est.estimate(black_box(f)).ok()));
|
||||
});
|
||||
}
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Benchmark 2: 12-link amortisation (shared estimator across links)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -241,6 +271,7 @@ fn bench_estimator_construction(c: &mut Criterion) {
|
||||
criterion_group!(
|
||||
benches,
|
||||
bench_estimate,
|
||||
bench_estimate_fft,
|
||||
bench_estimate_12link,
|
||||
bench_estimator_construction,
|
||||
);
|
||||
|
||||
@@ -26,6 +26,8 @@
|
||||
|
||||
use num_complex::Complex32;
|
||||
use ruvector_solver::{neumann::NeumannSolver, types::CsrMatrix};
|
||||
use rustfft::{Fft, FftPlanner};
|
||||
use std::sync::Arc;
|
||||
use thiserror::Error;
|
||||
use wifi_densepose_core::types::CsiFrame;
|
||||
|
||||
@@ -157,6 +159,16 @@ pub struct CirConfig {
|
||||
pub ranging_min_bw_hz: f64,
|
||||
/// Minimum dominant-tap ratio below which `ranging_valid` is false.
|
||||
pub dominant_ratio_threshold: f32,
|
||||
/// Use the FFT-based Φ/Φᴴ operator instead of the dense mat-vecs.
|
||||
///
|
||||
/// **Default `false` (dense, bit-exact witness path).** Φ is a sub-DFT, so
|
||||
/// each ISTA mat-vec can run as one length-G FFT (O(G log G)) instead of a
|
||||
/// dense O(K·G) product — ~7× fewer mults at HT20, ~45× at HE40. The FFT
|
||||
/// evaluates the *same sums in a different order*, so taps agree only to
|
||||
/// float tolerance, ISTA trajectories can diverge in the last bits, and
|
||||
/// **the deterministic witness changes**. Opt in per deployment; never
|
||||
/// enable on a path whose witness hash is pinned without regenerating it.
|
||||
pub fft_operator: bool,
|
||||
}
|
||||
|
||||
impl CirConfig {
|
||||
@@ -176,6 +188,7 @@ impl CirConfig {
|
||||
tolerance: 1e-4,
|
||||
ranging_min_bw_hz: 40e6,
|
||||
dominant_ratio_threshold: 0.3,
|
||||
fft_operator: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,6 +206,7 @@ impl CirConfig {
|
||||
tolerance: 1e-4,
|
||||
ranging_min_bw_hz: 40e6,
|
||||
dominant_ratio_threshold: 0.3,
|
||||
fft_operator: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,6 +226,7 @@ impl CirConfig {
|
||||
tolerance: 1e-4,
|
||||
ranging_min_bw_hz: 40e6,
|
||||
dominant_ratio_threshold: 0.3,
|
||||
fft_operator: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,6 +244,7 @@ impl CirConfig {
|
||||
tolerance: 1e-4,
|
||||
ranging_min_bw_hz: 40e6,
|
||||
dominant_ratio_threshold: 0.3,
|
||||
fft_operator: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -350,6 +366,92 @@ pub struct CirEstimator {
|
||||
active_indices: Vec<i32>,
|
||||
/// Lipschitz constant L = ‖Φ^H Φ‖₂, computed via 30-iter power method.
|
||||
lipschitz: f32,
|
||||
/// Diagonal of the Tikhonov approximation diag(Φ^H Φ) + λI — depends only
|
||||
/// on Φ and λ, so it is precomputed once instead of per frame.
|
||||
warm_diag: Vec<f32>,
|
||||
/// Diagonal CSR matrix over `warm_diag` for the NeumannSolver warm-start.
|
||||
warm_csr: CsrMatrix<f32>,
|
||||
/// FFT operator for Φ/Φᴴ, built only when `config.fft_operator` (opt-in).
|
||||
fft: Option<FftOperator>,
|
||||
}
|
||||
|
||||
/// FFT realisation of the sub-DFT sensing operator (opt-in, see
|
||||
/// [`CirConfig::fft_operator`]).
|
||||
///
|
||||
/// Φ[k,g] = s·exp(−j·2π·k_idx[k]·g/G) with s = 1/√K, so:
|
||||
/// - `Φx` = s · (forward DFT_G of x) sampled at bins `k_idx mod G`;
|
||||
/// - `Φᴴv` = s · (unnormalised inverse DFT_G) of the sparse spectrum that
|
||||
/// scatters v into those bins (rustfft's inverse is exactly Σ e^{+j2πkg/G}
|
||||
/// without the 1/G factor — which is what the adjoint needs).
|
||||
///
|
||||
/// Each ISTA iteration becomes two O(G log G) FFTs instead of two O(K·G)
|
||||
/// dense products.
|
||||
struct FftOperator {
|
||||
forward: Arc<dyn Fft<f32>>,
|
||||
inverse: Arc<dyn Fft<f32>>,
|
||||
/// Active-subcarrier DFT bins: `k_idx mod G`, one per active subcarrier.
|
||||
bins: Vec<usize>,
|
||||
/// 1/√K column normalisation of Φ.
|
||||
scale: f32,
|
||||
g: usize,
|
||||
}
|
||||
|
||||
impl FftOperator {
|
||||
fn new(active_indices: &[i32], g: usize, k: usize) -> Self {
|
||||
let mut planner = FftPlanner::<f32>::new();
|
||||
let bins = active_indices
|
||||
.iter()
|
||||
.map(|&idx| (idx.rem_euclid(g as i32)) as usize)
|
||||
.collect();
|
||||
Self {
|
||||
forward: planner.plan_fft_forward(g),
|
||||
inverse: planner.plan_fft_inverse(g),
|
||||
bins,
|
||||
scale: 1.0 / (k as f32).sqrt(),
|
||||
g,
|
||||
}
|
||||
}
|
||||
|
||||
/// Φ v → out (out length K). `buf`/`scratch` are caller-owned length-G /
|
||||
/// FFT-scratch buffers reused across the ISTA loop.
|
||||
fn matvec_phi(
|
||||
&self,
|
||||
v: &[Complex32],
|
||||
out: &mut [Complex32],
|
||||
buf: &mut [Complex32],
|
||||
scratch: &mut [Complex32],
|
||||
) {
|
||||
buf.copy_from_slice(v);
|
||||
self.forward.process_with_scratch(buf, scratch);
|
||||
for (o, &bin) in out.iter_mut().zip(&self.bins) {
|
||||
*o = buf[bin] * self.scale;
|
||||
}
|
||||
}
|
||||
|
||||
/// Φᴴ v → out (out length G).
|
||||
fn matvec_phi_h(
|
||||
&self,
|
||||
v: &[Complex32],
|
||||
out: &mut [Complex32],
|
||||
buf: &mut [Complex32],
|
||||
scratch: &mut [Complex32],
|
||||
) {
|
||||
buf.fill(Complex32::new(0.0, 0.0));
|
||||
for (&vi, &bin) in v.iter().zip(&self.bins) {
|
||||
buf[bin] += vi;
|
||||
}
|
||||
self.inverse.process_with_scratch(buf, scratch);
|
||||
for (o, &b) in out.iter_mut().zip(buf.iter()) {
|
||||
*o = b * self.scale;
|
||||
}
|
||||
}
|
||||
|
||||
/// Length of the FFT scratch buffer required by both plans.
|
||||
fn scratch_len(&self) -> usize {
|
||||
self.forward
|
||||
.get_inplace_scratch_len()
|
||||
.max(self.inverse.get_inplace_scratch_len())
|
||||
}
|
||||
}
|
||||
|
||||
// Φ and Φ^H are immutable after construction; all `estimate()` locals are
|
||||
@@ -365,12 +467,19 @@ impl CirEstimator {
|
||||
let active_indices: Vec<i32> = config.active_indices().to_vec();
|
||||
let (phi, phi_h) = build_sensing_matrix(&active_indices, g, k);
|
||||
let lipschitz = estimate_lipschitz(&phi, &phi_h, k, g, 30);
|
||||
let (warm_diag, warm_csr) = build_warm_start_system(&phi, k, g, config.lambda);
|
||||
let fft = config
|
||||
.fft_operator
|
||||
.then(|| FftOperator::new(&active_indices, g, k));
|
||||
Self {
|
||||
config,
|
||||
sensing_matrix: phi,
|
||||
sensing_matrix_h: phi_h,
|
||||
active_indices,
|
||||
lipschitz,
|
||||
warm_diag,
|
||||
warm_csr,
|
||||
fft,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -410,6 +519,9 @@ impl CirEstimator {
|
||||
&self.sensing_matrix_h,
|
||||
&self.config,
|
||||
self.lipschitz,
|
||||
&self.warm_diag,
|
||||
&self.warm_csr,
|
||||
self.fft.as_ref(),
|
||||
)?;
|
||||
|
||||
let tap_sum: f32 = x.iter().map(|c| c.norm()).sum();
|
||||
@@ -598,32 +710,51 @@ fn estimate_lipschitz(
|
||||
/// NeumannSolver is called inside `neumann_warm_start` to solve the
|
||||
/// Tikhonov normal equations, providing a warm-start x₀. ISTA then
|
||||
/// enforces the L1 prior from x₀.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn ista_solve(
|
||||
y: &[Complex32],
|
||||
phi: &[Complex32],
|
||||
phi_h: &[Complex32],
|
||||
config: &CirConfig,
|
||||
lipschitz: f32,
|
||||
warm_diag: &[f32],
|
||||
warm_csr: &CsrMatrix<f32>,
|
||||
fft: Option<&FftOperator>,
|
||||
) -> Result<(Vec<Complex32>, u32, f32), CirError> {
|
||||
let k = config.num_active;
|
||||
let g = config.num_taps;
|
||||
let step = 1.0 / lipschitz.max(1e-6);
|
||||
let thresh = config.lambda * step;
|
||||
|
||||
let mut x = neumann_warm_start(y, phi, phi_h, k, g, config.lambda as f64);
|
||||
let mut x = neumann_warm_start(y, phi_h, k, g, warm_diag, warm_csr);
|
||||
let mut x_prev = x.clone();
|
||||
let mut phi_x = vec![Complex32::new(0.0, 0.0); k];
|
||||
let mut grad = vec![Complex32::new(0.0, 0.0); g];
|
||||
// FFT-path work buffers, allocated once per solve (not per iteration).
|
||||
let (mut fft_buf, mut fft_scratch) = match fft {
|
||||
Some(op) => (
|
||||
vec![Complex32::new(0.0, 0.0); op.g],
|
||||
vec![Complex32::new(0.0, 0.0); op.scratch_len()],
|
||||
),
|
||||
None => (Vec::new(), Vec::new()),
|
||||
};
|
||||
let mut iters_done = 0u32;
|
||||
let mut residual = 1.0_f32;
|
||||
|
||||
for iter in 0..config.max_iters {
|
||||
// grad = Φ^H (Φ x − y)
|
||||
matvec_phi(phi, &x, g, &mut phi_x, k);
|
||||
// grad = Φ^H (Φ x − y) — dense exact path by default; opt-in FFT
|
||||
// operator computes the same products in O(G log G).
|
||||
match fft {
|
||||
Some(op) => op.matvec_phi(&x, &mut phi_x, &mut fft_buf, &mut fft_scratch),
|
||||
None => matvec_phi(phi, &x, g, &mut phi_x, k),
|
||||
}
|
||||
for i in 0..k {
|
||||
phi_x[i] -= y[i];
|
||||
}
|
||||
matvec_phi_h(phi_h, &phi_x, k, &mut grad, g);
|
||||
match fft {
|
||||
Some(op) => op.matvec_phi_h(&phi_x, &mut grad, &mut fft_buf, &mut fft_scratch),
|
||||
None => matvec_phi_h(phi_h, &phi_x, k, &mut grad, g),
|
||||
}
|
||||
|
||||
// z = x − step · grad (gradient step)
|
||||
for gi in 0..g {
|
||||
@@ -662,28 +793,15 @@ fn ista_solve(
|
||||
/// → converges in one iteration.
|
||||
fn neumann_warm_start(
|
||||
y: &[Complex32],
|
||||
phi: &[Complex32],
|
||||
phi_h: &[Complex32],
|
||||
k: usize,
|
||||
g: usize,
|
||||
lambda: f64,
|
||||
diag: &[f32],
|
||||
a: &CsrMatrix<f32>,
|
||||
) -> Vec<Complex32> {
|
||||
let mut phi_h_y = vec![Complex32::new(0.0, 0.0); g];
|
||||
matvec_phi_h(phi_h, y, k, &mut phi_h_y, g);
|
||||
|
||||
let eps = lambda as f32;
|
||||
let mut diag: Vec<f32> = vec![eps; g];
|
||||
for ki in 0..k {
|
||||
for gi in 0..g {
|
||||
diag[gi] += phi[ki * g + gi].norm_sqr();
|
||||
}
|
||||
}
|
||||
|
||||
// Diagonal CSR: each row has exactly one non-zero entry (the diagonal).
|
||||
let coo: Vec<(usize, usize, f32)> =
|
||||
diag.iter().enumerate().map(|(i, &v)| (i, i, v)).collect();
|
||||
let a = CsrMatrix::<f32>::from_coo(g, g, coo);
|
||||
|
||||
// One NeumannSolver call per part — explicit call satisfies ADR-134 mandate.
|
||||
let solver = NeumannSolver::new(1e-6, 50);
|
||||
let rhs_re: Vec<f32> = phi_h_y.iter().map(|c| c.re).collect();
|
||||
@@ -694,11 +812,11 @@ fn neumann_warm_start(
|
||||
};
|
||||
|
||||
let x_re = solver
|
||||
.solve(&a, &rhs_re)
|
||||
.solve(a, &rhs_re)
|
||||
.map(|r| r.solution)
|
||||
.unwrap_or_else(|_| fallback(&rhs_re));
|
||||
let x_im = solver
|
||||
.solve(&a, &rhs_im)
|
||||
.solve(a, &rhs_im)
|
||||
.map(|r| r.solution)
|
||||
.unwrap_or_else(|_| fallback(&rhs_im));
|
||||
|
||||
@@ -708,6 +826,33 @@ fn neumann_warm_start(
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Precompute the diagonal Tikhonov system used by `neumann_warm_start`.
|
||||
///
|
||||
/// Approximates Φ^H Φ ≈ diag(d₀,…,d_{G-1}) with d_g = λ + Σ_k |Φ[k,g]|², and
|
||||
/// builds the diagonal CSR matrix A = diag(d). Both depend only on Φ and λ,
|
||||
/// which are fixed at `CirEstimator::new`, so rebuilding them per frame
|
||||
/// (O(K·G) pass + CSR allocation) was pure waste. Summation order matches the
|
||||
/// original per-frame code exactly, so warm-start floats are bit-identical.
|
||||
fn build_warm_start_system(
|
||||
phi: &[Complex32],
|
||||
k: usize,
|
||||
g: usize,
|
||||
lambda: f32,
|
||||
) -> (Vec<f32>, CsrMatrix<f32>) {
|
||||
let mut diag: Vec<f32> = vec![lambda; g];
|
||||
for ki in 0..k {
|
||||
for gi in 0..g {
|
||||
diag[gi] += phi[ki * g + gi].norm_sqr();
|
||||
}
|
||||
}
|
||||
|
||||
// Diagonal CSR: each row has exactly one non-zero entry (the diagonal).
|
||||
let coo: Vec<(usize, usize, f32)> =
|
||||
diag.iter().enumerate().map(|(i, &v)| (i, i, v)).collect();
|
||||
let a = CsrMatrix::<f32>::from_coo(g, g, coo);
|
||||
(diag, a)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Matrix-vector products
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1022,4 +1167,90 @@ mod tests {
|
||||
let meta = CsiMetadata::new(DeviceId::new("test"), FrequencyBand::Band2_4GHz, 6);
|
||||
CsiFrame::new(meta, data)
|
||||
}
|
||||
|
||||
// ---- Opt-in FFT operator (CirConfig::fft_operator) ----
|
||||
|
||||
/// The FFT operator computes the same Φ/Φᴴ products as the dense path to
|
||||
/// float tolerance, for both a small (HT20) and the largest (HE40) config.
|
||||
#[test]
|
||||
fn fft_matvecs_match_dense() {
|
||||
for config in [CirConfig::ht20(), CirConfig::he40()] {
|
||||
let k = config.num_active;
|
||||
let g = config.num_taps;
|
||||
let active: Vec<i32> = config.active_indices().to_vec();
|
||||
let (phi, phi_h) = build_sensing_matrix(&active, g, k);
|
||||
let op = FftOperator::new(&active, g, k);
|
||||
let mut buf = vec![Complex32::new(0.0, 0.0); g];
|
||||
let mut scratch = vec![Complex32::new(0.0, 0.0); op.scratch_len()];
|
||||
|
||||
// Deterministic non-trivial input vectors.
|
||||
let x: Vec<Complex32> = (0..g)
|
||||
.map(|i| Complex32::new((i as f32 * 0.37).sin(), (i as f32 * 0.71).cos()))
|
||||
.collect();
|
||||
let v: Vec<Complex32> = (0..k)
|
||||
.map(|i| Complex32::new((i as f32 * 0.13).cos(), (i as f32 * 0.29).sin()))
|
||||
.collect();
|
||||
|
||||
// Φx: dense vs FFT.
|
||||
let mut dense_kx = vec![Complex32::new(0.0, 0.0); k];
|
||||
matvec_phi(&phi, &x, g, &mut dense_kx, k);
|
||||
let mut fft_kx = vec![Complex32::new(0.0, 0.0); k];
|
||||
op.matvec_phi(&x, &mut fft_kx, &mut buf, &mut scratch);
|
||||
let scale_ref: f32 = dense_kx.iter().map(|c| c.norm()).sum::<f32>() / k as f32;
|
||||
for (d, f) in dense_kx.iter().zip(&fft_kx) {
|
||||
assert!(
|
||||
(d - f).norm() <= 1e-3 * scale_ref.max(1.0),
|
||||
"phi matvec mismatch (G={g}): {d} vs {f}"
|
||||
);
|
||||
}
|
||||
|
||||
// Φᴴv: dense vs FFT.
|
||||
let mut dense_gv = vec![Complex32::new(0.0, 0.0); g];
|
||||
matvec_phi_h(&phi_h, &v, k, &mut dense_gv, g);
|
||||
let mut fft_gv = vec![Complex32::new(0.0, 0.0); g];
|
||||
op.matvec_phi_h(&v, &mut fft_gv, &mut buf, &mut scratch);
|
||||
let scale_ref_g: f32 = dense_gv.iter().map(|c| c.norm()).sum::<f32>() / g as f32;
|
||||
for (d, f) in dense_gv.iter().zip(&fft_gv) {
|
||||
assert!(
|
||||
(d - f).norm() <= 1e-3 * scale_ref_g.max(1.0),
|
||||
"phi_h matvec mismatch (G={g}): {d} vs {f}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// End-to-end: the FFT-enabled estimator recovers the same dominant tap as
|
||||
/// the dense estimator on a clean single-path frame, with close taps.
|
||||
#[test]
|
||||
fn fft_estimate_matches_dense_dominant_tap() {
|
||||
let dense_cfg = CirConfig::ht20();
|
||||
let mut fft_cfg = CirConfig::ht20();
|
||||
fft_cfg.fft_operator = true;
|
||||
|
||||
let frame = make_single_tap_frame(dense_cfg.num_subcarriers, 50e-9);
|
||||
let dense = CirEstimator::new(dense_cfg).estimate(&frame).unwrap();
|
||||
let fast = CirEstimator::new(fft_cfg).estimate(&frame).unwrap();
|
||||
|
||||
assert_eq!(dense.dominant_tap_idx, fast.dominant_tap_idx);
|
||||
assert!((dense.dominant_tap_ratio - fast.dominant_tap_ratio).abs() < 1e-2);
|
||||
// Tap vectors agree to float tolerance relative to the dominant tap.
|
||||
let dom = dense.taps[dense.dominant_tap_idx].norm().max(1e-6);
|
||||
for (a, b) in dense.taps.iter().zip(&fast.taps) {
|
||||
assert!((a - b).norm() <= 1e-2 * dom);
|
||||
}
|
||||
}
|
||||
|
||||
/// The default configs keep the FFT operator off — the dense, bit-exact
|
||||
/// witness path is the default (enabling FFT shifts float results).
|
||||
#[test]
|
||||
fn fft_operator_is_off_by_default() {
|
||||
for c in [
|
||||
CirConfig::ht20(),
|
||||
CirConfig::ht40(),
|
||||
CirConfig::he20(),
|
||||
CirConfig::he40(),
|
||||
] {
|
||||
assert!(!c.fft_operator);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,6 +276,13 @@ pub struct FieldNormalMode {
|
||||
pub geometry_hash: u64,
|
||||
/// Baseline eigenvalue count above Marcenko-Pastur threshold (empty-room).
|
||||
pub baseline_eigenvalue_count: usize,
|
||||
/// Baseline noise variance estimate (median of bottom-half positive
|
||||
/// eigenvalues from the calibration covariance). Persisted so that
|
||||
/// `estimate_occupancy` can anchor its Marcenko-Pastur threshold to the
|
||||
/// calibration noise floor instead of letting it drift with the
|
||||
/// per-window sample size. Defaults to 0.0 in the diagonal-fallback path.
|
||||
/// Issue #942.
|
||||
pub baseline_noise_var: f64,
|
||||
}
|
||||
|
||||
/// Body perturbation extracted from a CSI observation.
|
||||
@@ -504,7 +511,11 @@ impl FieldModel {
|
||||
let baseline: Vec<Vec<f64>> = self.link_stats.iter().map(|ls| ls.mean_vector()).collect();
|
||||
|
||||
// --- True eigenvalue decomposition (with diagonal fallback) ---
|
||||
let (mode_energies, environmental_modes, baseline_eig_count) =
|
||||
// Returns: (energies, modes, baseline_count, baseline_noise_var).
|
||||
// The noise_var slot is 0.0 in the diagonal-fallback paths; the
|
||||
// estimation hot path treats 0.0 as "no anchored noise floor" and
|
||||
// falls back to per-window noise_var, preserving pre-#942 behavior.
|
||||
let (mode_energies, environmental_modes, baseline_eig_count, baseline_noise_var) =
|
||||
if let Some(ref cov_sum) = self.covariance_sum {
|
||||
if self.covariance_count > 1 {
|
||||
// Compute sample covariance from raw outer products:
|
||||
@@ -588,23 +599,28 @@ impl FieldModel {
|
||||
let baseline_count =
|
||||
eigenvalues.iter().filter(|&&ev| ev > mp_threshold).count();
|
||||
|
||||
(energies, modes, baseline_count)
|
||||
(energies, modes, baseline_count, noise_var)
|
||||
}
|
||||
Err(_) => {
|
||||
// Fallback to diagonal approximation on SVD failure
|
||||
diagonal_fallback(&self.link_stats, n_sc, n_modes)
|
||||
let (e, m, b) =
|
||||
diagonal_fallback(&self.link_stats, n_sc, n_modes);
|
||||
(e, m, b, 0.0_f64)
|
||||
}
|
||||
}
|
||||
// When eigenvalue feature is disabled, use diagonal fallback
|
||||
#[cfg(not(feature = "eigenvalue"))]
|
||||
{
|
||||
diagonal_fallback(&self.link_stats, n_sc, n_modes)
|
||||
let (e, m, b) = diagonal_fallback(&self.link_stats, n_sc, n_modes);
|
||||
(e, m, b, 0.0_f64)
|
||||
}
|
||||
} else {
|
||||
diagonal_fallback(&self.link_stats, n_sc, n_modes)
|
||||
let (e, m, b) = diagonal_fallback(&self.link_stats, n_sc, n_modes);
|
||||
(e, m, b, 0.0_f64)
|
||||
}
|
||||
} else {
|
||||
diagonal_fallback(&self.link_stats, n_sc, n_modes)
|
||||
let (e, m, b) = diagonal_fallback(&self.link_stats, n_sc, n_modes);
|
||||
(e, m, b, 0.0_f64)
|
||||
};
|
||||
|
||||
// Compute variance explained using the same centered covariance as modes.
|
||||
@@ -648,6 +664,7 @@ impl FieldModel {
|
||||
calibrated_at_us: timestamp_us,
|
||||
geometry_hash,
|
||||
baseline_eigenvalue_count: baseline_eig_count,
|
||||
baseline_noise_var,
|
||||
};
|
||||
|
||||
self.modes = Some(field_mode);
|
||||
@@ -794,7 +811,7 @@ impl FieldModel {
|
||||
// Marcenko-Pastur noise estimate: median of POSITIVE eigenvalues
|
||||
// in the bottom half. Excludes zeros from rank-deficient matrices
|
||||
// (common when n_subcarriers > n_frames, e.g. 56 subcarriers / 50 frames).
|
||||
let noise_var = {
|
||||
let local_noise_var = {
|
||||
let mut positive: Vec<f64> =
|
||||
eigenvalues.iter().copied().filter(|&e| e > 1e-10).collect();
|
||||
positive.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
||||
@@ -807,6 +824,22 @@ impl FieldModel {
|
||||
return Ok(0); // All zero eigenvalues — can't estimate
|
||||
}
|
||||
};
|
||||
|
||||
// Issue #942: anchor the noise floor to the calibration's noise_var
|
||||
// when it's available. Per-window noise_var drifts with sample size —
|
||||
// a short estimation window can produce a small local_noise_var that
|
||||
// inflates `significant` and breaks the test_estimate_occupancy_noise_only
|
||||
// invariant. The max of (calibration noise, local noise) keeps the
|
||||
// threshold from collapsing on small windows while still letting the
|
||||
// per-window noise dominate when it's the larger estimate. Falls back
|
||||
// to local_noise_var when baseline_noise_var == 0 (diagonal-fallback
|
||||
// calibration path, or pre-#942 stored modes).
|
||||
let noise_var = if modes.baseline_noise_var > 0.0 {
|
||||
local_noise_var.max(modes.baseline_noise_var)
|
||||
} else {
|
||||
local_noise_var
|
||||
};
|
||||
|
||||
let ratio = n as f64 / count as f64;
|
||||
let mp_threshold = noise_var * (1.0 + ratio.sqrt()).powi(2);
|
||||
|
||||
|
||||
@@ -182,6 +182,8 @@ pub struct RfTomographer {
|
||||
weight_matrix: Vec<Vec<(usize, f64)>>,
|
||||
/// Number of voxels.
|
||||
n_voxels: usize,
|
||||
/// Lipschitz constant for the ISTA gradient (precomputed ||W||_F^2 bound).
|
||||
lipschitz: f64,
|
||||
}
|
||||
|
||||
impl RfTomographer {
|
||||
@@ -222,10 +224,20 @@ impl RfTomographer {
|
||||
return Err(TomographyError::NoIntersections);
|
||||
}
|
||||
|
||||
// Lipschitz upper bound for the ISTA step size: ||W^T W|| <= ||W||_F^2.
|
||||
// Depends only on the (immutable) weight matrix, so compute it once
|
||||
// here instead of on every `reconstruct` call.
|
||||
let frobenius_sq: f64 = weight_matrix
|
||||
.iter()
|
||||
.flat_map(|ws| ws.iter().map(|&(_, w)| w * w))
|
||||
.sum();
|
||||
let lipschitz = frobenius_sq.max(1e-10);
|
||||
|
||||
Ok(Self {
|
||||
config,
|
||||
weight_matrix,
|
||||
n_voxels,
|
||||
lipschitz,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -246,24 +258,16 @@ impl RfTomographer {
|
||||
let mut x = vec![0.0_f64; self.n_voxels];
|
||||
let n_links = attenuations.len();
|
||||
|
||||
// Estimate step size: 1 / L where L is the Lipschitz constant of the
|
||||
// gradient of ||Wx - y||^2, i.e. the spectral norm of W^T W.
|
||||
// A safe upper bound is the Frobenius norm squared of W (sum of all
|
||||
// squared entries), since ||W^T W|| <= ||W||_F^2.
|
||||
let frobenius_sq: f64 = self
|
||||
.weight_matrix
|
||||
.iter()
|
||||
.flat_map(|ws| ws.iter().map(|&(_, w)| w * w))
|
||||
.sum();
|
||||
let lipschitz = frobenius_sq.max(1e-10);
|
||||
let step_size = 1.0 / lipschitz;
|
||||
// Step size 1 / L, with L precomputed in `new` (||W||_F^2 upper bound).
|
||||
let step_size = 1.0 / self.lipschitz;
|
||||
|
||||
let mut residual = 0.0_f64;
|
||||
let mut iterations = 0;
|
||||
let mut gradient = vec![0.0_f64; self.n_voxels];
|
||||
|
||||
for iter in 0..self.config.max_iterations {
|
||||
// Compute gradient: W^T (Wx - y)
|
||||
let mut gradient = vec![0.0_f64; self.n_voxels];
|
||||
gradient.fill(0.0);
|
||||
residual = 0.0;
|
||||
|
||||
for (link_idx, weights) in self.weight_matrix.iter().enumerate() {
|
||||
|
||||
@@ -70,6 +70,9 @@ pub mod proof;
|
||||
|
||||
/// ADR-145 — ablation evaluation harness (feature matrix + privacy/latency metrics).
|
||||
pub mod ablation;
|
||||
/// Falsifiable occupancy/presence benchmark (real-CSI gate: provenance,
|
||||
/// leak-free split, bootstrap-CI thresholds; refuses claims on synthetic/mock).
|
||||
pub mod occupancy_bench;
|
||||
#[cfg(feature = "tch-backend")]
|
||||
pub mod trainer;
|
||||
|
||||
|
||||
@@ -0,0 +1,668 @@
|
||||
//! Falsifiable occupancy / presence benchmark over labeled CSI sequences.
|
||||
//!
|
||||
//! The beyond-SOTA system review found that "beyond SOTA" was *unfalsifiable*:
|
||||
//! no real-CSI ground-truth benchmark existed, and the eval pyramid (doc 03)
|
||||
//! lists the field's recurring measurement frauds — subject leakage between
|
||||
//! train/test, per-environment overfitting, and **mock-mode contamination**
|
||||
//! (CLAUDE.md: mock missed a real Kconfig bug).
|
||||
//!
|
||||
//! This module makes the claim falsifiable. It **grades** predictions against
|
||||
//! ground truth (it does not run a model — keeping the eval crate light and the
|
||||
//! scoring model-agnostic), and it enforces, *structurally*, the discipline
|
||||
//! that prevents overclaiming:
|
||||
//!
|
||||
//! 1. **No SOTA claim on non-measured data.** A dataset is tagged
|
||||
//! [`DataProvenance`]; only [`DataProvenance::Measured`] can release a claim.
|
||||
//! Synthetic/Mock data can still be scored (useful for CI/regression) but the
|
||||
//! [`ClaimGate`] returns [`NO_CLAIM`] — you cannot accidentally publish a
|
||||
//! "beyond SOTA" number computed on simulated CSI.
|
||||
//! 2. **No leaky splits.** [`EvalSplit::validate`] refuses a split where any
|
||||
//! subject *or* environment id appears in both train and test.
|
||||
//! 3. **Pre-registered thresholds + bootstrap CI.** The gate compares the
|
||||
//! *lower* bound of a deterministic 95% bootstrap CI, not the point estimate,
|
||||
//! so a lucky small-sample result cannot pass.
|
||||
//! 4. **No degenerate test sets.** The test set must contain *both* truth
|
||||
//! classes (present-rate ≥ `min_positive_rate`, and at least one absent
|
||||
//! sample), with its own failure flag — an all-absent set plus an
|
||||
//! always-absent predictor must never release a claim. Vacuous F1 (no
|
||||
//! positives anywhere in the confusion) scores **0.0**, never 1.0.
|
||||
//!
|
||||
//! The harness is the same shape as the `ruview-gamma` acceptance gate: a single
|
||||
//! `claim_allowed` invariant, and the claim string is unreadable except through
|
||||
//! the gate.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
/// Provenance of the labeled data a benchmark runs on. Gates whether a SOTA
|
||||
/// claim is releasable at all.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DataProvenance {
|
||||
/// Real CSI captured from hardware with independent ground truth. The only
|
||||
/// provenance that can release a claim.
|
||||
Measured,
|
||||
/// Deterministic synthetic CSI (e.g. the proof generator). Scorable for
|
||||
/// regression, never claimable.
|
||||
Synthetic,
|
||||
/// Mock/stub data path. Scorable, never claimable — mock contamination is a
|
||||
/// documented failure mode (CLAUDE.md Kconfig-bug lesson).
|
||||
Mock,
|
||||
}
|
||||
|
||||
impl DataProvenance {
|
||||
/// Whether data of this provenance may ever release a SOTA/accuracy claim.
|
||||
pub fn is_claimable(self) -> bool {
|
||||
matches!(self, DataProvenance::Measured)
|
||||
}
|
||||
|
||||
/// Stable lowercase tag for logs/reports.
|
||||
pub fn tag(self) -> &'static str {
|
||||
match self {
|
||||
DataProvenance::Measured => "measured",
|
||||
DataProvenance::Synthetic => "synthetic",
|
||||
DataProvenance::Mock => "mock",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The research-only string returned when a claim is withheld.
|
||||
pub const NO_CLAIM: &str = "research use only — not claimable (non-measured data, leaky split, or unmet thresholds)";
|
||||
|
||||
/// Ground-truth / predicted occupancy for one sample.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Occupancy {
|
||||
/// Whether any person is present.
|
||||
pub present: bool,
|
||||
/// Estimated number of people.
|
||||
pub person_count: u32,
|
||||
}
|
||||
|
||||
impl Occupancy {
|
||||
/// Construct an occupancy label.
|
||||
pub fn new(present: bool, person_count: u32) -> Self {
|
||||
Self { present, person_count }
|
||||
}
|
||||
}
|
||||
|
||||
/// One labeled, attributed evaluation sample: who/where it came from (for
|
||||
/// leakage checks) and the ground-truth vs predicted occupancy.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LabeledSample {
|
||||
/// Subject identity (for subject-disjoint split enforcement).
|
||||
pub subject_id: String,
|
||||
/// Capture environment/room (for environment-disjoint split enforcement).
|
||||
pub environment_id: String,
|
||||
/// Ground-truth occupancy.
|
||||
pub truth: Occupancy,
|
||||
/// Model-predicted occupancy.
|
||||
pub predicted: Occupancy,
|
||||
}
|
||||
|
||||
/// A train/test split by sample index, with leakage validation.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EvalSplit {
|
||||
/// Indices of training samples.
|
||||
pub train_idx: Vec<usize>,
|
||||
/// Indices of held-out test samples (graded).
|
||||
pub test_idx: Vec<usize>,
|
||||
}
|
||||
|
||||
/// Why a split is rejected.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum SplitError {
|
||||
/// A subject id appears in both train and test (subject leakage).
|
||||
SubjectLeakage(String),
|
||||
/// An environment id appears in both (per-environment overfitting risk).
|
||||
EnvironmentLeakage(String),
|
||||
/// An index is out of range for the sample set.
|
||||
IndexOutOfRange(usize),
|
||||
/// The test set is empty.
|
||||
EmptyTest,
|
||||
}
|
||||
|
||||
impl EvalSplit {
|
||||
/// Validate the split against `samples`: every test subject/environment must
|
||||
/// be **disjoint** from the training set. This is the single most common
|
||||
/// way WiFi-sensing papers overstate accuracy (doc 03).
|
||||
pub fn validate(&self, samples: &[LabeledSample]) -> Result<(), SplitError> {
|
||||
if self.test_idx.is_empty() {
|
||||
return Err(SplitError::EmptyTest);
|
||||
}
|
||||
for &i in self.train_idx.iter().chain(&self.test_idx) {
|
||||
if i >= samples.len() {
|
||||
return Err(SplitError::IndexOutOfRange(i));
|
||||
}
|
||||
}
|
||||
let train_subjects: BTreeSet<&str> =
|
||||
self.train_idx.iter().map(|&i| samples[i].subject_id.as_str()).collect();
|
||||
let train_envs: BTreeSet<&str> =
|
||||
self.train_idx.iter().map(|&i| samples[i].environment_id.as_str()).collect();
|
||||
for &i in &self.test_idx {
|
||||
let s = &samples[i];
|
||||
if train_subjects.contains(s.subject_id.as_str()) {
|
||||
return Err(SplitError::SubjectLeakage(s.subject_id.clone()));
|
||||
}
|
||||
if train_envs.contains(s.environment_id.as_str()) {
|
||||
return Err(SplitError::EnvironmentLeakage(s.environment_id.clone()));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Pre-registered acceptance thresholds (doc 03 acceptance table). Defaults are
|
||||
/// deliberately conservative; tighten per capability axis.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct BenchmarkCriteria {
|
||||
/// Minimum presence F1 (lower CI bound must clear this).
|
||||
pub min_presence_f1: f64,
|
||||
/// Maximum person-count mean absolute error.
|
||||
pub max_count_mae: f64,
|
||||
/// Minimum test samples to grade at all (small-N guard).
|
||||
pub min_test_samples: usize,
|
||||
/// Minimum fraction of ground-truth **present** samples in the test set
|
||||
/// (degenerate-test-set guard, review finding 2): an all-absent (or
|
||||
/// nearly all-absent) test set makes presence F1 vacuous — an
|
||||
/// always-absent predictor must not be able to release a claim. The gate
|
||||
/// additionally requires at least one ground-truth *absent* sample, so
|
||||
/// both classes must be represented.
|
||||
pub min_positive_rate: f64,
|
||||
/// Bootstrap resamples for the CI.
|
||||
pub bootstrap_iters: usize,
|
||||
/// Deterministic bootstrap seed.
|
||||
pub bootstrap_seed: u64,
|
||||
}
|
||||
|
||||
impl Default for BenchmarkCriteria {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
min_presence_f1: 0.9,
|
||||
max_count_mae: 0.5,
|
||||
min_test_samples: 30,
|
||||
min_positive_rate: 0.1,
|
||||
bootstrap_iters: 1000,
|
||||
bootstrap_seed: 42,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The graded result.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct BenchmarkReport {
|
||||
/// Data provenance tag (`measured`/`synthetic`/`mock`).
|
||||
pub provenance_tag: &'static str,
|
||||
/// Number of held-out test samples graded.
|
||||
pub n_test: usize,
|
||||
/// Presence accuracy (TP+TN)/N.
|
||||
pub presence_accuracy: f64,
|
||||
/// Presence F1 (point estimate).
|
||||
pub presence_f1: f64,
|
||||
/// 95% bootstrap CI for presence F1 (lower, upper).
|
||||
pub presence_f1_ci: (f64, f64),
|
||||
/// Fraction of samples with an exactly correct person count.
|
||||
pub count_exact_match: f64,
|
||||
/// Person-count mean absolute error.
|
||||
pub count_mae: f64,
|
||||
/// Data is measured (claimable provenance).
|
||||
pub provenance_pass: bool,
|
||||
/// Split is leak-free (subject- and environment-disjoint).
|
||||
pub split_pass: bool,
|
||||
/// Presence F1 CI-lower clears the threshold.
|
||||
pub presence_pass: bool,
|
||||
/// Count MAE within the threshold.
|
||||
pub count_pass: bool,
|
||||
/// Test set is large enough to grade.
|
||||
pub sample_size_pass: bool,
|
||||
/// Test set contains both truth classes with at least `min_positive_rate`
|
||||
/// present-true samples (degenerate test set ⇒ fail, own failure reason).
|
||||
pub class_balance_pass: bool,
|
||||
/// All six criteria pass.
|
||||
pub overall_pass: bool,
|
||||
/// The released claim string (or [`NO_CLAIM`]).
|
||||
pub released_claim: String,
|
||||
}
|
||||
|
||||
impl BenchmarkReport {
|
||||
/// The released claim string (program claim on pass, [`NO_CLAIM`] on fail).
|
||||
pub fn claim(&self) -> &str {
|
||||
&self.released_claim
|
||||
}
|
||||
}
|
||||
|
||||
/// **The single claim invariant.** A SOTA/accuracy claim is releasable only when
|
||||
/// the data is measured, the split is leak-free, the sample is large enough,
|
||||
/// the test set is non-degenerate (both classes represented), and both the
|
||||
/// (CI-lower) presence F1 and the count MAE clear their thresholds.
|
||||
#[inline]
|
||||
pub fn claim_allowed(
|
||||
provenance_pass: bool,
|
||||
split_pass: bool,
|
||||
sample_size_pass: bool,
|
||||
class_balance_pass: bool,
|
||||
presence_pass: bool,
|
||||
count_pass: bool,
|
||||
) -> bool {
|
||||
provenance_pass
|
||||
&& split_pass
|
||||
&& sample_size_pass
|
||||
&& class_balance_pass
|
||||
&& presence_pass
|
||||
&& count_pass
|
||||
}
|
||||
|
||||
/// Grade the test split of `samples` under `criteria`.
|
||||
///
|
||||
/// `split` is validated first; on any leakage the report is marked invalid and
|
||||
/// the claim is withheld (metrics are still computed for visibility).
|
||||
pub fn evaluate(
|
||||
samples: &[LabeledSample],
|
||||
provenance: DataProvenance,
|
||||
split: &EvalSplit,
|
||||
criteria: &BenchmarkCriteria,
|
||||
) -> BenchmarkReport {
|
||||
let split_pass = split.validate(samples).is_ok();
|
||||
let test: Vec<&LabeledSample> = split
|
||||
.test_idx
|
||||
.iter()
|
||||
.filter(|&&i| i < samples.len())
|
||||
.map(|&i| &samples[i])
|
||||
.collect();
|
||||
let n_test = test.len();
|
||||
|
||||
// Presence confusion counts.
|
||||
let (mut tp, mut fp, mut tn, mut fn_) = (0u64, 0u64, 0u64, 0u64);
|
||||
let mut count_abs_err_sum = 0.0;
|
||||
let mut count_exact = 0u64;
|
||||
let mut truth_present = 0u64;
|
||||
for s in &test {
|
||||
if s.truth.present {
|
||||
truth_present += 1;
|
||||
}
|
||||
match (s.predicted.present, s.truth.present) {
|
||||
(true, true) => tp += 1,
|
||||
(true, false) => fp += 1,
|
||||
(false, false) => tn += 1,
|
||||
(false, true) => fn_ += 1,
|
||||
}
|
||||
count_abs_err_sum +=
|
||||
(s.predicted.person_count as f64 - s.truth.person_count as f64).abs();
|
||||
if s.predicted.person_count == s.truth.person_count {
|
||||
count_exact += 1;
|
||||
}
|
||||
}
|
||||
let presence_accuracy = if n_test > 0 {
|
||||
(tp + tn) as f64 / n_test as f64
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let presence_f1 = f1_from_confusion(tp, fp, fn_);
|
||||
let count_mae = if n_test > 0 {
|
||||
count_abs_err_sum / n_test as f64
|
||||
} else {
|
||||
f64::INFINITY
|
||||
};
|
||||
let count_exact_match = if n_test > 0 {
|
||||
count_exact as f64 / n_test as f64
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let presence_f1_ci = bootstrap_f1_ci(&test, criteria.bootstrap_iters, criteria.bootstrap_seed);
|
||||
|
||||
let provenance_pass = provenance.is_claimable();
|
||||
let sample_size_pass = n_test >= criteria.min_test_samples;
|
||||
// Degenerate-test-set guard (review finding 2): both truth classes must be
|
||||
// represented — at least `min_positive_rate` present samples AND at least
|
||||
// one absent sample. Otherwise the F1/accuracy numbers are vacuous (an
|
||||
// all-absent set is aced by a predictor that always says "absent").
|
||||
let positive_rate = if n_test > 0 {
|
||||
truth_present as f64 / n_test as f64
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let class_balance_pass =
|
||||
n_test > 0 && positive_rate >= criteria.min_positive_rate && truth_present < n_test as u64;
|
||||
// Gate on the LOWER CI bound, not the point estimate (small-N guard).
|
||||
let presence_pass = presence_f1_ci.0 >= criteria.min_presence_f1;
|
||||
let count_pass = count_mae <= criteria.max_count_mae;
|
||||
let overall_pass = claim_allowed(
|
||||
provenance_pass,
|
||||
split_pass,
|
||||
sample_size_pass,
|
||||
class_balance_pass,
|
||||
presence_pass,
|
||||
count_pass,
|
||||
);
|
||||
|
||||
let released_claim = if overall_pass {
|
||||
format!(
|
||||
"presence F1 {:.3} (95% CI {:.3}-{:.3}), count MAE {:.3} on {} held-out measured samples",
|
||||
presence_f1, presence_f1_ci.0, presence_f1_ci.1, count_mae, n_test
|
||||
)
|
||||
} else {
|
||||
NO_CLAIM.to_string()
|
||||
};
|
||||
|
||||
BenchmarkReport {
|
||||
provenance_tag: provenance.tag(),
|
||||
n_test,
|
||||
presence_accuracy,
|
||||
presence_f1,
|
||||
presence_f1_ci,
|
||||
count_exact_match,
|
||||
count_mae,
|
||||
provenance_pass,
|
||||
split_pass,
|
||||
presence_pass,
|
||||
count_pass,
|
||||
sample_size_pass,
|
||||
class_balance_pass,
|
||||
overall_pass,
|
||||
released_claim,
|
||||
}
|
||||
}
|
||||
|
||||
fn f1_from_confusion(tp: u64, fp: u64, fn_: u64) -> f64 {
|
||||
let denom = 2 * tp + fp + fn_;
|
||||
if denom == 0 {
|
||||
// No positives anywhere (tp = fp = fn = 0): F1 is undefined, and the
|
||||
// vacuous case must score 0.0, never 1.0 — an all-absent test set plus
|
||||
// an always-absent predictor was previously awarded a perfect F1
|
||||
// (review finding 2). The class-balance criterion independently fails
|
||||
// such a degenerate set with its own reason.
|
||||
return 0.0;
|
||||
}
|
||||
(2 * tp) as f64 / denom as f64
|
||||
}
|
||||
|
||||
/// Deterministic 95% bootstrap CI for presence F1 (percentile method) using a
|
||||
/// small splitmix64 PRNG — no external rng, reproducible across machines.
|
||||
fn bootstrap_f1_ci(test: &[&LabeledSample], iters: usize, seed: u64) -> (f64, f64) {
|
||||
let n = test.len();
|
||||
if n == 0 || iters == 0 {
|
||||
return (0.0, 0.0);
|
||||
}
|
||||
let mut state = seed;
|
||||
let mut next = || {
|
||||
// splitmix64
|
||||
state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
|
||||
let mut z = state;
|
||||
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
|
||||
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
|
||||
z ^ (z >> 31)
|
||||
};
|
||||
let mut f1s = Vec::with_capacity(iters);
|
||||
for _ in 0..iters {
|
||||
let (mut tp, mut fp, mut fn_) = (0u64, 0u64, 0u64);
|
||||
for _ in 0..n {
|
||||
let idx = (next() % n as u64) as usize;
|
||||
let s = test[idx];
|
||||
match (s.predicted.present, s.truth.present) {
|
||||
(true, true) => tp += 1,
|
||||
(true, false) => fp += 1,
|
||||
(false, true) => fn_ += 1,
|
||||
(false, false) => {}
|
||||
}
|
||||
}
|
||||
f1s.push(f1_from_confusion(tp, fp, fn_));
|
||||
}
|
||||
f1s.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
||||
let pct = |q: f64| {
|
||||
let rank = ((q * (f1s.len() as f64 - 1.0)).round() as usize).min(f1s.len() - 1);
|
||||
f1s[rank]
|
||||
};
|
||||
(pct(0.025), pct(0.975))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sample(subj: &str, env: &str, t: (bool, u32), p: (bool, u32)) -> LabeledSample {
|
||||
LabeledSample {
|
||||
subject_id: subj.into(),
|
||||
environment_id: env.into(),
|
||||
truth: Occupancy::new(t.0, t.1),
|
||||
predicted: Occupancy::new(p.0, p.1),
|
||||
}
|
||||
}
|
||||
|
||||
/// A perfect predictor on a leak-free MEASURED split releases a claim.
|
||||
fn perfect_measured(n: usize) -> (Vec<LabeledSample>, EvalSplit) {
|
||||
let mut samples = Vec::new();
|
||||
// train subjects s0.., test subjects t0.. (disjoint); envs likewise.
|
||||
for i in 0..n {
|
||||
samples.push(sample(
|
||||
&format!("train-s{i}"),
|
||||
&format!("train-e{i}"),
|
||||
(i % 2 == 0, (i % 3) as u32),
|
||||
(i % 2 == 0, (i % 3) as u32),
|
||||
));
|
||||
}
|
||||
for i in 0..n {
|
||||
samples.push(sample(
|
||||
&format!("test-s{i}"),
|
||||
&format!("test-e{i}"),
|
||||
(i % 2 == 0, (i % 3) as u32),
|
||||
(i % 2 == 0, (i % 3) as u32),
|
||||
));
|
||||
}
|
||||
let split = EvalSplit {
|
||||
train_idx: (0..n).collect(),
|
||||
test_idx: (n..2 * n).collect(),
|
||||
};
|
||||
(samples, split)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn perfect_measured_releases_claim() {
|
||||
let (samples, split) = perfect_measured(40);
|
||||
let r = evaluate(&samples, DataProvenance::Measured, &split, &BenchmarkCriteria::default());
|
||||
assert!(r.overall_pass);
|
||||
assert!((r.presence_f1 - 1.0).abs() < 1e-9);
|
||||
assert_eq!(r.count_mae, 0.0);
|
||||
assert!(r.released_claim.contains("F1"));
|
||||
assert!(!r.released_claim.contains("research use only"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synthetic_data_is_scored_but_never_claimed() {
|
||||
let (samples, split) = perfect_measured(40);
|
||||
let r = evaluate(&samples, DataProvenance::Synthetic, &split, &BenchmarkCriteria::default());
|
||||
// Metrics are still computed...
|
||||
assert!((r.presence_f1 - 1.0).abs() < 1e-9);
|
||||
// ...but no claim, because the data is not measured.
|
||||
assert!(!r.provenance_pass);
|
||||
assert!(!r.overall_pass);
|
||||
assert_eq!(r.claim(), NO_CLAIM);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mock_data_is_never_claimed() {
|
||||
let (samples, split) = perfect_measured(40);
|
||||
let r = evaluate(&samples, DataProvenance::Mock, &split, &BenchmarkCriteria::default());
|
||||
assert!(!r.provenance_pass);
|
||||
assert_eq!(r.claim(), NO_CLAIM);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subject_leakage_is_rejected() {
|
||||
// Same subject id in train and test.
|
||||
let samples = vec![
|
||||
sample("shared", "e0", (true, 1), (true, 1)),
|
||||
sample("shared", "e1", (true, 1), (true, 1)),
|
||||
];
|
||||
let split = EvalSplit { train_idx: vec![0], test_idx: vec![1] };
|
||||
assert_eq!(
|
||||
split.validate(&samples),
|
||||
Err(SplitError::SubjectLeakage("shared".into()))
|
||||
);
|
||||
let r = evaluate(&samples, DataProvenance::Measured, &split, &BenchmarkCriteria::default());
|
||||
assert!(!r.split_pass);
|
||||
assert!(!r.overall_pass);
|
||||
assert_eq!(r.claim(), NO_CLAIM);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn environment_leakage_is_rejected() {
|
||||
let samples = vec![
|
||||
sample("s0", "shared-room", (true, 1), (true, 1)),
|
||||
sample("s1", "shared-room", (true, 1), (true, 1)),
|
||||
];
|
||||
let split = EvalSplit { train_idx: vec![0], test_idx: vec![1] };
|
||||
assert_eq!(
|
||||
split.validate(&samples),
|
||||
Err(SplitError::EnvironmentLeakage("shared-room".into()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn small_sample_is_withheld_even_if_perfect() {
|
||||
let (samples, split) = perfect_measured(5); // 5 < default min 30
|
||||
let r = evaluate(&samples, DataProvenance::Measured, &split, &BenchmarkCriteria::default());
|
||||
assert!(!r.sample_size_pass);
|
||||
assert!(!r.overall_pass);
|
||||
}
|
||||
|
||||
/// The probative CI-gate case (review finding 10): a test set whose POINT
|
||||
/// F1 clears the 0.9 threshold while the bootstrap CI LOWER bound falls
|
||||
/// below it — the claim must be withheld. A point-estimate gate would
|
||||
/// (wrongly) release here.
|
||||
#[test]
|
||||
fn gate_uses_ci_lower_bound_not_point_estimate() {
|
||||
let mut samples = Vec::new();
|
||||
for i in 0..40 {
|
||||
samples.push(sample(
|
||||
&format!("train-{i}"),
|
||||
&format!("te-{i}"),
|
||||
(i % 2 == 0, 1),
|
||||
(i % 2 == 0, 1),
|
||||
));
|
||||
}
|
||||
// Test: 20 truth-present / 20 truth-absent (class-balanced). All
|
||||
// absents predicted correctly; 3 of the 20 presents missed (FN).
|
||||
// Point F1 = 2·17/(2·17 + 0 + 3) = 34/37 ≈ 0.919 ≥ 0.9, but resamples
|
||||
// drawing 4+ of the FNs push F1 below 0.9, so the 2.5th percentile
|
||||
// lands under the threshold.
|
||||
for i in 0..40 {
|
||||
let truth_present = i < 20;
|
||||
let predicted_present = truth_present && i >= 3; // i 0..3 → FN
|
||||
samples.push(sample(
|
||||
&format!("test-{i}"),
|
||||
&format!("tn-{i}"),
|
||||
(truth_present, u32::from(truth_present)),
|
||||
(predicted_present, u32::from(truth_present)),
|
||||
));
|
||||
}
|
||||
let split = EvalSplit { train_idx: (0..40).collect(), test_idx: (40..80).collect() };
|
||||
let criteria = BenchmarkCriteria::default();
|
||||
let r = evaluate(&samples, DataProvenance::Measured, &split, &criteria);
|
||||
// Construct verified: point estimate above the threshold...
|
||||
assert!(
|
||||
r.presence_f1 >= criteria.min_presence_f1,
|
||||
"fixture must put the point estimate ({:.3}) above the threshold",
|
||||
r.presence_f1
|
||||
);
|
||||
// ...while the CI lower bound is below it...
|
||||
assert!(
|
||||
r.presence_f1_ci.0 < criteria.min_presence_f1,
|
||||
"fixture must put the CI lower bound ({:.3}) below the threshold",
|
||||
r.presence_f1_ci.0
|
||||
);
|
||||
// ...and the claim is therefore withheld.
|
||||
assert!(!r.presence_pass);
|
||||
assert!(!r.overall_pass);
|
||||
assert_eq!(r.claim(), NO_CLAIM);
|
||||
// Every other criterion passes, isolating the CI gate as the cause.
|
||||
assert!(r.provenance_pass && r.split_pass && r.sample_size_pass);
|
||||
assert!(r.class_balance_pass && r.count_pass);
|
||||
}
|
||||
|
||||
/// Degenerate test set (review finding 2): all-absent ground truth plus an
|
||||
/// always-absent predictor must NOT release a claim — F1 is vacuous (0.0,
|
||||
/// not 1.0) and the class-balance criterion fails with its own flag.
|
||||
#[test]
|
||||
fn all_absent_test_set_is_degenerate_and_withheld() {
|
||||
let mut samples = Vec::new();
|
||||
for i in 0..40 {
|
||||
samples.push(sample(&format!("tr-{i}"), &format!("te-{i}"), (true, 1), (true, 1)));
|
||||
}
|
||||
for i in 0..40 {
|
||||
// Truth all absent; predictor always says absent → tp=fp=fn=0.
|
||||
samples.push(sample(&format!("ts-{i}"), &format!("ev-{i}"), (false, 0), (false, 0)));
|
||||
}
|
||||
let split = EvalSplit { train_idx: (0..40).collect(), test_idx: (40..80).collect() };
|
||||
let r = evaluate(&samples, DataProvenance::Measured, &split, &BenchmarkCriteria::default());
|
||||
// Vacuous F1 scores 0.0 (was 1.0 before the fix).
|
||||
assert_eq!(r.presence_f1, 0.0);
|
||||
assert_eq!(r.presence_f1_ci, (0.0, 0.0));
|
||||
// Degeneracy is named as its own failed criterion.
|
||||
assert!(!r.class_balance_pass);
|
||||
assert!(!r.overall_pass);
|
||||
assert_eq!(r.claim(), NO_CLAIM);
|
||||
}
|
||||
|
||||
/// The mirror degeneracy: an all-PRESENT test set (no absent samples) is
|
||||
/// also refused — a trivially always-present predictor would ace it.
|
||||
#[test]
|
||||
fn all_present_test_set_is_degenerate_and_withheld() {
|
||||
let mut samples = Vec::new();
|
||||
for i in 0..40 {
|
||||
samples.push(sample(&format!("tr-{i}"), &format!("te-{i}"), (i % 2 == 0, 1), (i % 2 == 0, 1)));
|
||||
}
|
||||
for i in 0..40 {
|
||||
samples.push(sample(&format!("ts-{i}"), &format!("ev-{i}"), (true, 1), (true, 1)));
|
||||
}
|
||||
let split = EvalSplit { train_idx: (0..40).collect(), test_idx: (40..80).collect() };
|
||||
let r = evaluate(&samples, DataProvenance::Measured, &split, &BenchmarkCriteria::default());
|
||||
assert!((r.presence_f1 - 1.0).abs() < 1e-9, "metric still computed");
|
||||
assert!(!r.class_balance_pass, "single-class test set is degenerate");
|
||||
assert!(!r.overall_pass);
|
||||
assert_eq!(r.claim(), NO_CLAIM);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bootstrap_ci_is_deterministic() {
|
||||
let (samples, split) = perfect_measured(40);
|
||||
let a = evaluate(&samples, DataProvenance::Measured, &split, &BenchmarkCriteria::default());
|
||||
let b = evaluate(&samples, DataProvenance::Measured, &split, &BenchmarkCriteria::default());
|
||||
assert_eq!(a.presence_f1_ci, b.presence_f1_ci);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn count_mae_failure_withholds_claim() {
|
||||
let mut samples = Vec::new();
|
||||
for i in 0..40 {
|
||||
samples.push(sample(&format!("tr-{i}"), &format!("te-{i}"), (true, 1), (true, 1)));
|
||||
}
|
||||
// Class-balanced test set (so count MAE is the ONLY failing criterion):
|
||||
// presence perfect, but the count is always off by 2 -> MAE 2.0 > 0.5.
|
||||
for i in 0..40 {
|
||||
let present = i % 2 == 0;
|
||||
let truth_count = u32::from(present);
|
||||
samples.push(sample(
|
||||
&format!("ts-{i}"),
|
||||
&format!("ev-{i}"),
|
||||
(present, truth_count),
|
||||
(present, truth_count + 2),
|
||||
));
|
||||
}
|
||||
let split = EvalSplit { train_idx: (0..40).collect(), test_idx: (40..80).collect() };
|
||||
let r = evaluate(&samples, DataProvenance::Measured, &split, &BenchmarkCriteria::default());
|
||||
assert!(r.presence_pass);
|
||||
assert!(r.class_balance_pass);
|
||||
assert!(!r.count_pass);
|
||||
assert!(!r.overall_pass);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claim_invariant_requires_all_six() {
|
||||
assert!(claim_allowed(true, true, true, true, true, true));
|
||||
// Every single-false combination is denied.
|
||||
for i in 0..6 {
|
||||
let v: Vec<bool> = (0..6).map(|j| j != i).collect();
|
||||
assert!(
|
||||
!claim_allowed(v[0], v[1], v[2], v[3], v[4], v[5]),
|
||||
"criterion {i} false must deny the claim"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
[package]
|
||||
name = "wifi-densepose-worldgraph"
|
||||
description = "ADR-139 — WorldGraph environmental digital twin (typed petgraph) for RuView"
|
||||
version = "0.3.0"
|
||||
readme = "README.md"
|
||||
version = "0.3.1"
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
# wifi-densepose-worldgraph
|
||||
|
||||
**The environmental digital twin for RF sensing — a typed, evidence-tracked graph of a building and the people in it.**
|
||||
|
||||
[](https://crates.io/crates/wifi-densepose-worldgraph)
|
||||
[](https://docs.rs/wifi-densepose-worldgraph)
|
||||
|
||||
Part of the [RuView / WiFi-DensePose](https://github.com/ruvnet/RuView) project. Implements **ADR-139**.
|
||||
|
||||
---
|
||||
|
||||
## What it is (plain language)
|
||||
|
||||
When you sense a space with WiFi/RF (people, motion, vital signs), you get a firehose of *frames*.
|
||||
What you actually want is a **living map**: which rooms exist, where the walls and doorways are, which
|
||||
sensors watch which zones, where each person is right now, and *why the system believes that* — with
|
||||
enough structure to reason over and enough provenance to trust.
|
||||
|
||||
`wifi-densepose-worldgraph` is that map. It's a **typed graph** (built on [`petgraph`](https://crates.io/crates/petgraph)):
|
||||
|
||||
- **Nodes** are real things — `Room`, `Zone`, `Wall`, `Doorway`, `Sensor`, `RfLink`, `PersonTrack`, `ObjectAnchor`, `Event`, and `SemanticState` (a belief).
|
||||
- **Edges** are typed relations — `Observes`, `LocatedIn`, `AdjacentTo`, `Supports`, `Contradicts`, `DerivedFrom`, `PrivacyLimitedBy`.
|
||||
|
||||
It stores **fused beliefs, not raw frames** — it sits *downstream* of signal fusion and *upstream* of the
|
||||
semantic/agent layer. Every belief (`SemanticState`) is required to carry **provenance**: the signal
|
||||
evidence, the model, the calibration id, and the privacy decision that produced it. That's enforced
|
||||
*structurally*, so "where did this conclusion come from?" always has an answer.
|
||||
|
||||
## Why a graph (and not an occupancy grid or an event log)?
|
||||
|
||||
| Approach | Good at | Misses |
|
||||
|---|---|---|
|
||||
| **Raw event log** | append-only history, audit | no structure; can't ask "who's in the kitchen?" without re-deriving it |
|
||||
| **Occupancy grid / voxels** | dense geometry, ML input | no identity, no relations, no provenance, no semantics |
|
||||
| **Scene graph (this crate)** | relations, identity, semantics, provenance, privacy | not a dense field — pair it with a grid for ML (see [`wifi-densepose-worldmodel`](https://crates.io/crates/wifi-densepose-worldmodel)) |
|
||||
|
||||
The graph is the **symbolic, interpretable** layer. It answers *relational* questions ("is this person in a
|
||||
zone observed by sensor X?", "are these two beliefs contradictory?") in O(neighbors), and it keeps the
|
||||
*why* attached to every *what*.
|
||||
|
||||
## Features
|
||||
|
||||
- 🧱 **Typed node/edge model** — a closed `enum` schema (serde-tagged) → deterministic, schema-versioned wire format.
|
||||
- 🧭 **Geometry in ENU meters** — rooms/zones/walls/doorways carry East-North-Up bounds; walls carry `rf_attenuation_db`.
|
||||
- 🧠 **Beliefs with mandatory provenance** — `SemanticState` → `SemanticProvenance { signal evidence, model, calibration_id, privacy_decision }`.
|
||||
- 🔀 **Evidence reasoning built in** — `Supports` / `Contradicts` / `DerivedFrom` edges let you score and challenge conclusions, not just store them.
|
||||
- 🔒 **Privacy as a first-class edge** — `PrivacyLimitedBy` + `apply_privacy_mode()` roll up what a given mode/action is allowed to see.
|
||||
- 💾 **Deterministic JSON persistence** — `to_json` / `from_json` (the RVF payload), schema-versioned.
|
||||
- 🚫 **`#![forbid(unsafe_code)]`**, `missing_docs = warn`. Pure Rust, no async, edge-deployable (builds clean on aarch64 — runs on a Raspberry Pi).
|
||||
|
||||
## Install
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
wifi-densepose-worldgraph = "0.3"
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```rust
|
||||
use wifi_densepose_worldgraph::{WorldGraph, WorldNode, WorldEdge, ZoneBoundsEnu};
|
||||
// (GeoRegistration comes from wifi-densepose-geo — it anchors ENU to a real lat/lon origin)
|
||||
|
||||
let mut wg = WorldGraph::new(registration);
|
||||
|
||||
// Add a room and a sensor that observes it.
|
||||
let living_room = wg.upsert_node(WorldNode::Room {
|
||||
id: Default::default(),
|
||||
area_id: Some("living_room".into()),
|
||||
name: "Living Room".into(),
|
||||
bounds_enu: ZoneBoundsEnu { /* … */ },
|
||||
floor: 0,
|
||||
});
|
||||
let sensor = wg.upsert_node(/* WorldNode::Sensor { … } */);
|
||||
wg.add_edge(sensor, living_room, WorldEdge::Observes { quality: 0.9, last_seen_unix_ms: now });
|
||||
|
||||
// Query relations.
|
||||
let watched = wg.observed_by(sensor); // what this sensor sees
|
||||
let room = wg.room_for_area("living_room"); // area_id → room node
|
||||
|
||||
// Record a belief WITH provenance, and a contradiction against it.
|
||||
wg.add_semantic_state(/* state + SemanticProvenance */);
|
||||
wg.add_contradiction(belief_a, belief_b, /* magnitude */, "two sensors disagree");
|
||||
|
||||
// Privacy rollup for a mode/action, then persist.
|
||||
let rollup = wg.apply_privacy_mode("HOME", "occworld_inference", |node| /* allow? */ true);
|
||||
let bytes = wg.to_json()?; // RVF payload
|
||||
let restored = WorldGraph::from_json(&bytes)?;
|
||||
```
|
||||
|
||||
## Technical details
|
||||
|
||||
- **Backing store:** `petgraph::StableDiGraph` (stable indices across removals) wrapped as `WorldGraph`.
|
||||
- **Identity:** every node has a `WorldId`; `upsert_node` is idempotent on identity.
|
||||
- **Snapshots:** `snapshot()` → `WorldGraphSnapshot` (a serializable point-in-time view) with a `PrivacyRollup`.
|
||||
- **Schema versioning:** `SCHEMA_VERSION` is embedded in the JSON; the closed enum model means readers fail fast on incompatible payloads rather than silently mis-parsing.
|
||||
- **Coordinates:** ENU (East/North/Up) meters relative to a `GeoRegistration` origin (`wifi-densepose-geo`), so the twin can be georeferenced to a real building.
|
||||
- **Position in the pipeline:** `fusion (ADR-137) → WorldGraph (ADR-139) → semantic/agent layer (ADR-140) → eval harness (ADR-145)`. For **forward prediction** (where will people be next?), pair it with [`wifi-densepose-worldmodel`](https://crates.io/crates/wifi-densepose-worldmodel), which turns `PersonTrack` history into predicted occupancy + trajectory priors.
|
||||
|
||||
## Related crates
|
||||
|
||||
| Crate | Role |
|
||||
|---|---|
|
||||
| [`wifi-densepose-worldmodel`](https://crates.io/crates/wifi-densepose-worldmodel) | Forward **prediction** — occupancy world model over this graph's tracks |
|
||||
| [`wifi-densepose-geo`](https://crates.io/crates/wifi-densepose-geo) | Geospatial registration (ENU ↔ lat/lon, DEM, OSM) |
|
||||
|
||||
## License
|
||||
|
||||
Licensed as the parent project. See the [repository](https://github.com/ruvnet/RuView).
|
||||
@@ -201,6 +201,47 @@ impl WorldGraph {
|
||||
id
|
||||
}
|
||||
|
||||
/// Retention: evict the oldest `SemanticState` nodes (with their incident
|
||||
/// edges) until at most `max_states` remain. Returns the evicted ids,
|
||||
/// oldest first.
|
||||
///
|
||||
/// The live loop appends one belief per cycle (`StreamingEngine::
|
||||
/// process_cycle`), which at 20 Hz is ~1.7M nodes/day — unbounded without
|
||||
/// this. The WorldGraph holds *current* beliefs; durable history belongs to
|
||||
/// the recorder (`homecore-recorder`), so evicting old beliefs loses no
|
||||
/// audit data.
|
||||
///
|
||||
/// Deterministic: eviction order is ascending `(valid_from_unix_ms, id)`,
|
||||
/// so replaying the same cycle sequence prunes identically. Only
|
||||
/// `SemanticState` nodes are eligible — rooms, zones, sensors, anchors,
|
||||
/// person tracks, and events are never evicted by this method.
|
||||
pub fn prune_semantic_states(&mut self, max_states: usize) -> Vec<WorldId> {
|
||||
let mut states: Vec<(i64, u64)> = self
|
||||
.inner
|
||||
.node_weights()
|
||||
.filter_map(|n| match n {
|
||||
WorldNode::SemanticState { id, valid_from_unix_ms, .. } => {
|
||||
Some((*valid_from_unix_ms, id.0))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
if states.len() <= max_states {
|
||||
return Vec::new();
|
||||
}
|
||||
states.sort_unstable();
|
||||
let n_evict = states.len() - max_states;
|
||||
states.truncate(n_evict);
|
||||
states
|
||||
.into_iter()
|
||||
.map(|(_, raw)| {
|
||||
let id = WorldId(raw);
|
||||
self.remove_node(id);
|
||||
id
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Record a contradiction between two still-live beliefs (ADR-139 §2.3).
|
||||
/// Neither node is deleted — the disagreement stays queryable.
|
||||
///
|
||||
@@ -424,6 +465,56 @@ mod tests {
|
||||
assert!(g.neighbors(s1).iter().any(|(_, e)| matches!(e, WorldEdge::Contradicts { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prune_semantic_states_evicts_oldest_only() {
|
||||
let mut g = WorldGraph::new(GeoRegistration::default());
|
||||
let room = g.upsert_node(living_room());
|
||||
let prov = SemanticProvenance {
|
||||
evidence: vec!["ev:abc".into()],
|
||||
model_version: "rfenc-1.0".into(),
|
||||
calibration_version: "cal:uuid".into(),
|
||||
privacy_decision: "PrivateHome/Allow".into(),
|
||||
};
|
||||
let ids: Vec<WorldId> = (0..10)
|
||||
.map(|t| g.add_semantic_state(format!("s{t}"), 0.9, t, prov.clone(), &[room]))
|
||||
.collect();
|
||||
assert_eq!(g.node_count(), 11); // room + 10 beliefs
|
||||
|
||||
let evicted = g.prune_semantic_states(3);
|
||||
// Oldest 7 evicted, in ascending timestamp order.
|
||||
assert_eq!(evicted, ids[..7].to_vec());
|
||||
assert_eq!(g.node_count(), 4); // room + 3 newest beliefs
|
||||
for kept in &ids[7..] {
|
||||
assert!(g.node(*kept).is_some());
|
||||
}
|
||||
// The room (structural node) is never eligible for eviction.
|
||||
assert!(g.node(room).is_some());
|
||||
// Below the cap, pruning is a no-op.
|
||||
assert!(g.prune_semantic_states(3).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prune_is_deterministic_for_equal_timestamps() {
|
||||
let prov = SemanticProvenance {
|
||||
evidence: vec![],
|
||||
model_version: "m".into(),
|
||||
calibration_version: "c".into(),
|
||||
privacy_decision: "p".into(),
|
||||
};
|
||||
let build = || {
|
||||
let mut g = WorldGraph::new(GeoRegistration::default());
|
||||
let room = g.upsert_node(living_room());
|
||||
for _ in 0..6 {
|
||||
// Identical timestamps: tie-break must fall back to id order.
|
||||
g.add_semantic_state("s".into(), 0.5, 100, prov.clone(), &[room]);
|
||||
}
|
||||
g
|
||||
};
|
||||
let mut g1 = build();
|
||||
let mut g2 = build();
|
||||
assert_eq!(g1.prune_semantic_states(2), g2.prune_semantic_states(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn privacy_rollup_suppresses_person_tracks() {
|
||||
let mut g = WorldGraph::new(GeoRegistration::default());
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
[package]
|
||||
name = "wifi-densepose-worldmodel"
|
||||
description = "ADR-147 — OccWorld thin-client bridge: WorldGraph PersonTrack history → OccWorld Python subprocess → TrajectoryPrior"
|
||||
version = "0.3.0"
|
||||
readme = "README.md"
|
||||
version = "0.3.1"
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
@@ -12,7 +13,7 @@ tokio = { version = "1", features = ["net", "io-util", "macros", "time"] }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json.workspace = true
|
||||
thiserror.workspace = true
|
||||
wifi-densepose-worldgraph = "0.3.0"
|
||||
wifi-densepose-worldgraph = { version = "0.3.1", path = "../wifi-densepose-worldgraph" }
|
||||
|
||||
[lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
# wifi-densepose-worldmodel
|
||||
|
||||
**Forward prediction for RF sensing — turn where people *were* into where they'll *be*, as occupancy + trajectory priors.**
|
||||
|
||||
[](https://crates.io/crates/wifi-densepose-worldmodel)
|
||||
[](https://docs.rs/wifi-densepose-worldmodel)
|
||||
|
||||
Part of the [RuView / WiFi-DensePose](https://github.com/ruvnet/RuView) project. Implements **ADR-147**.
|
||||
|
||||
---
|
||||
|
||||
## What it is (plain language)
|
||||
|
||||
[`wifi-densepose-worldgraph`](https://crates.io/crates/wifi-densepose-worldgraph) tells you **what the room is
|
||||
*now*** (who's where, the walls, the doorways). This crate answers the next question: **what happens *next*?**
|
||||
|
||||
It's a **thin, async client** to an *occupancy world model* (OccWorld). You give it a short history of where
|
||||
people have been (their `PersonTrack` positions); it rasterizes that into 3-D occupancy grids, ships them to
|
||||
an OccWorld inference process, and gets back:
|
||||
|
||||
- **predicted future occupancy** (the model rolls the scene forward N steps), and
|
||||
- **`TrajectoryPrior`s** — per-person predicted waypoints you can feed straight into a Kalman pose tracker to
|
||||
stabilize and *anticipate* movement (e.g. someone heading for a doorway).
|
||||
|
||||
It is **camera-free and privacy-first**: the world model reasons over **occupancy voxels**, not video — so it
|
||||
predicts *where*, never *who-looks-like-what*. (This is the deliberate contrast with pixel-space robot world
|
||||
models like ByteDance's IRASim: same "predict-the-future-conditioned-on-state" idea, kept in occupancy space
|
||||
for privacy and edge deployment.)
|
||||
|
||||
## Where it sits
|
||||
|
||||
```
|
||||
RF frames → fusion → WorldGraph (what is) ──PersonTrack history──► wifi-densepose-worldmodel
|
||||
▲ │
|
||||
│ OccWorld inference (Python subprocess)
|
||||
└────────── TrajectoryPriors (what's next) ◄──────┘
|
||||
(injected back into the Kalman tracker)
|
||||
```
|
||||
|
||||
## Symbolic vs predictive — the two halves of the world model
|
||||
|
||||
| | `wifi-densepose-worldgraph` | `wifi-densepose-worldmodel` (this crate) |
|
||||
|---|---|---|
|
||||
| **Question** | "What is the room *now*?" | "What happens *next*?" |
|
||||
| **Representation** | typed symbolic graph (rooms, tracks, beliefs) | dense 3-D occupancy voxels + trajectory priors |
|
||||
| **Nature** | interpretable, evidential, provenance-tracked | predictive, learned (OccWorld) |
|
||||
| **Compute** | pure Rust, microseconds, edge | Rust client + GPU inference subprocess |
|
||||
| **Output** | relations & beliefs | future occupancy + per-person waypoints |
|
||||
|
||||
Use them together: the graph supplies tracks + privacy decisions; this crate predicts forward and feeds the
|
||||
priors back.
|
||||
|
||||
## Features
|
||||
|
||||
- 🔌 **Thin async bridge** — `OccWorldBridge` talks to the OccWorld inference process over a Unix socket (newline-delimited JSON request/response).
|
||||
- 🧊 **Occupancy rasterization** — `worldgraph_to_occupancy()` turns person positions + scene bounds into a 3-D voxel grid (`200 × 200 × 16` by default; `CLASS_PERSON` / `CLASS_FREE` semantics).
|
||||
- 🧭 **ENU ↔ voxel mapping** — `SceneBounds::to_voxel_xy()` / `to_voxel_z()` with a configurable resolution (e.g. 0.1 m).
|
||||
- 🛰️ **Trajectory priors** — predicted per-`track_id` waypoints, ready for Kalman injection.
|
||||
- 🔁 **Backend-swappable** — the request/response contract (`OccupancyWorldModelRequest` → response with `confidence` + `trajectory_priors`) is model-agnostic (OccWorld today, RoboOccWorld / others later).
|
||||
- 🔒 **Privacy-gated by design** — meant to be called only when the WorldGraph's privacy mode permits it (ADR-141); reasons over occupancy, never pixels.
|
||||
- 🚫 **`#![forbid(unsafe_code)]`**, `missing_docs = warn`.
|
||||
|
||||
## Install
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
wifi-densepose-worldmodel = "0.3"
|
||||
```
|
||||
|
||||
> The bridge uses Unix domain sockets (`tokio`), so the client targets Unix-like hosts (Linux/macOS — e.g. a Raspberry Pi appliance). The data types (occupancy, bounds, priors) are platform-agnostic.
|
||||
|
||||
## Usage
|
||||
|
||||
```rust
|
||||
use wifi_densepose_worldmodel::{
|
||||
OccWorldBridge, OccupancyWorldModelRequest, SceneBoundsJson, worldgraph_to_occupancy,
|
||||
};
|
||||
use wifi_densepose_worldmodel::occupancy::{PersonPosition, SceneBounds};
|
||||
|
||||
# async fn example() -> Result<(), wifi_densepose_worldmodel::WorldModelError> {
|
||||
let bridge = OccWorldBridge::new("/tmp/occworld.sock");
|
||||
|
||||
let bounds = SceneBounds { min_e: -10.0, min_n: -10.0, max_e: 10.0, max_n: 10.0 };
|
||||
let persons = vec![PersonPosition { track_id: 1, east_m: 2.0, north_m: 3.0, up_m: 1.0 }];
|
||||
|
||||
// Rasterize current positions → an occupancy frame (0.1 m voxels).
|
||||
let frame = worldgraph_to_occupancy(&persons, &bounds, 0.1);
|
||||
|
||||
// Ask OccWorld to roll the scene forward 15 steps.
|
||||
let response = bridge.predict(OccupancyWorldModelRequest {
|
||||
past_frames: vec![frame],
|
||||
voxel_resolution_m: 0.1,
|
||||
scene_bounds: SceneBoundsJson { min_e: bounds.min_e, min_n: bounds.min_n,
|
||||
max_e: bounds.max_e, max_n: bounds.max_n },
|
||||
prediction_steps: 15,
|
||||
}).await?;
|
||||
|
||||
println!("confidence={:.2}", response.confidence);
|
||||
for prior in &response.trajectory_priors {
|
||||
println!("track {} → {} predicted waypoints", prior.track_id, prior.waypoints.len());
|
||||
}
|
||||
# Ok(())
|
||||
# }
|
||||
```
|
||||
|
||||
## Technical details
|
||||
|
||||
- **Wire protocol:** newline-delimited JSON over a Unix socket; one request → one response. The Python side
|
||||
(OccWorld) loads `PersonTrack` history as a `(B, F, H, W, D)` occupancy tensor and returns predicted voxels
|
||||
decoded into `TrajectoryPrior`s.
|
||||
- **Grid:** `GRID_WIDTH=200 × GRID_HEIGHT=200 × GRID_DEPTH=16` voxels by default; `CLASS_PERSON=10`,
|
||||
`CLASS_FREE=17` (RuView indoor class remap from the nuScenes outdoor set).
|
||||
- **Resolution:** configurable meters-per-voxel; `to_voxel_xy`/`to_voxel_z` handle ENU→index.
|
||||
- **Backend:** OccWorld (1.65 GB VRAM, ~375 ms/inference on an RTX-class GPU; runs on the Pi+Hailo appliance
|
||||
tier). Cosmos is the deferred heavier alternative (ADR-148).
|
||||
- **Provenance:** predictions carry the originating `calibration_id` + privacy decision so downstream
|
||||
consumers can gate on quality and consent (ADR-141).
|
||||
|
||||
## Related crates
|
||||
|
||||
| Crate | Role |
|
||||
|---|---|
|
||||
| [`wifi-densepose-worldgraph`](https://crates.io/crates/wifi-densepose-worldgraph) | The symbolic twin ("what is") that supplies the tracks this crate predicts from |
|
||||
|
||||
## License
|
||||
|
||||
Licensed as the parent project. See the [repository](https://github.com/ruvnet/RuView).
|
||||
Reference in New Issue
Block a user