Compare commits

..

16 Commits

Author SHA1 Message Date
rUv 29de574e63 Beyond-SOTA engine/signal/train improvements: mesh partition guard, FFT CIR solver, canonical frame decoder, falsifiable occupancy benchmark, governed streaming, adapter provenance (#1018)
* docs(research): add RuView beyond-SOTA system review (00)

First document of the beyond-SOTA research series: capability audit of
the current RuView engine with role-to-crate maturity matrix, ruvsense
module inventory, gap analysis, and risk register.

https://claude.ai/code/session_01MjBucx95K4BuUxZi8NWwRH

* docs(research): add beyond-SOTA architecture design (02, in progress)

https://claude.ai/code/session_01MjBucx95K4BuUxZi8NWwRH

* docs(research): finalize beyond-SOTA architecture (02)

https://claude.ai/code/session_01MjBucx95K4BuUxZi8NWwRH

* docs(research): add benchmark/validation methodology snapshot (03)

https://claude.ai/code/session_01MjBucx95K4BuUxZi8NWwRH

* docs(research): add beyond-SOTA series index with validation results; changelog

README index ties the 5 research docs together with the session's
measured validation evidence: 2,797 workspace tests / 0 failed, Python
proof PASS (bit-exact), and paired pre/post criterion CIR benchmarks.

https://claude.ai/code/session_01MjBucx95K4BuUxZi8NWwRH

* perf(signal): precompute CIR warm-start system; hoist tomography solver allocs

Exact, determinism-safe optimizations (bit-identical float results):

- cir.rs: diag(PhiH Phi)+lambda*I and its CSR matrix depend only on Phi
  and lambda (fixed at CirEstimator::new) but were rebuilt every frame
  (O(K*G) pass + CSR allocation). Now built once in new() via
  build_warm_start_system; summation order unchanged.
- tomography.rs: ISTA gradient buffer hoisted out of the 100-iteration
  loop (fill(0.0) reset) and the Frobenius Lipschitz bound moved from
  per-reconstruct to construction.

Verified: signal 456 tests green; engine 11/11 green including
cycle_is_deterministic and witness-stability tests. Criterion paired
pre/post: cir_estimate/he40 -3.9% (p<0.01), multiband -1.2/-1.4%.

https://claude.ai/code/session_01MjBucx95K4BuUxZi8NWwRH

* fix(worldgraph): bound SemanticState growth with deterministic retention

StreamingEngine::process_cycle appended one SemanticState belief per cycle
with no eviction — ~1.7M nodes/day at 20 Hz (beyond-SOTA roadmap finding #6).

Add 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) are never eligible. Wire it into the
engine after each belief append (DEFAULT_SEMANTIC_RETENTION = 7,200, ~6 min
at 20 Hz; set_semantic_retention to tune). The WorldGraph holds current
beliefs; durable history is the recorder's job, so no audit data is lost.

3 new tests: end-to-end bounded growth, oldest-only eviction, deterministic
equal-timestamp tie-break. Workspace gate: 2,865 passed, 0 failed.

https://claude.ai/code/session_01MjBucx95K4BuUxZi8NWwRH

* feat(sensing-server): route live frames through the governed StreamingEngine

Closes the live-trust-path gap (ADR-136 section 8, beyond-SOTA system review):
the running server fused live CSI with the bare MultistaticFuser, while the
privacy/provenance/witness control plane (ADR-135..146) only ever ran on
synthetic in-test frames. The privacy control plane was therefore bypassable
on the real path.

New engine_bridge module drives StreamingEngine::process_cycle from the
server's live NodeState map, reusing the existing NodeState -> MultiBandCsiFrame
conversion. It lazily wires each contributing node as a WorldGraph sensor
(idempotent), bounds belief growth via the retention cap, and forwards explicit
timestamps/calibration ids so the path stays deterministic and replayable.

Wired additively into both live ESP32/WiFi fusion sites in main.rs via a
split-borrow off the write guard, so person-count behavior is unchanged; the
latest BLAKE3 witness is stored on AppState. Every published belief now carries
evidence + model + calibration + privacy decision and a deterministic witness.

Adds wifi-densepose-engine/-worldgraph/-bfld/-geo deps. 6 new bridge tests
(witnessed belief with full provenance, cross-run determinism, idempotent node
registration, retention bound, privacy-mode propagation). sensing-server suite
430+128 green; workspace gate 2,904 passed / 0 failed.

https://claude.ai/code/session_01MjBucx95K4BuUxZi8NWwRH

* feat(train): falsifiable occupancy benchmark with anti-overfitting gate

Makes the presence/person-count "beyond SOTA" claim falsifiable in code
instead of aspirational (the unfalsifiability gap from the beyond-SOTA system
review). occupancy_bench grades predictions vs ground truth and gates a SOTA
claim behind one claim_allowed invariant requiring ALL of:

- DataProvenance::Measured — synthetic/mock data is scorable for regression
  but never claimable (anti-mock-contamination; the CLAUDE.md Kconfig-bug
  lesson made structural).
- A leak-free EvalSplit — validate() refuses any split where a subject OR
  environment id appears in both train and test (subject leakage /
  per-environment overfitting).
- n_test >= min_test_samples (small-N guard).
- Presence F1 whose bootstrap-CI lower bound (deterministic seeded splitmix64)
  clears the threshold — not the point estimate.
- Count MAE within threshold.

The claim string is unreadable except through the gate (NO_CLAIM otherwise),
same discipline as the ruview-gamma acceptance gate. What remains is data, not
method: a frozen, SHA-pinned, subject/environment-disjoint measured replay set
turns the claim into a passing/failing test.

Lives in wifi-densepose-train (the eval bounded context, alongside ablation/
eval/metrics). 10 tests cover each refusal path; warning-clean under the
crate's missing_docs lint. Workspace gate 2,914 passed / 0 failed. Doc 03
updated.

https://claude.ai/code/session_01MjBucx95K4BuUxZi8NWwRH

* feat(engine): per-room adapter provenance + drift-to-recalibration advisor

Closes the trust-chain gap where an ~11 KB per-room LoRA adapter (ADR-150
section 3.4) could silently change inference without the witness noticing:
provenance carried only "rfenc-v<N>" with no notion of adapter identity.

- 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.
- RecalibrationAdvisor: recommends re-running the ADR-135 empty-room baseline
  / refitting the room 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, stored on the
  sensing-server AppState alongside the witness at both live fusion sites.
- Bridge plumbing: EngineBridge::{set_room_adapter, clear_room_adapter} +
  live-path test that the adapter id flows into the live witness.

Scope note (honest): 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/); a trained
RF-encoder checkpoint still does not exist in-tree.

Engine 15 tests, bridge 7 tests. Workspace gate: 2,918 passed / 0 failed.

https://claude.ai/code/session_01MjBucx95K4BuUxZi8NWwRH

* fix(mat): gate api module behind its feature — standalone no-default-features builds

pub mod api was unconditional while its only dependency, serde, is optional
behind the 'api' feature, so any build without default features failed with
101 unresolved-serde errors (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 combos compile: bare --no-default-features (was 101 errors, now 0),
--no-default-features --features api, and full default (177 tests pass).
Workspace gate: 2,918 passed / 0 failed.

https://claude.ai/code/session_01MjBucx95K4BuUxZi8NWwRH

* perf(signal): opt-in FFT operator for the CIR ISTA solver (8-14x measured)

Phi 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 — the dominant-latency-hazard
finding from the beyond-SOTA optimization roadmap.

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 in the last bits and requires
regenerating any pinned witness — strictly opt-in per deployment.

FftOperator (rustfft, planned once at CirEstimator::new, scratch buffers
reused across the ISTA loop) dispatches inside ista_solve:
  Phi x   = scale * forward-FFT(x) sampled at bins (k_idx mod G)
  Phi^H v = scale * unnormalised inverse-FFT of v scattered into those bins
Warm-start and Lipschitz estimation stay dense at construction.

Measured (criterion, same run, same machine):
  ht20: 2.22 ms -> 265 us  (8.4x)
  ht40: 10.26 ms -> 717 us (14.3x)
The real HE40 grid (K=484, G=1452) scales further per the O(K*G)/O(G log G)
ratio.

3 new tests: FFT<->dense matvec equivalence to float tolerance on ht20 and
he40 grids; end-to-end dominant-tap agreement on a single-path frame; all
default configs keep FFT off. New cir_estimate_fft bench group.

Workspace gate: 2,921 passed / 0 failed (default path bit-exact, witnesses
unchanged).

https://claude.ai/code/session_01MjBucx95K4BuUxZi8NWwRH

* feat(core): canonical frame decoder — capture-to-claim replay (ADR-136)

The encode half of the ADR-136 frame contract existed (ComplexSample,
to_canonical_bytes, witness_hash) but there was no decoder: a captured
canonical frame could be witnessed but never reconstructed, blocking
replay-from-capture.

CsiFrame::from_canonical_bytes is the exact inverse: same id, metadata,
complex payload, and witness hash (tested as the round-trip law AC7 — the
replayed frame re-encodes byte-identically). Amplitude/phase are recomputed
from the payload (projections, not independent state). Every malformed-input
class fails closed (AC8): header truncation -> Truncated, payload truncation
-> PayloadMismatch, unknown discriminants, non-UTF-8 device id, trailing
bytes. Nil calibration uuid decodes as None per the documented encoding.

Core: 36 tests pass. Workspace gate: 2,937 passed / 0 failed.

https://claude.ai/code/session_01MjBucx95K4BuUxZi8NWwRH

* feat(engine): dynamic min-cut mesh partition guard (ruvector-mincut)

Maintains an exact min-cut over the live mesh coupling graph — nodes are
sensing nodes, coupling is the product of fusion attention weights — and
surfaces per cycle, as TrustedOutput::mesh:

- cut value: the global "how close is the array to partitioning" number,
  a structural measure per-node heuristics miss;
- weak side: which specific nodes would split off (failure/jamming triage,
  feeds ADR-032 posture);
- at-risk flag: counts as a structural event for the drift->recalibration
  advisor (alongside ADR-142 change-points).

Degenerate cases fail toward risk: a node with zero coupling is reported as
already partitioned (cut 0, that node as the weak side).

Measured cost policy (criterion, 12-node mesh — the honest part):
- weights quantized (1/64) + change-gated: steady-state cycles do ZERO graph
  work and reuse the cached cut (~7.3 us, ~23x cheaper than building);
- on any real change a full exact rebuild (~171 us) is used, because ONE
  DynamicMinCut delete+insert measured ~240 us — the subpolynomial machinery
  amortizes on much larger graphs, so rebuild-on-change is the measured
  optimum at mesh scale (one-edge case -28% after switching policy);
- full process_cycle with the guard: ~33 us for 4 nodes vs the 50 ms budget.

9 mesh_guard tests (weak-node detection, steady-state zero updates,
sub-quantum gating, join/drop rebuild, determinism, disconnection) + an
engine-level wiring test (down-weighted node -> weak side -> recalibration).
Engine 24 tests; workspace gate 2,946 passed / 0 failed.

https://claude.ai/code/session_01MjBucx95K4BuUxZi8NWwRH

* feat(engine): mesh partition risk demotes privacy + enters the witness (ADR-032)

Completes the mesh-guard integration: its at_risk signal was advisory-only
(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; mesh_guard_mut
exposes risk-threshold tuning.

Test: a forced-risk 3-node cycle demotes PrivateHome Anonymous->Restricted
and shifts the witness vs a clean baseline. Engine 25 tests; workspace gate
2,947 passed / 0 failed.

https://claude.ai/code/session_01MjBucx95K4BuUxZi8NWwRH

* fix: public-PR review findings — privacy-path honesty, gate holes, mesh-guard cliff

- sensing-server: engine errors logged+counted (no silent swallow), trust
  state exposed via status surface, privacy-demotion claims aligned with
  the actual parallel-audit-path behavior
- occupancy_bench: vacuous-F1 hole closed (degenerate test sets fail with
  their own criterion); CI-lower-bound test made probative
- mesh_guard: quantization scaled to observed coupling range — >=65-node
  balanced meshes no longer permanently at_risk (regression test)
- engine: both wiring tests made probative (same-topology witness compare,
  deterministic risk-crossing fixture)
- mat: axum/tokio optional behind api; real serde feature (api enables it)
- core: canonical decoder strict (non-zero reserved bytes and nil UUID
  rejected — injective on accepted domain, forged-bytes tests)
- CHANGELOG: un-spliced the FFT/adapter bullet mangle

Co-Authored-By: claude-flow <ruv@ruv.net>

* chore: strip private-track references for public PR

Reword the occupancy-benchmark changelog bullet to drop a cross-reference
to the private research track, and restore the WorldGraph retention bullet
header that was glued onto the preceding MAT bullet.

Co-Authored-By: claude-flow <ruv@ruv.net>

* chore: lockfile refresh for cherry-picked feature set

Co-Authored-By: claude-flow <ruv@ruv.net>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-11 16:08:54 -04:00
rUv d0e27e652e fix(firmware): C6 IDF v5.5 guard + HE-LTF host ingest + WITNESS-LOG-110 B1 resolution (#1005) (#1011)
* fix(firmware): c6_sync_espnow IDF v5.5 send-callback guard + B1 HE-LTF resolution (#1005)

Espressif backported the esp_now_send_cb_t signature change to v5.5
(esp_now_send_info_t = wifi_tx_info_t there), so the #944 guard must be
ESP_IDF_VERSION >= VAL(5,5,0), not MAJOR >= 6.

Validated on this repo's hardware toolchain:
- WITHOUT fix, IDF v5.5.2 esp32c6 build fails with the reporter's exact
  incompatible-pointer error at c6_sync_espnow.c:199 (reproduced)
- WITH fix, clean build on IDF v5.5.2 (esp32c6) AND IDF v5.4 (regression)

Docs: WITNESS-LOG-110 §B1 marked RESOLVED WITH MEASUREMENT (external,
@stuinfla, issue #1005): IDF v5.4 driver downconverts HE->HT; v5.5.2
delivers true HE-LTF (532B / 256 bins / 242 tones, PPDU 0x01 HE-SU).
ADR-110 capability table updated accordingly.

Co-Authored-By: claude-flow <ruv@ruv.net>

* docs: WITNESS-LOG-110 §B1 — in-house HE-LTF replication on the original COM12 C6

84% of 1,525 frames at 532B/PPDU 0x01 (HE-SU) with IDF v5.5.2 + the #1005
guard fix, AP ruv.net 11ax 2.4GHz. Two independent rigs now confirm:
v5.4 downconverts, v5.5.2 delivers 242-tone HE20.

Co-Authored-By: claude-flow <ruv@ruv.net>

* fix(host): 256-bin HE-LTF ingest end-to-end + latent offset bugs (#1005)

Audit of every ADR-018 consumer against live C6 HE20 frames (532B/256-bin):
- sensing-server + CLI calibrate parsers read n_subcarriers from one byte
  (256 decoded as 0) with stale seq/rssi offsets (rssi always 0 — latent,
  pre-existing, confirmed vs firmware csi_collector.c). Fixed to the real
  ADR-018 layout; n_subcarriers u8->u16; byte 18 surfaced as typed PpduType.
- sensing-server probe buffer 256B -> 2048B (532B datagram errored on Windows)
- per-node grid gate: lock densest (n_subcarriers, ppdu_type) grid, re-warm
  on upgrade, skip sparser minority frames — HT-64 never mixes into an
  HE-256 baseline window
- hardware parser: HE-aware bandwidth classification (256-FFT HE20 = 20MHz,
  was Bw160); PpduType/Adr018Flags re-exported
- verbatim live frames (532B HE-SU, 148B HT) embedded as regression fixtures
- archive python parser: bandwidth heuristic mirror fix

Live-validated: calibrate --tier he20 consumed 600x 256-bin frames into an
ADR-135 He20 baseline (242 tones) skipping 94 HT frames; sensing-server
shows node 12 active with real RSSI (-40dBm). 765 tests green across the
three crates; workspace check clean; Python proof PASS.

Co-Authored-By: claude-flow <ruv@ruv.net>

* test(fuzz): esp_netif/ping_sock/ip_addr stubs — un-break ADR-061 fuzz build after #954

csi_collector.c gained esp_netif.h / ping/ping_sock.h / lwip/ip_addr.h
includes for the #954 gateway self-ping; the host-fuzz stub env lacked
them, breaking the fuzz build on main since 5789351b7. Stubs return
no-gateway so the self-ping path early-outs (compiles + links, never
exercised — matches the fuzz threat model which targets frame
serialization, not the network stack).

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-11 11:00:37 -04:00
rUv 2a307138f2 feat: per-room calibration system (ADR-151) + cognitum-v0 appliance integration spec (#989)
* docs(adr): ADR-151 — Per-Room Calibration & Specialized Model Training

Room-first calibration -> bank of small specialised ruVector models
(breathing, heartbeat, restlessness, posture, presence, anomaly) distilled
from the frozen Hugging-Face-published RF Foundation Encoder (ADR-150).

Four-stage local-first pipeline: baseline (ADR-135 environmental fingerprint)
-> guided enrollment (NEW EnrollmentProtocol, clean anchors not hours) ->
feature extraction (reuse signal_features + ruvsense) -> specialist bank
training (rapid_adapt LoRA heads, RVF storage, HNSW prototypes).

Invariants: specialisation over scale; local heads over a shared public base;
honest STALE degradation on baseline drift. Indexes ADR-149/150/151.

Co-Authored-By: claude-flow <ruv@ruv.net>

* feat(cli): calibration HTTP API for UI-driven baseline capture (ADR-135/151)

Adds `wifi-densepose calibrate-serve` — an Axum HTTP API that wraps the
ADR-135 CalibrationRecorder so a UI (or any client) can drive an empty-room
baseline capture remotely. Stage 1 ("teach the room") of the ADR-151 room
calibration & training pipeline.

A single background task owns the UDP socket (ESP32 0xC511_0001 frames) and
the optional active recorder; HTTP handlers talk to it over an mpsc command
channel and read a shared status snapshot, keeping the &mut recorder
lock-free. CORS permissive so a browser UI can call it.

Endpoints (/api/v1/calibration/*):
  GET  /health      liveness + UDP ingest stats (frames_seen, streaming)
  POST /start       { tier?, duration_s?, room_id?, min_frames? }
  GET  /status      live progress (state, frames, progress, z, eta) — poll for UI
  POST /stop        finalize the current session early
  GET  /result      finalized baseline summary (amp/phase-dispersion averages)
  GET  /baselines   list persisted baseline .bin files

Reuses the existing calibrate.rs ESP32 wire parser (made pub(crate)); honest
abort when <10 frames arrive in the window (e.g. ESP32 not streaming).

Verified end-to-end over loopback: start -> 300 replayed HT20 frames ->
state=complete, 52-subcarrier baseline, phase_dispersion_avg=0.00096
(concentrated/valid), persisted to disk; all 6 endpoints exercised.
CLI: 19 tests pass; crate builds clean.

Co-Authored-By: claude-flow <ruv@ruv.net>

* test(cli): firewall-free CSI UDP relay for local Windows ESP32 testing

Windows Defender blocks inbound LAN UDP to a freshly-built binary without an
admin allow-rule; python.exe is already allowed. This relay binds the public
CSI port and forwards each datagram verbatim to a loopback port where
`calibrate-serve --udp-bind 127.0.0.1 --udp-port 5006` listens (loopback is
firewall-exempt). No admin required.

Validated: ESP32-format 0xC5110001 frames -> :5005 -> relay -> :5006 ->
calibrate-serve -> state=complete, 52-subcarrier baseline,
phase_dispersion_avg=0.00098 (clean). Completes the no-admin live-test path.

Co-Authored-By: claude-flow <ruv@ruv.net>

* docs(changelog): record ADR-151 calibration API (calibrate-serve)

Co-Authored-By: claude-flow <ruv@ruv.net>

* feat(calibration): ADR-151 Stages 2–5 — enrollment, extraction, specialist bank, runtime

New crate wifi-densepose-calibration implementing the per-room pipeline beyond
Stage-1 baseline:

- anchor.rs: guided-anchor sequence + event-sourced EnrollmentSession (Stage 2)
- enrollment.rs: AnchorQualityGate + AnchorRecorder — gates anchors against the
  ADR-135 baseline deviation (presence/motion), re-prompts bad captures
- extract.rs: Features + AnchorFeature — autocorrelation periodicity (breathing/
  HR bands), variance/motion (Stage 3)
- specialist.rs: 6 small room-calibrated models — presence (learned threshold),
  posture (nearest-prototype), breathing/heartbeat (band periodicity),
  restlessness (calm/active normalization), anomaly (novelty vs anchors) (Stage 4)
- bank.rs: SpecialistBank — train/persist + baseline-drift STALE invalidation
- runtime.rs: MixtureOfSpecialists — presence short-circuit + anomaly veto +
  stale flagging (Stage 5)

Statistical heads make the pipeline runnable/validatable today; the ADR-150 HF
RF Foundation Encoder backbone is the documented upgrade path. 29 unit tests pass.

Co-Authored-By: claude-flow <ruv@ruv.net>

* feat(cli): wire ADR-151 enroll / train-room / room-status / room-watch

Integrates the wifi-densepose-calibration crate into the CLI as four
subcommands driving the full Stage 2–5 pipeline against a live ESP32 raw-CSI
stream (edge_tier=0):

- enroll: walks the guided anchor sequence, gates each capture against the
  ADR-135 baseline deviation (re-prompts bad anchors), writes labelled features
- train-room: fits the SpecialistBank from the enrollment, persists JSON
- room-status: prints a trained bank's summary
- room-watch: live mixture-of-specialists readout (presence/posture/breathing/
  heart/restless) over a rolling window, with anomaly veto + STALE flagging

Per-frame scalar is the mean CSI amplitude (carries presence/motion + breathing
modulation). Validated end-to-end on the live ESP32 (COM8, edge_tier=0): the
real parser → feature extraction → runtime detected breathing (~16–31 BPM) on
hardware. Full multi-anchor enrollment accuracy requires the operator to perform
the poses; phase-based breathing extraction is a noted refinement.

48 tests pass (29 calibration + 19 CLI).

Co-Authored-By: claude-flow <ruv@ruv.net>

* docs(adr-151): mark Stages 1–5 implemented; expand CHANGELOG

Co-Authored-By: claude-flow <ruv@ruv.net>

* fix(cli): keep proven mean-amplitude carrier for room features

The max-variance-subcarrier carrier locked onto motion artifacts (not
breathing) and also had an out-of-bounds bug on variable CSI subcarrier
counts. Reverted to the mean-amplitude carrier, which is validated live to
detect breathing. Phase-based extraction on a stable subcarrier remains the
proper higher-SNR refinement (ADR-151 §4).

Co-Authored-By: claude-flow <ruv@ruv.net>

* feat(calibration): multistatic fusion of co-located nodes (ADR-029/151)

MultiNodeMixture fuses several co-located nodes (each with its own
room-calibrated SpecialistBank) into one RoomState:
- presence: OR across nodes (any node seeing a person wins)
- posture/breathing/heartbeat: highest-confidence node (best viewpoint)
- restlessness/anomaly: max across nodes
- veto: any node's physically-implausible signal vetoes the room's vitals
  (anti-hallucination, same as single-node runtime) + presence short-circuit
- stale: any node's STALE flag propagates

Same-room multistatic only; cross-room is federation (ADR-105), not fusion.
6 unit tests (presence OR, best-confidence breathing, single-node veto,
staleness). 35 calibration tests pass.

Co-Authored-By: claude-flow <ruv@ruv.net>

* feat(cli): multistatic room-watch — fuse co-located nodes (ADR-029/151)

`room-watch --node-bank N:path` (repeatable) groups live CSI frames by node_id
and fuses per-node banks via MultiNodeMixture. Validated live on COM8 (node 9,
edge_tier=0): frames grouped + fused end-to-end. True 2-node fusion is covered
by unit tests; a second raw-CSI node is the hardware blocker. 54 tests pass.

Co-Authored-By: claude-flow <ruv@ruv.net>

* docs(integration): calibration → cognitum-v0 appliance integration overview

Detailed cross-repo integration spec for cognitum-one/v0-appliance: data
contracts (CSI wire format, ADR-135 baseline binary, enrollment/bank/RoomState
JSON schemas), calibrate-serve HTTP API, public crate API, Pi5+Hailo tiering,
and a 5-step appliance integration plan. Grounded in the verified cognitum-v0
inventory (aarch64, cargo 1.96, HAILO10H, ruview-vitals-worker:50054).

Co-Authored-By: claude-flow <ruv@ruv.net>

* fix(calibration): address PR review — aarch64 decouple, API auth, path traversal, throttle

Resolves the review on #989:

- **Cross-compile (the appliance blocker):** make wifi-densepose-mat optional
  and feature-gate it (`mat`), so `cargo build -p wifi-densepose-cli
  --no-default-features` excludes the mat→nn→ort(ONNX)→openssl-sys chain.
  Verified: `cargo tree --no-default-features` shows 0 ort/openssl deps →
  calibration cross-compiles clean for the Pi.
- **Security (must-fix before LAN):**
  - `--token` / CALIBRATE_TOKEN bearer-auth middleware on every route; warns if
    bound non-loopback without a token.
  - sanitize client-supplied `room_id` to [A-Za-z0-9_-] (≤64) before it reaches
    the baseline write path — kills the `../` file-write primitive. + test.
- **Perf:** stop locking shared status + cloning SessionStatus on every UDP
  frame — counters/snapshot flush on the 200 ms tick instead (no CPU
  starvation under flood). finalize write moved to async `tokio::fs::write`.
- **Docs:** ADR-151 STALE wording matches the impl (baseline-id change;
  drift-threshold = P6 refinement); integration doc gets the
  `--no-default-features` build + auth/sanitize notes.

35 calibration + 15 CLI tests (no-default) / 20 CLI (default) pass.

Co-Authored-By: claude-flow <ruv@ruv.net>

* docs(worldgraph,worldmodel): add crates.io READMEs

Plain-language overviews + feature lists, comparison tables (symbolic graph vs
predictive occupancy; graph vs grid vs event-log), usage, and technical
details. Adds readme = "README.md" to both manifests so they render on
crates.io on the next release.

Co-Authored-By: claude-flow <ruv@ruv.net>

* release: worldgraph & worldmodel 0.3.1 (READMEs on crates.io)

Co-Authored-By: claude-flow <ruv@ruv.net>

* docs: precise calibration validation scope (capture+API+auth proven; clean enroll→train→infer not yet on-target)

Aligns ADR-151 §7 + the appliance integration doc with the PR #989 scope
clarification: nothing has run a clean baseline → enroll → train → infer on
live CSI; the live breathing read used the stateless head, not a trained bank.
Adds --source-format adr018v6 to the backlog.

Co-Authored-By: claude-flow <ruv@ruv.net>

* feat(calibrate-serve): live GET /room/state endpoint (mixture over CSI window)

Adds a live RoomState readout over HTTP — the appliance UI's main need. The
ingest task maintains a rolling per-frame scalar window (flushed on the 200 ms
tick, no per-frame lock); the handler loads a bank (resolved as a sanitized
name under output_dir — same path-traversal defense as room_id), runs the
MixtureOfSpecialists over the window, returns RoomState JSON.

Validated live (ESP32-S3 via relay): breathing 14-19 BPM over HTTP; a
bank=../../etc/passwd query is neutralized to 'etcpasswd' (no traversal).

Co-Authored-By: claude-flow <ruv@ruv.net>

* feat(calibrate-serve): POST /room/train + fix AnchorLabel JSON to snake_case

- POST /api/v1/room/train: { room_id, baseline_id, anchors[] } → trains a
  SpecialistBank and persists it as <output_dir>/<room_id>.json (path-sanitized),
  readable via /room/state?bank=<room_id>. Completes the HTTP train→infer loop.
- Fix data-contract bug: AnchorLabel serialized as PascalCase variant names
  (serde default) while as_str() + the integration doc used snake_case. Added
  #[serde(rename_all = "snake_case")] so the JSON wire format matches the
  documented contract (empty/stand_still/…). Locked with a roundtrip test.

Validated live (ESP32-S3): POST train (4 anchors → 6 specialists, persisted) →
GET /room/state returns RoomState with the trained presence/restlessness; the
synthetic-vs-real scale mismatch correctly triggers the anomaly veto. 36
calibration tests pass.

Co-Authored-By: claude-flow <ruv@ruv.net>

* feat(calibrate-serve): live enroll-over-HTTP (POST /enroll/anchor + /enroll/status)

Closes the last HTTP gap — the appliance can now drive the ENTIRE calibration
pipeline over HTTP without the CLI:
  baseline (start/stop) -> enroll/anchor x8 -> room/train -> room/state

- POST /enroll/anchor { room_id, baseline, label, duration_s? }: the ingest task
  loads the baseline (sanitized name under output_dir), captures the anchor for
  the duration against it (AnchorRecorder + per-frame series), runs the quality
  gate, and on completion replies with the verdict + accumulates the AnchorFeature
  in an in-server enrollment map keyed by room_id. Re-prompts on rejection.
- GET /enroll/status?room=<id>: accepted anchors, next, complete.
- POST /room/train now falls back to the in-server enrollment when anchors[] is
  omitted.

Validated live (ESP32-S3): capture baseline -> enroll stand_still (271 frames,
6s) -> gate correctly rejects "no person detected (presence_z 0.90 < 1.50)"
relative to a same-occupancy baseline (a clean empty-room baseline is the
documented on-target prerequisite). Builds clean; CLI tests pass.

Co-Authored-By: claude-flow <ruv@ruv.net>

* test(calibrate-serve): HTTP integration tests for the room/enroll endpoints

Factor the router into build_router() (shared by execute + tests) and add
tower-oneshot integration tests (no network/ingest needed):
- health + descriptor → 200
- POST /room/train persists the bank; GET /room/state → 200; train with no
  anchors/enrollment → 400
- path-traversal: /room/state?bank=../../etc/passwd → 404 (sanitized, never
  reads outside output_dir)
- enroll/status empty; /enroll/anchor with an unknown label → 400

CI regression coverage for the endpoints added this session. 18 CLI tests pass.

Co-Authored-By: claude-flow <ruv@ruv.net>

* fix(mat): make serde non-optional — unblocks `cargo test --workspace --no-default-features`

Making wifi-densepose-mat optional in the CLI (for the aarch64/ort decouple)
exposed a latent feature bug: mat's `api` module compiles unconditionally and
uses serde, but `serde` was an optional dep enabled only via the `api`/`serde`
features. Previously the CLI's *unconditional* mat dependency enabled those
features transitively, so `--workspace --no-default-features` still got serde;
once mat became optional+gated, the workspace build lost it →
`error[E0432]: unresolved import serde` across mat's api/* (CI red).

mat already pulls serde_json + axum unconditionally, so making `serde`
non-optional has no real cost and restores the workspace build. Does NOT affect
the aarch64 CLI build (mat isn't built there at all): verified
`cargo tree -p wifi-densepose-cli --no-default-features` still shows 0
ort/openssl deps, and `cargo test --workspace --no-default-features` compiles
clean.

Co-Authored-By: claude-flow <ruv@ruv.net>

* docs(claude.md): add wifi-densepose-calibration to crate table (pre-merge)

Co-Authored-By: claude-flow <ruv@ruv.net>

* docs(adr): ADR-152 — WiFi-pose SOTA 2026 intake (geometry-conditioned calibration, external benchmarks, encoder recipe)

Records the 2026-06-10 deep-research run (22 sources, 110 claims, 25
adversarially verified: 24 confirmed / 1 refuted) and the decisions it
implies:

- §2.1 ACCEPTED: geometry-condition the ADR-151 calibration system —
  NodeGeometry at enrollment, geometry embeddings for future LoRA heads,
  PerceptAlign-style two-checkerboard camera↔WiFi alignment for the
  ADR-079 supervised path. PerceptAlign (MobiCom'26) names the failure
  mode ("coordinate overfitting") that matches our own ADR-150 cross-
  subject collapse.
- §2.2 ACCEPTED: benchmark protocol vs external "WiFlow-STD (DY2434)"
  (claimed 97.25% PCK@20, Apache-2.0 weights+dataset) with a no-citation
  rule until measured on our 17-keypoint ESP32 eval set. Name collision
  with our internal WiFlow is disambiguated.
- §2.3 ACCEPTED: amend ADR-150 training recipe per UNSW MAE study —
  80% masking, (30,3) patches, data-over-capacity priority (log-linear,
  unsaturated at 1.3M samples).
- §2.4 watch items: IEEE 802.11bf-2025 published 2025-09-26;
  esp_wifi_sensing as external presence baseline (drop-in claim REFUTED
  0-3); ZTECSITool 160MHz/512-subcarrier anchor node (procurement-gated).
- §2.5 NOT adopted: non-WiFi "foundation model" papers; DensePose-UV
  (no 2025-2026 work does UV regression from commodity WiFi).

Every number is evidence-graded CLAIMED vs MEASURED in the source
register. Re-check horizon 2026-12.

Co-Authored-By: RuFlo <ruv@ruv.net>

* test(calibration): full-loop integration test — baseline→enroll→train→infer proven in-process (ADR-151 §7 gap, software half)

Closes the software half of PR #989's headline validation gap: the
complete calibration loop had never run end-to-end anywhere, even
in-process. tests/full_loop.rs (412 lines, deterministic xorshift32
room simulator, HT20/52-subcarrier/20Hz, same fingerprint family as
the ADR-135 roundtrip test) now drives the CLI's exact stage order
through the public API:

  1. baseline  — 600 static frames, zero motion flags post-warmup,
                 calibration_uuid() exactly as the CLI derives it
  2. enroll    — all 8 AnchorLabel::SEQUENCE anchors through
                 AnchorQualityGate::default(), session is_complete()
  3. extract   — AnchorFeature::from_series recovers injected 0.25Hz
                 and 0.125Hz breathing within ±0.04Hz
  4. train     — SpecialistBank::train fits all 6 specialists; JSON
                 round-trip and the runtime consumes the RELOADED bank
  5. infer     — positive: never-enrolled 0.30Hz subject reads present,
                 18±2 BPM; negative: empty window reads absent;
                 degradation: foreign baseline_id flags STALE

Seed-robust (5 seeds), passes with and without default features:
36 unit + 1 integration green.

Validation docs updated (ADR-151 §7 + integration doc §7 matrix): what
remains is strictly the on-target hardware session (real CSI, physically
empty room, operator performing the guided anchors). Three behavioral
findings from building the test are recorded for pre-session triage:
z-band squeeze between baseline motion flagging (z>2.0) and the still-
anchor gate (presence_z≥1.5) — likeliest on-hardware enroll failure;
variance-only PresenceSpecialist missing motionless-person mean shift;
ungated breathing_hz/heart_hz in noise-window embeddings.

Co-Authored-By: RuFlo <ruv@ruv.net>

* fix(calibration): close all four ADR-152 behavioral findings pre-hardware-session

The full-loop integration test surfaced three findings; fixing the third
exposed a fourth. All four are fixed and regression-guarded:

1. z-band squeeze (enrollment.rs) — anchor motion is now measured from
   frame-to-frame deltas of the deviation series (|Δz| > Z_DELTA_MOTION
   0.5 ∨ |Δφ| > π/6), not from the absolute motion_flagged, which fires
   at amplitude_z_median > 2.0 vs the EMPTY baseline and so conflated
   presence strength with motion. A strongly-reflecting still person
   (z = 3.0 — every frame flagged by the old heuristic) now enrolls.
   The old unit tests mocked (z=3.0, motion=false), a combination the
   real deviation() can never emit — which is exactly how the squeeze
   hid; tests now derive the flag from z the way the producer does.

2. variance-only presence (specialist.rs) — PresenceSpecialist gains a
   mean-shift channel: present when variance > threshold OR
   |mean − empty_mean| > mean_dist_threshold (trained at half the
   empty→occupied mean distance, None when the means don't separate).
   Detects the motionless person whose body raises the scalar mean but
   not its variance. Old persisted banks deserialize with the channel
   inert (serde default None) — variance-only behavior preserved,
   proven by a fixture test against pre-change JSON.

3. ungated hz embedding (extract.rs) — Features::embedding() zeroes
   breathing_hz/heart_hz below EMBED_MIN_SCORE (0.25), keeping the
   random in-band peaks of noise windows out of the posture/anomaly
   prototype space. Raw fields stay ungated (specialists have their
   own stricter gates).

4. heart-band lag-floor leakage (extract.rs, found while fixing 3) —
   a pure 0.30 Hz breathing signal scored 0.67 in the heart band at
   3.33 Hz: out-of-band rhythm leaks as a monotonic slope whose max
   sits at the band's lag floor, so score gating alone cannot stop it.
   autocorr_dominant now requires the winning lag to be an interior
   local maximum; band-edge "peaks" are rejected, true in-band peaks
   (interior by definition) are preserved.

full_loop.rs strengthened to drive the fixes end-to-end: the StandStill
anchor is now a z=3.0 strong reflector (unenrollable pre-fix), and a new
motionless-person runtime case proves mean-channel detection at empty-
level variance.

Validation: 41 calibration unit + 1 full-loop integration + 23 CLI tests
green; cargo test --workspace --no-default-features exit 0.

Co-Authored-By: RuFlo <ruv@ruv.net>
2026-06-10 15:21:09 -04:00
rUv 992c2b25cb fix(firmware): correct ESP32 edge heart rate — sample-rate + harmonic lock (#987) (#988)
* fix(firmware): correct heart-rate estimation — sample-rate + harmonic lock

The edge vitals HR was stuck at ~45 BPM regardless of true heart rate
(Apple Watch ground truth 87 BPM read as ~45) and "dropped a lot" between
frames. Two root causes:

1. Stale fixed sample rate. estimate_bpm_zero_crossing() used a hardcoded
   `sample_rate = 10.0f` (and the biquads a separate `fs = 20.0f`). That
   constant was correct when CSI came from ~10 Hz beacons, but #985's
   self-ping raised the callback rate to a VARIABLE ~13-19 Hz. BPM scales as
   (assumed_rate / actual_rate) x true, so a true 87 read ~45, and because
   the real rate fluctuates with CSI yield while the code assumed a fixed
   value, the reported HR swung frame-to-frame (the "drops").

2. Breathing-harmonic lock. Zero-crossing HR estimation locked onto a
   breathing harmonic — a 0.25 Hz breathing fundamental puts its 3rd
   harmonic at ~0.74 Hz ~= 44 BPM, right in the HR band — so it parked at
   ~45 BPM independent of the real heartbeat.

Fix:
- Measure the real sample rate from inter-frame timestamps (EMA-smoothed,
  clamped 8-30 Hz); use it for both BPM conversion and biquad design, and
  re-tune the filters when the rate drifts >15% so the passbands stay in
  real Hz.
- Replace the HR zero-crossing with estimate_hr_autocorr(): autocorrelation
  peak in the 45-180 BPM band that explicitly rejects lags within 8% of any
  breathing harmonic (k=1..6), with parabolic interpolation and a peak-
  confidence gate (returns 0 rather than a noise value).
- Median-smooth (N=9) the emitted HR over valid estimates to kill residual
  single-frame outliers.

Validated on hardware (ESP32-S3, COM8/192.168.1.80) vs an unmodified board
(192.168.1.67) and an Apple Watch (87 BPM):
- old firmware: HR pegged 40-52 BPM (median ~45)
- fixed firmware: HR reaches the true 88-91 BPM range (peak 88.5, vs 87 GT)

Known limitation: under subject motion (motion=Y) HR is still noisy because
the breathing estimate degrades and misguides harmonic rejection; motion
gating + breathing robustness are follow-ups.

Co-Authored-By: claude-flow <ruv@ruv.net>

* fix(firmware): robust HR harmonic rejection via autocorr breathing period (#987)

Follow-up to 332c2a98d. The HR harmonic rejection was fed the noisy
zero-crossing breathing estimate, which under motion notched the wrong
frequencies and let the autocorr lock onto the ~0.75 Hz breathing harmonic
(~45 BPM). Generalize estimate_hr_autocorr -> estimate_periodicity_autocorr
and drive HR harmonic rejection from a robust autocorrelation breathing
period instead; widen the HR median smoother to N=13.

Hardware A/B (fixed .80 vs unmodified control .67, both edge_tier=2, subject
in motion 100% of frames):
- control (old fw): HR pegged 40-43 BPM (median 40.6)
- fixed:            HR 60-91 BPM (median 71.9) — sub-60 harmonic locks
                    eliminated, spread 42->31 BPM vs previous build

Reported breathing is unchanged (still zero-crossing); the autocorr breathing
period is used only internally for HR harmonic rejection.

Co-Authored-By: claude-flow <ruv@ruv.net>

* docs(changelog): record ESP32 heart-rate fix (#987)

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-09 11:27:21 -04:00
rUv 5789351b78 fix(esp32): add connected-STA self-ping CSI traffic source (#954) (#985)
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=0
(#521, #954).

Fix (additive): pin a ~50 Hz OFDM unicast floor by pinging the STA's own
DHCP gateway. The router's ICMP echo replies are OFDM frames destined to
this station and drive the CSI engine regardless of promiscuous filter
state or ambient traffic. Mirrors Espressif's esp-csi csi_recv_router
reference. Promiscuous capture (#396/#893) is left fully intact so
multistatic/multi-node sensing still hears other stations' frames.

Reconciles PR #955 (which removed promiscuous entirely and conflicted
with the already-shipped #893 DATA-capture path) into an additive change
on current main.

Verified on ESP32-S3 (N16R8, COM8), ESP-IDF v5.4:
  Promiscuous mode enabled (MGMT-only, RuView#396)
  self-ping started -> 192.168.1.1 @50Hz (CSI OFDM source, fix #521/#954)
  CSI cb #1: len=128 rssi=-40 ch=5
  adaptive_ctrl: state=6 yield=13-19pps motion=1.00 presence>0  (SENSE_ACTIVE)
DEGRADED cleared; CSI yield stable ~15 pps over 60 s.

Co-authored-by: Meraj <merajmehrabi@gmail.com>
2026-06-09 14:43:12 +02:00
rUv b6420ac9ba fix(server): make synthetic CSI opt-in only (sibling fix to #937) (#979)
Background

Issue #937 in the cognitum-v0 appliance repo flagged that the
`cognitum-csi-capture` systemd unit shipped `--simulate` by default,
silently serving synthetic CSI tagged as production telemetry on
`/api/v1/sensor/stream`. That's a textbook trust-eroding pattern — the
single most-cited "where's the real data?" evidence external reviewers
(#943, #934) point at when they call the project AI-slop.

A grep across THIS tree surfaced the exact same anti-pattern in three
places:

  docker/docker-compose.yml:27        # auto (default) — probe ESP32, fall back to simulation
  docker/docker-entrypoint.sh:14      # CSI_SOURCE — data source: auto (default), ...
  main.rs:6435                        info!("No hardware detected, using simulation"); "simulate"

The sensing-server's `auto` source resolver at main.rs:6425-6440
silently fell back to synthetic with only an `info!` log line as the
signal. Downstream consumers calling `/api/v1/sensing/latest` or
`/ws/sensing` had no in-band way to know they were being served fake
data.

Fix

`auto` now refuses to fall back. When neither ESP32 UDP nor host WiFi
is detected, the server logs a clear `error!` explaining the situation
and exits 78 (EX_CONFIG). The error message names the two ways to
proceed: provision real hardware, or set `--source simulated` /
`CSI_SOURCE=simulated` explicitly. Existing operators who already use
`--source simulated` (or its legacy `simulate` alias) are unaffected —
the alias is preserved for back-compat.

Docker entrypoint comment, docker-compose comment, and the Tauri
desktop app's source-default path also updated to reflect the new
posture. The desktop app keeps its `simulated` default because it's
an explicit demo product — the value passed downstream is the
*explicit* `simulated`, not `auto`, so the server tags it correctly
and never lies about its data source.

Validation

  cargo build  -p wifi-densepose-sensing-server --no-default-features
  cargo test   -p wifi-densepose-sensing-server --no-default-features
  → 122 / 122 pass, build clean (existing pre-fix warnings unchanged).

Deployment

⚠ Breaking change for unattended deployments that relied on the
`auto → simulated` silent fallback. That is exactly the failure mode
this PR fixes: pretending to serve real sensing data when the source
is fake. Operators who genuinely want demo mode set
`CSI_SOURCE=simulated` explicitly; the error message and the
docker-compose comment both point them there.
2026-06-08 18:07:39 +02:00
rUv c353255672 fix: firmware cluster — wasm3 IDF v6.0 build (#946) + swarm TLS stack (#949) + Docker unauth default (#864) (#975)
* fix(firmware,docker): clear three high-severity bugs in one sweep

Closes #946 — wasm3 fails on Xtensa GCC 15.2.0 (ESP-IDF v6.0.1)

  cannot tail-call: machine description does not have a sibcall_epilogue
  instruction pattern

wasm3's `M3_MUSTTAIL return jumpOpImpl(...)` uses
`__attribute__((musttail))` which GCC 15 enforces strictly on Xtensa,
where the backend never reliably implemented sibling-call epilogues.
Define `M3_NO_MUSTTAIL=1` in the wasm3 component compile-defs so the
macro expands to plain `return` — slightly slower per opcode dispatch
but functionally identical, and the only change needed in this tree.
Older IDF / GCC builds accept the define as a no-op so the IDF v5.4
CI build is unchanged.

Closes #949 — swarm task stack overflow on Seed TLS init

The reporter provisioned with `--seed-url https://...` which exercises
TLS, and the task panicked with the FreeRTOS stack-fill sentinel
`0xa5a5a5a5` immediately after the bridge init line. `SWARM_TASK_STACK`
was 3 KB ("HTTP client uses ~2.5 KB" per the original comment) — fine
for plain HTTP, far too small for mbedTLS handshake which alone wants
4-6 KB for the cipher suite + cert chain + ECDH state, plus another
1.5-2 KB for esp_http_client. Bumped to 8192 with the why in the
comment. Plain-HTTP deployments waste ~5 KB headroom (negligible
PSRAM cost) but the bug class is closed.

Closes #864 — Docker default exposes unauthenticated sensing API + WS

`docker-entrypoint.sh` started the sensing-server with `--bind-addr
0.0.0.0` AND empty `RUVIEW_API_TOKEN` AND docker-compose published
3000/3001/5005 — anyone on a reachable network segment could read
/api/v1/sensing/latest and the /ws/sensing live frame stream.

Now the entrypoint refuses to start when:
  RUVIEW_API_TOKEN is empty
  AND RUVIEW_ALLOW_UNAUTHENTICATED is not "1"
  AND RUVIEW_BIND_ADDR is not loopback / localhost / ::1

…and prints exactly which three escape hatches the operator can take
(set the token, opt in explicitly, or pin to loopback). Also wires
RUVIEW_BIND_ADDR through to --bind-addr so the loopback escape hatch
is one env var, not a flag override. cog-ha-matter / homecore routes
are excluded from this check since they own their own auth lifecycle.
This is a breaking change for unattended LAN deployments — exactly
what the reporter asked for.

Validation

* `idf.py build` for esp32s3 target — succeeds (#946 fix doesn't
  affect default IDF v5.4 build path).
* `idf.py set-target esp32c6 && idf.py build` — succeeds, binary
  1015 KB / 45% partition free.
* Hardware flash to COM12 (C6) failed with "No serial data received"
  — XIAO C6 needs manual BOOT-hold+RESET; couldn't drive that without
  operator. Code is correct per build + review; runtime validation
  needs the operator to press the BOOT button at flash time.
* docker-entrypoint.sh changes are shell-only — exercised by reading
  the path under the four escape-hatch conditions.

Out of scope — cross-repo issues

Issues #935 (cognitum-agent mesh panics), #936 (CSI relay routing),
and #937 (cognitum-csi-capture --simulate default) reference
`cognitum-agent` / `csi-capture` / `csi-relay-routes.json` artifacts
that live in the cognitum-v0 appliance repo, not this tree.

Issue #954 (CSI callback never fires on S3 v0.6.5/v0.7.0) is not
addressed here — the reporter is on the S3 (COM9 in this lab) but the
hardware path needs an interactive debug session with a configurable
AP traffic source to pin the root cause (MGMT-only filter, traffic
filter MAC, or driver-level callback wiring). Will tackle in a
follow-up.

Co-Authored-By: claude-flow <ruv@ruv.net>

* fix(firmware): bump LWIP UDP / WiFi TX buffer pools to ease ENOMEM

Hardware validation on COM8 (S3) and COM9 (C6) surfaced a v0.7.0
regression not captured in the existing issue tracker: stock IDF v5.4
defaults (UDP recv mbox = 6, TCPIP recv mbox = 32, WiFi dynamic TX
buffers = 32) are too small for the v0.7.0 packet mix once CSI
promiscuous mode is active. The boot trace showed
`stream_sender: sendto ENOMEM — backing off for 100 ms` repeating
every capture cycle, with the csi_collector path reporting `fail #1..5`
within seconds of associating to an AP.

Modest bumps applied (~3 KB extra heap each):

  CONFIG_LWIP_UDP_RECVMBOX_SIZE      6  → 32
  CONFIG_LWIP_TCPIP_RECVMBOX_SIZE   32  → 64
  CONFIG_ESP_WIFI_DYNAMIC_TX_BUFFER_NUM 32 → 64

Empirical 25 s measurement on S3 / COM8 post-fix:

  csi_collector fail #            : 1-5  → 0  (full path drained)
  stream_sender ENOMEM hits / sec : 8-15 → 8  (capped by 100 ms backoff)
  CSI cb rate                     : ~28 cb/s, yield max 18 pps
  feature_state emit failed       : still present

A second, more aggressive iteration (DYNAMIC_TX=128, PBUF_POOL=32, TCP
SND/WND=16384) was tested and reverted — the ENOMEM count was
identical to the modest bump. The residual 8/s is structural: it's the
100 ms backoff window ceiling × the adaptive_controller emit cadence
which currently fires roughly every 50 ms instead of the intended 1 Hz.
Bigger buffers don't fix that — only rate-limiting the emitter does.

Code-level rate-limit refactor is tracked separately to keep this PR
scoped to the bundle that landed mechanically.

Co-Authored-By: claude-flow <ruv@ruv.net>

* fix(firmware): rate-limit feature_state emit from 5 Hz → 1 Hz

Completes the ENOMEM cure that the LWIP/WiFi buffer bumps started.

Root cause (verified on COM8 / S3 + COM9 / C6)

`fast_loop_cb` runs every 200 ms (5 Hz) and unconditionally called
`emit_feature_state()`. Combined with CSI capture in promiscuous mode
(radio mostly in RX), the WiFi TX airtime got saturated and every
100 ms backoff window had at least one ENOMEM. Bumping the LWIP/WiFi
buffer pools to 4× had no effect on the ENOMEM rate because the
bottleneck was radio TX time, not pool size.

The ADR-081 spec calls out "1–10 Hz" for feature_state; 5 Hz was at
the top of the range and not necessary — operators consuming the
telemetry want a sample every second, not five times. Dropping to
1 Hz frees ~80 % of the feature_state TX traffic.

Measurement on COM8 (25 s windows, otherwise-idle environment)

  csi_collector lost sends     : 1-5 / 25 s  →  0 / 25 s  (✓ fixed)
  feature_state emit failed    : 75 / 25 s   →  25 / 25 s (3× ↓)
  total sendto ENOMEM log lines: 200/25 s    →  212 / 25 s
                                 (unchanged — bound by 100 ms backoff
                                  window ceiling, not by emit rate)
  CSI yield                    : 18 pps (steady)

The unchanged total ENOMEM is a measurement artifact: the backoff
window emits exactly one ENOMEM record per 100 ms when *anything*
collides with a TX-busy moment. The packet-loss numbers (which is
what actually matters) all dropped to zero or near-zero on the CSI
path.

Implementation

Pure-static `s_emit_divider` counter in `fast_loop_cb`. Every 5th tick
calls the emit. Zero allocation, zero extra state, zero interaction
with the existing observation snapshot under `s_obs_lock`. Could be
made config-driven if any operator ever wants 2-5 Hz back — out of
scope here.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-08 16:39:42 +02:00
rUv 872d7593bb fix: IDF v6.0 ESP-NOW callback compat (#944) + occupancy noise-floor anchor (#942) (#945)
* fix(firmware): on_send ESP-NOW callback compat for IDF v6.0 (closes #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)

The C6 sync ESP-NOW path's `on_recv` was already version-guarded with
`#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 0, 0)` (lines 102-112)
but the `on_send` sibling missed the equivalent guard. CI runs against
IDF v5.4 so the regression slipped through; the reporter on IDF v6.0.1
with xtensa-esp-elf esp-15.2.0_20251204 hit:

  c6_sync_espnow.c:182:30: error: passing argument 1 of
  'esp_now_register_send_cb' from incompatible pointer type
  [-Wincompatible-pointer-types]

Fix: mirror the recv guard with `#if ESP_IDF_VERSION_MAJOR >= 6` since
the send-callback signature change happened at IDF v6.0 (not v5.x like
the recv-callback). Both branches ignore the address-side argument
since `on_send` only inspects `status` to bump the TX-fail counter.

Adds `#include "esp_idf_version.h"` so the macro is in scope.

Closes #944

Co-Authored-By: claude-flow <ruv@ruv.net>

* fix(signal): anchor estimate_occupancy noise floor to calibration (closes #942)

`test_estimate_occupancy_noise_only` asserts that 20 noise-only frames
fed through a 50-frame calibrated `FieldModel` yield 0 occupancy.
Failure reported on the upstream Linux + BLAS build.

Root cause

Calibration and estimation each compute their own Marcenko-Pastur
threshold:

  threshold = noise_var · (1 + sqrt(p / N))²

with `noise_var` = median of the bottom half of positive eigenvalues
from their own covariance. The MP ratio differs across the two phases:

  calibration  (50 frames, p=8): ratio = 0.16, factor ≈ 1.96
  estimation   (20 frames, p=8): ratio = 0.40, factor ≈ 2.66

On a small estimation window the local `noise_var` estimate can also
be smaller than the calibration's (fewer samples → bottom-half median
hits lower-magnitude eigenvalues). The combination of a smaller
noise_var on estimation and the larger MP factor can flip eigenvalues
on/off the "significant" line in a sample-size-dependent way, so an
identical-distribution test window scores `significant >
baseline_eigenvalue_count` and reports phantom persons.

Fix

Persist the calibration `noise_var` on `FieldNormalMode` (new field
`baseline_noise_var: f64`) and use `max(local_noise_var,
baseline_noise_var)` as the noise floor inside `estimate_occupancy`.
This anchors the threshold to the calibration scale and prevents the
short-window collapse without changing behavior when the local
window's own noise dominates (the real-motion case).

`baseline_noise_var` defaults to 0.0 in the diagonal-fallback paths;
the estimation code treats 0.0 as "no anchored floor available" and
preserves the pre-#942 single-window behavior — so older `FieldNormalMode`
instances deserialised from disk continue to work unchanged.

Test results

  cargo test --workspace --no-default-features
  → 413 lib tests pass (signal crate), 0 fail, 1 ignored.

The actual `eigenvalue`-gated test still requires BLAS (not buildable
on Windows). Logic-trace via the four numerical anchors above shows
the fix flips `noise_var` from the smaller local value back up to the
calibration scale, dropping `significant` to or below
`baseline_eigenvalue_count` so the saturating subtraction returns 0.

Closes #942

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-04 08:17:37 +02:00
rUv 2c136aca74 fix(protocol): resolve 0xC511_0004 magic collision (closes #928) (#931)
* fix(ci): SAST actually scans the code + drop deprecated flaky semgrep action

Two real problems in the Static Application Security Testing job:

1. **It scanned a path that no longer exists.** `bandit -r src/` and
   `semgrep … src/` pointed at the repo-root `src/`, but the Python code
   moved to `archive/v1/src/` (64 .py files) when the runtime was rewritten
   in Rust. So the SAST scan matched nothing — a silent no-op (this is also
   why `bandit-results.sarif` was "Path does not exist" on recent runs).
   Fixed both to `archive/v1/src/`.

2. **Deprecated + redundant + flaky semgrep step.** The
   `returntocorp/semgrep-action@v1` step pulled `returntocorp/semgrep-agent:v1`
   from Docker Hub every run (intermittently timing out → red check, e.g. on
   #929) and is EOL. It was redundant: the pip `semgrep --sarif` step is what
   feeds GitHub Security; the action only pushed to the Semgrep cloud app via
   SEMGREP_APP_TOKEN. Removed it and folded its `p/docker` + `p/kubernetes`
   rulesets into the pip semgrep command, so coverage is preserved with no
   Docker pull.

The job stays `continue-on-error: true` (non-gating). YAML validated.

Co-Authored-By: claude-flow <ruv@ruv.net>

* fix(protocol): resolve 0xC511_0004 magic collision (closes #928)

Background

`0xC511_0004` was assigned to two different packet formats in firmware
— `EDGE_FUSED_MAGIC` (ADR-063, 48-byte `edge_fused_vitals_pkt_t`) and
`WASM_OUTPUT_MAGIC` (ADR-040, variable-length `wasm_output_pkt_t`).
Both were transmitted. The sensing-server only had a WASM parser for
that magic and no fused-vitals parser, so on the ESP32-C6 + MR60BHA2
mmWave configuration the fused-vitals packet was silently misparsed
as a malformed WASM output — `breathing_rate` was read as
`event_count`, mmWave-fused vitals were lost, and spurious WASM events
were emitted to subscribers.

Fix

1. Reassign `WASM_OUTPUT_MAGIC` to `0xC511_0007` (next free slot per
   the registry in `rv_feature_state.h`). Smaller blast radius than
   moving fused-vitals — the registry already treats `0xC511_0004` as
   fused-vitals canonical and several years of deployed feature
   tracking depends on that assignment.

2. Add `parse_edge_fused_vitals` + `EdgeFusedVitalsPacket` in
   `wifi-densepose-sensing-server::main`. Byte layout taken directly
   from `edge_processing.h:129`, mirroring the firmware's
   `_Static_assert(sizeof(edge_fused_vitals_pkt_t) == 48)` so future
   firmware changes that grow the packet will break this parser
   loudly instead of silently.

3. Add a dispatch arm in the UDP receive loop. Fused-vitals is tried
   BEFORE WASM so a stale firmware (still emitting 0xC511_0004 with
   the WASM payload) fails to parse as fused-vitals (size mismatch),
   then fails to parse as WASM (magic mismatch on the new 0x...0007),
   and gets dropped — a deliberate "fail loud" outcome rather than the
   pre-fix silent garbage.

4. Update the registry comment in `rv_feature_state.h` to add the new
   0x...0007 row.

5. Add five tests in a new `issue_928_magic_collision_tests` mod:
   - `parse_edge_fused_vitals_extracts_fields_correctly`
   - `parse_edge_fused_vitals_rejects_short_buffer`
   - `parse_edge_fused_vitals_rejects_wrong_magic`
   - `parse_wasm_output_rejects_legacy_0004_magic`
   - `parse_wasm_output_accepts_new_0007_magic`

WebSocket payload

Fused-vitals now broadcasts as `{"type": "edge_fused_vitals", ...}`
with the mmWave-specific block nested under `mmwave`. Schema is
additive — existing subscribers that only inspect `type` are
unaffected; subscribers that switch on `type` gain a new branch.

Deployment note

This is a wire-protocol change. Firmware older than this commit that
emits WASM output on 0xC511_0004 will lose its WASM event stream
against an updated host (host expects 0xC511_0007). Per the issue
discussion, "fail loud" is preferred to silent misparsing. Operators
running C6+mmWave should reflash firmware concurrent with the host
upgrade.

Test results
  cargo test -p wifi-densepose-sensing-server --no-default-features
  --bin sensing-server
  → 122 passed / 0 failed (5 new + 117 existing, unchanged)

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-03 11:56:35 +02:00
rUv 69e61e3437 docs(changelog): record this cycle's behavior-changing fixes (#932)
Per the CLAUDE.md pre-merge checklist (item 5, "Add entry under
[Unreleased]"), several recently-merged PRs landed without CHANGELOG
entries. Backfilling the user/operator-facing ones — most importantly the
MAT triage safety fix:

- #926 (Security/safety): survivor with a heartbeat never triaged Deceased
- #918: per-node HA devices report each node's own presence/motion
- #919: actionable --model load diagnostic (refs #894)
- #920: --export-rvf no longer silently produces a placeholder model
- #929 (Security): bearer scheme matched case-insensitively (RFC 6750)

CI-internal fixes (#925 rust-cache, #930 SAST) are intentionally omitted —
they don't change product behavior. Docs-only.
2026-06-03 11:47:07 +02:00
rUv d9e87e13b4 fix(ci): SAST actually scans the code + drop deprecated flaky semgrep action (#930)
Two real problems in the Static Application Security Testing job:

1. **It scanned a path that no longer exists.** `bandit -r src/` and
   `semgrep … src/` pointed at the repo-root `src/`, but the Python code
   moved to `archive/v1/src/` (64 .py files) when the runtime was rewritten
   in Rust. So the SAST scan matched nothing — a silent no-op (this is also
   why `bandit-results.sarif` was "Path does not exist" on recent runs).
   Fixed both to `archive/v1/src/`.

2. **Deprecated + redundant + flaky semgrep step.** The
   `returntocorp/semgrep-action@v1` step pulled `returntocorp/semgrep-agent:v1`
   from Docker Hub every run (intermittently timing out → red check, e.g. on
   #929) and is EOL. It was redundant: the pip `semgrep --sarif` step is what
   feeds GitHub Security; the action only pushed to the Semgrep cloud app via
   SEMGREP_APP_TOKEN. Removed it and folded its `p/docker` + `p/kubernetes`
   rulesets into the pip semgrep command, so coverage is preserved with no
   Docker pull.

The job stays `continue-on-error: true` (non-gating). YAML validated.
2026-06-03 11:18:49 +02:00
rUv be48143f77 fix(auth): match the Bearer scheme case-insensitively (RFC 6750) (#929)
`require_bearer` parsed the Authorization header with
`strip_prefix("Bearer ")`, which is case-sensitive. Per RFC 6750 §2.1 /
RFC 7235 §2.1 the auth-scheme is case-insensitive, so a correct token sent
as `Authorization: bearer <token>` (or `BEARER`, or with extra whitespace)
was rejected with a confusing "invalid bearer token" 401 — needless friction
when setting up `RUVIEW_API_TOKEN` (the active #864/#924 theme).

Now the scheme is matched with `eq_ignore_ascii_case` and leading token
whitespace trimmed. The token comparison itself is unchanged — still exact
and constant-time (`ct_eq`) — so this does not weaken auth: a wrong token or
a non-Bearer scheme (`Basic …`) still returns 401.

New test `accepts_case_insensitive_bearer_scheme` covers `bearer`/`BEARER`/
extra-space (accept) and wrong-token/`Basic` (still reject). bearer_auth
suite: 9 passed.
2026-06-03 11:07:34 +02:00
rUv c453268002 fix(mat): never triage a survivor with a heartbeat as Deceased (safety) (#926)
Both triage paths in the Mass Casualty Assessment tool classified a
survivor as Deceased (Black) on "no breathing + no movement" while
completely ignoring the heartbeat signal:

- domain `TriageCalculator::calculate` → `combine_assessments(Absent, None)`
  returned Deceased. That branch is in fact only reachable *because* a
  heartbeat makes `has_vitals()` true (breathing+movement absent alone →
  Unknown) — so every "Deceased" was a live person with a pulse.
- detection `EnsembleClassifier::determine_triage` (the path used by
  `classify()`) returned Deceased on `!has_breathing && !has_movement`,
  also ignoring `reading.heartbeat`.

A survivor with a detectable pulse but no sensed breathing/movement is in
respiratory arrest — the most time-critical *savable* state. Reporting them
Deceased would deprioritize a rescuable person. WiFi-CSI also cannot confirm
death (no airway-repositioning step), so a pulse must override.

Fix: in both paths, if the result would be Deceased but a heartbeat is
present, return Immediate. Total absence of breathing, movement AND heartbeat
is unchanged (domain → Unknown, ensemble → Deceased).

2 safety regression tests added. Full MAT suite: 168 + 6 + 3 passed, 0 failed
(existing test_no_vitals_is_deceased still green — no heartbeat → Deceased).
2026-06-03 09:37:09 +02:00
rUv 6ee21a0941 ci: use Swatinem/rust-cache for the Rust workspace job (reliability) (#925)
The Rust Workspace Tests job manually cached the whole `v2/target` via
actions/cache@v4. For a 38-crate workspace that dir is multi-GB, and several
CI runs this cycle intermittently died at the cache/setup step (after
toolchain install, before "Run Rust tests"), each needing a rerun.

Swatinem/rust-cache@v2 is the de-facto standard Rust CI cache: it caches the
cargo registry/git + a pruned target, evicts stale dependencies, and restores
large workspaces far more reliably and faster than a naive whole-target cache.
`workspaces: v2` points it at the v2/ cargo workspace.

Reliability/speed change — verified by observing subsequent main runs.
2026-06-03 09:12:26 +02:00
rUv 0cfd255730 fix: --export-rvf no longer silently produces a placeholder model (#920)
The --export-rvf handler ran *before* the --train/--pretrain handlers and
unconditionally wrote placeholder sine-wave weights, then returned. So the
documented `--train --dataset … --export-rvf <path>` workflow
(user-guide.md) short-circuited to a PLACEHOLDER model and never trained —
printing "exported successfully" for a non-functional model. Given the
project's anti-"is it fake" stance, silently emitting a fake model is the
wrong default.

Fix:
- Only emit the placeholder container-format demo when --export-rvf is used
  *standalone* (new `export_emits_placeholder_demo` guard). With
  --train/--pretrain, fall through so the real training pipeline runs and
  exports calibrated weights.
- The standalone path now prints a clear WARNING that it writes a
  container-format demo with placeholder weights — not a trained model —
  pointing to --train / a pretrained encoder (#894).
- Docs: flag --export-rvf as a placeholder demo in the flag table, and fix
  the Docker training example to use --save-rvf (consistent with the
  from-source example) instead of the placeholder --export-rvf.

3 unit tests for the guard. Full crate unit suite: 429 + 117 passed, 0 failed.
2026-06-03 08:55:36 +02:00
rUv f5d0e1e69e fix(#894): actionable diagnostic when --model gets a non-RVF file (#919)
Users who downloaded ruvnet/wifi-densepose-pretrained and passed
model.safetensors / model-q4.bin / model.rvf.jsonl to --model hit a bare
"Progressive loader init failed: invalid magic at offset 0: expected
0x52564653, got 0x77455735" and were stuck — the server then silently fell
back to signal heuristics (which over-count, feeding "is it fake" reports).

The HF files are a different *format* and encoder architecture than the RVF
binary container the progressive loader expects, so they can't load directly.
Now the load-failure path detects the common cases (safetensors header,
JSONL manifest, quantized .bin blob) and emits a plain explanation naming the
format, what --model actually expects (RVF `RVFS` container from
wifi-densepose-train), and that it's continuing with heuristics — with a
pointer to #894.

Pure, testable `diagnose_model_load_error()` + 4 unit tests (run under the
default `--no-default-features` CI). Full crate unit suite: 429 + 114 passed,
0 failed.
2026-06-02 20:05:30 +02:00
84 changed files with 10963 additions and 243 deletions
+11 -9
View File
@@ -108,16 +108,18 @@ jobs:
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
- name: Cache cargo
uses: actions/cache@v4
# Swatinem/rust-cache replaces a naive `actions/cache` of the whole
# `v2/target`. That manual cache of a 38-crate target dir (multi-GB) was an
# intermittent failure source — several CI runs this cycle died at the
# cache/setup step (after toolchain install, before "Run Rust tests"),
# needing a rerun. rust-cache is purpose-built for Rust: it caches the
# registry + git + a pruned target, evicts stale deps, and restores far more
# reliably (and faster) on large workspaces. `workspaces: v2` points it at
# the v2/ cargo workspace (keys on v2/Cargo.lock, caches v2/target).
- name: Cache cargo (Swatinem/rust-cache)
uses: Swatinem/rust-cache@v2
with:
path: |
~/.cargo/registry
~/.cargo/git
v2/target
key: ${{ runner.os }}-cargo-${{ hashFiles('v2/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-
workspaces: v2
- name: Run Rust tests
working-directory: v2
+17 -16
View File
@@ -46,7 +46,10 @@ jobs:
- name: Run Bandit security scan
run: |
bandit -r src/ -f sarif -o bandit-results.sarif
# The Python codebase lives under archive/v1/src (it moved there when
# the runtime was rewritten in Rust). Scanning `src/` matched nothing,
# so this SAST step was a silent no-op.
bandit -r archive/v1/src/ -f sarif -o bandit-results.sarif
continue-on-error: true
- name: Upload Bandit results to GitHub Security
@@ -57,22 +60,20 @@ jobs:
sarif_file: bandit-results.sarif
category: bandit
- name: Run Semgrep security scan
continue-on-error: true
uses: returntocorp/semgrep-action@v1
with:
config: >-
p/security-audit
p/secrets
p/python
p/docker
p/kubernetes
env:
SEMGREP_APP_TOKEN: ${{ secrets.SEMGREP_APP_TOKEN }}
- name: Generate Semgrep SARIF
# Removed the deprecated `returntocorp/semgrep-action@v1` step: it was
# redundant (the pip `semgrep --sarif` below is what feeds GitHub Security;
# the action only pushed to the Semgrep cloud app via SEMGREP_APP_TOKEN) and
# it pulled `returntocorp/semgrep-agent:v1` from Docker Hub on every run,
# which intermittently timed out and turned this check red. The pip semgrep
# (installed above) needs no Docker pull. The action's `p/docker` +
# `p/kubernetes` rulesets are folded into the command below so coverage is
# preserved.
- name: Run Semgrep + generate SARIF
run: |
semgrep --config=p/security-audit --config=p/secrets --config=p/python --sarif --output=semgrep.sarif src/
semgrep \
--config=p/security-audit --config=p/secrets --config=p/python \
--config=p/docker --config=p/kubernetes \
--sarif --output=semgrep.sarif archive/v1/src/
continue-on-error: true
- name: Upload Semgrep results to GitHub Security
+29
View File
@@ -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 (814× 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 ~1319 Hz — BPM scales as `assumed/actual × true`, so 87 read ~45 and the reading swung as CSI yield fluctuated; (2) the zero-crossing estimator locked onto a breathing harmonic (a 0.25 Hz breathing fundamental puts its 3rd harmonic at ~0.74 Hz ≈ 44 BPM inside the HR band). Fix: measure the real sample rate from inter-frame timestamps (used for BPM conversion + biquad re-tuning on >15% drift); replace the HR zero-crossing with an autocorrelation estimator that rejects breathing harmonics (driven by a robust autocorr breathing period); median-13 smooth the output. Hardware A/B (fixed vs unmodified control board, both `edge_tier=2`): control pegged 4049 BPM; fixed reaches the true 8891 BPM (vs 87 GT) and holds a stable physiological value (spread 59→0 for a steady subject). Known limitation: heavy subject motion still degrades the estimate (motion gating is a follow-up).
- **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 23 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`, 03) 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 (~1631 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.23.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 **~100200 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.23.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`)
+2 -1
View File
@@ -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) |
+7 -3
View File
@@ -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
+5 -2
View File
@@ -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:
+57 -2
View File
@@ -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
+1 -1
View File
@@ -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 15 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.10.15 Hz | 30 s | slow respiration | periodicity, micro-Doppler |
| `breathe_normal` | resp≈0.20.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.
- **Teacherstudent 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 teacherstudent distillation | Drift marks bank STALE; AetherArena entry | ◐ Partial (STALE done; SONA/federation/HF-backbone = follow-ups) |
**Current status (2026-06-10):** Stages 15 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 ~1631 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 20252026 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; 20160 MHz) — reports zero-shot cross-domain gains of 2.215.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.40.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 (~510× 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 20252026 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) |
+6
View File
@@ -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 ~1631 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-118123/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 (R1R20), 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-136146 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-118123, 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 (200500 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 14 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-125133 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-105109 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.786.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 ≈ 90100 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 ~6365% 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 20252026 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 (5200 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 (05 occupants, lab) | 20202024 | typically 9097% 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 14 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) | 20232026 | HR MAE typically 13 bpm at 13 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, 13 m WiFi HR is ~0.81.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 | 12 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 | 20152021 | 0.41 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: 3060 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.47.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 | 20102013 | Through-wall tracking via RSS networks, ~0.51 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 20252026 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) | 202425 | 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 17.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-105109 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 20252026 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.53.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; 6365% 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, 100200 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 $615 ESP32 mesh, bench-validated | All placement numbers are synthetic; Tier-2.3 bench validation outstanding |
| Person count | ~9097% lab, ≤5 people **[K]** | cog ships uncertainty intervals **[I]** | Exact count 16 people ≥95% with honest intervals, multistatic, real bench | Multi-person CSI superposition; no public multi-occupancy benchmark |
| Vital signs HR | 0.81.2 bpm median, single subject, LoS, 13 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.43.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** — $615/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-105109) 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.23.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.22.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 I1I3).
**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érRao 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 816); 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.52.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 / 5458.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 3340 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.22.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 4049 BPM; fixed 8891 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: `‖predgt‖ ≤ 0.2·‖R-shoulderL-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%** @100200 samples (3 seeds); cross-env **10.6→73.1%** @200, 60.1% @5 (ADR-150 §3.53.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 **LowMedium**, 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·(AUC0.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 | 8891 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 1620 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 113 (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 5113152395) 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 ~840× 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 (242484) | 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 (48 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 F1F12, 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 110)
- 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 1035)
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 3570)
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 7090)
- 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 (F1F12), 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 ≈34 % 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
≈14 % — 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 (840× 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.
+2 -2
View File
@@ -1048,7 +1048,7 @@ The Rust sensing server binary accepts the following flags:
| `--dataset` | (none) | Path to dataset directory (MM-Fi or Wi-Pose) |
| `--dataset-type` | `mmfi` | Dataset format: `mmfi` or `wipose` |
| `--epochs` | `100` | Training epochs |
| `--export-rvf` | (none) | Export RVF model container and exit |
| `--export-rvf` | (none) | Export a **placeholder** RVF container-format demo and exit — **not a trained model**. For a real model use `--train` (+ `--save-rvf`) or download a pretrained encoder. |
| `--save-rvf` | (none) | Save model state to RVF on shutdown |
| `--model` | (none) | Load a trained `.rvf` model for inference |
| `--load-rvf` | (none) | Load model config from RVF container |
@@ -1359,7 +1359,7 @@ docker run --rm \
-v $(pwd)/output:/output \
--entrypoint /app/sensing-server \
ruvnet/wifi-densepose:latest \
--train --dataset /data --epochs 100 --export-rvf /output/model.rvf
--train --dataset /data --epochs 100 --save-rvf /output/model.rvf
```
The pipeline runs 10 phases:
@@ -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 110 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
* 110 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. */
+154 -8
View File
@@ -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
+10 -1
View File
@@ -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). */
+11 -2
View File
@@ -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;
}
+66
View File
@@ -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()
+1
View File
@@ -0,0 +1 @@
baselines/
Generated
+29 -33
View File
@@ -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]]
+1
View File
@@ -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.10.15 Hz).
BreatheSlow,
/// Normal respiration (~0.20.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(&centered, fs, 0.1, 0.6);
let (heart_hz, heart_score) = autocorr_dominant(&centered, 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.1250.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");
}
+11 -2
View File
@@ -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 }
+61 -26
View File
@@ -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
+22
View File
@@ -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),
+17
View File
@@ -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);
}
}
+458
View File
@@ -0,0 +1,458 @@
//! `enroll` / `train-room` / `room-status` / `room-watch` — ADR-151 Stages 25 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(())
}
+343 -1
View File
@@ -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);
+370 -6
View File
@@ -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 ≤ (n1)/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];
// 01 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 {
+3 -1
View File
@@ -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);
}
+12 -5
View File
@@ -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"
@@ -172,6 +172,14 @@ impl EnsembleClassifier {
let has_movement = reading.movement.movement_type != MovementType::None;
if !has_breathing && !has_movement {
// SAFETY: a detectable heartbeat means the survivor is ALIVE. No
// sensed breathing/movement *with* a pulse is respiratory arrest —
// the most time-critical savable state (Immediate), never Deceased.
// Only the total absence of breathing, movement AND heartbeat is
// reported Deceased.
if reading.heartbeat.is_some() {
return TriageStatus::Immediate;
}
return TriageStatus::Deceased;
}
@@ -295,6 +303,27 @@ mod tests {
assert_eq!(result.recommended_triage, TriageStatus::Deceased);
}
/// SAFETY regression: heartbeat present but no sensed breathing/movement is
/// respiratory arrest — Immediate, never Deceased. Only the *total* absence
/// of breathing, movement AND heartbeat (the test above) is Deceased.
#[test]
fn test_heartbeat_with_no_breathing_or_movement_is_immediate() {
// breathing: None, heartbeat: Some(72 bpm), movement: None
let reading = make_reading(None, Some(72.0), MovementType::None);
let classifier = EnsembleClassifier::new(EnsembleConfig {
min_ensemble_confidence: 0.0,
..EnsembleConfig::default()
});
let result = classifier.classify(&reading);
assert_eq!(
result.recommended_triage,
TriageStatus::Immediate,
"a survivor with a pulse must never be triaged Deceased"
);
}
#[test]
fn test_ensemble_confidence_weighting() {
let classifier = EnsembleClassifier::new(EnsembleConfig {
@@ -104,7 +104,20 @@ impl TriageCalculator {
let movement_status = Self::assess_movement(vitals);
// Step 4: Combine assessments
Self::combine_assessments(breathing_status, movement_status)
let status = Self::combine_assessments(breathing_status, movement_status);
// Step 5: SAFETY OVERRIDE — a detectable heartbeat means the survivor is
// ALIVE. `combine_assessments` only sees breathing + movement, so a
// person with a pulse but no *sensed* breathing/movement (respiratory
// arrest, or breathing too shallow for CSI to pick up) would otherwise
// be reported Deceased and deprioritized for rescue. No breathing + a
// pulse is the most time-critical *savable* state, so escalate to
// Immediate rather than ever calling a survivor with a heartbeat dead.
if status == TriageStatus::Deceased && vitals.heartbeat.is_some() {
return TriageStatus::Immediate;
}
status
}
/// Assess breathing status
@@ -217,7 +230,9 @@ enum MovementAssessment {
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::{BreathingPattern, ConfidenceScore, MovementProfile};
use crate::domain::{
BreathingPattern, ConfidenceScore, HeartbeatSignature, MovementProfile, SignalStrength,
};
use chrono::Utc;
fn create_vitals(
@@ -233,6 +248,29 @@ mod tests {
}
}
/// SAFETY regression: a survivor with a detectable heartbeat but no sensed
/// breathing or movement is in respiratory arrest — Immediate (Red), and
/// must NEVER be reported Deceased. (Before the fix, `combine_assessments`
/// ignored heartbeat and returned Deceased; that path was in fact only
/// reachable *because* a heartbeat made `has_vitals()` true.)
#[test]
fn heartbeat_with_no_breathing_or_movement_is_immediate_not_deceased() {
let vitals = VitalSignsReading {
breathing: None,
heartbeat: Some(HeartbeatSignature {
rate_bpm: 72.0,
variability: 0.1,
strength: SignalStrength::Moderate,
}),
movement: MovementProfile::default(),
timestamp: Utc::now(),
confidence: ConfidenceScore::new(0.8),
};
let status = TriageCalculator::calculate(&vitals);
assert_eq!(status, TriageStatus::Immediate, "pulse present ⇒ alive");
assert_ne!(status, TriageStatus::Deceased);
}
#[test]
fn test_no_vitals_is_unknown() {
let vitals = create_vitals(None, MovementProfile::default());
+6
View File
@@ -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).
@@ -100,7 +100,17 @@ pub async fn require_bearer(
.headers()
.get(AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|s| s.strip_prefix("Bearer "));
// RFC 6750 §2.1 / RFC 7235 §2.1: the auth-scheme ("Bearer") is
// case-insensitive. Match it as such (and tolerate extra leading
// whitespace before the token) so a correct token isn't rejected
// just because a client sent `bearer`/`BEARER`. The token compare
// below stays exact + constant-time.
.and_then(|s| {
let (scheme, token) = s.split_once(' ')?;
scheme
.eq_ignore_ascii_case("Bearer")
.then(|| token.trim_start())
});
let ok = supplied
.map(|s| ct_eq(s.as_bytes(), expected.as_bytes()))
.unwrap_or(false);
@@ -185,6 +195,31 @@ mod tests {
);
}
#[tokio::test]
async fn accepts_case_insensitive_bearer_scheme() {
// RFC 6750 §2.1 / RFC 7235 §2.1: the auth-scheme is case-insensitive.
// A correct token must authenticate regardless of scheme casing or
// extra whitespace; a wrong token must still be rejected.
async fn req_status(auth_value: &str) -> StatusCode {
let r = wrap(AuthState::from_token("s3cr3t"));
let mut req = Request::builder()
.method("GET")
.uri("/api/v1/info")
.body(Body::empty())
.unwrap();
req.headers_mut()
.insert(AUTHORIZATION, auth_value.parse().unwrap());
r.oneshot(req).await.unwrap().status()
}
assert_eq!(req_status("Bearer s3cr3t").await, StatusCode::OK);
assert_eq!(req_status("bearer s3cr3t").await, StatusCode::OK);
assert_eq!(req_status("BEARER s3cr3t").await, StatusCode::OK);
assert_eq!(req_status("Bearer s3cr3t").await, StatusCode::OK); // extra space
// Scheme leniency must NOT weaken the token check.
assert_eq!(req_status("bearer nope").await, StatusCode::UNAUTHORIZED);
assert_eq!(req_status("Basic s3cr3t").await, StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn enabled_blocks_api_with_wrong_bearer() {
let r = wrap(AuthState::from_token("s3cr3t"));
@@ -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(),
})
@@ -5570,6 +5953,65 @@ fn vitals_snapshots_from_sensing_json(
}
}
/// Turn a `ProgressiveLoader::new` failure into an actionable diagnostic (#894).
///
/// The published HuggingFace `ruvnet/wifi-densepose-pretrained` files
/// (`model.safetensors`, `model-q{2,4,8}.bin`, `model.rvf.jsonl`) are a
/// different *format* — and a different encoder architecture — than the RVF
/// binary container the `--model` progressive loader expects (`RVFS` magic
/// `0x52564653`). Feeding one to `--model` produced a bare
/// "invalid magic at offset 0 …" that left users stuck. Detect the common
/// cases and explain plainly what's loadable instead.
fn diagnose_model_load_error(path: &std::path::Path, data: &[u8], err: &str) -> String {
let name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.to_ascii_lowercase();
let ext = path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_ascii_lowercase();
// safetensors: 8-byte LE header length, then a JSON object starting with '{'.
let looks_safetensors = ext == "safetensors" || (data.len() > 9 && data[8] == b'{');
// JSONL manifest: starts with '{' (or the well-known suffix).
let looks_jsonl =
ext == "jsonl" || name.ends_with(".rvf.jsonl") || data.first() == Some(&b'{');
// Quantized weight blob shipped on HF (model-q2/q4/q8.bin).
let looks_quant_bin = ext == "bin" || name.contains("-q");
let kind = if looks_safetensors {
"a safetensors weight file"
} else if looks_jsonl {
"a JSONL manifest, not the binary container"
} else if looks_quant_bin {
"a quantized weight blob (e.g. HuggingFace model-q4.bin)"
} else {
"not an RVF binary container"
};
format!(
"model `{}` could not be loaded: it is {kind}. The --model flag expects an \
RVF binary container (`RVFS` magic 0x52564653) produced by the \
wifi-densepose-train pipeline. The HuggingFace ruvnet/wifi-densepose-pretrained \
files are a different format and encoder architecture, so they do not load \
here directly (issue #894). Continuing with signal heuristics. (loader: {err})",
path.display()
)
}
/// Whether `--export-rvf` should emit the placeholder container-format demo.
///
/// It must only do so **standalone**. Combined with `--train`/`--pretrain` the
/// real model is produced by the training pipeline, so short-circuiting here
/// would silently skip training and write placeholder weights — the #894 bug
/// where the documented `--train … --export-rvf` workflow produced a fake model.
fn export_emits_placeholder_demo(export_set: bool, train: bool, pretrain: bool) -> bool {
export_set && !train && !pretrain
}
// ── Main ─────────────────────────────────────────────────────────────────────
/// If `--ui-path` points nowhere (wrong cwd), try common repo layouts relative to cwd.
@@ -5613,9 +6055,24 @@ async fn main() {
return;
}
// Handle --export-rvf mode: build an RVF container package and exit
if let Some(ref rvf_path) = args.export_rvf {
eprintln!("Exporting RVF container package...");
// Handle --export-rvf: writes a CONTAINER-FORMAT DEMO with placeholder
// weights — it is NOT a trained model. Only short-circuit when standalone:
// combined with --train/--pretrain the real model is exported by the
// training pipeline, and short-circuiting here would silently skip training
// and write placeholder weights (#894 — the documented `--train …
// --export-rvf` workflow produced a placeholder and never trained).
if export_emits_placeholder_demo(args.export_rvf.is_some(), args.train, args.pretrain) {
let rvf_path = args
.export_rvf
.as_ref()
.expect("export_emits_placeholder_demo implies export_rvf is set");
eprintln!(
"WARNING: --export-rvf writes a CONTAINER-FORMAT DEMO with placeholder \
weights it is NOT a trained model. Train one with \
`--train --dataset <DIR>` (which exports a calibrated .rvf to the \
models/ directory), or download a pretrained encoder. See issue #894."
);
eprintln!("Exporting RVF container package (placeholder weights)...");
use rvf_pipeline::RvfModelBuilder;
let mut builder = RvfModelBuilder::new("wifi-densepose", "1.0.0");
@@ -5664,6 +6121,13 @@ async fn main() {
}
}
return;
} else if args.export_rvf.is_some() {
// --export-rvf alongside --train/--pretrain: don't emit a placeholder.
// Fall through so training runs; it exports the real calibrated model.
eprintln!(
"Note: --export-rvf is ignored in training mode — the trained model \
is exported by the training pipeline to the models/ directory."
);
}
// Handle --pretrain mode: self-supervised contrastive pretraining (ADR-024)
@@ -6120,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...");
@@ -6131,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,
};
@@ -6207,7 +6694,9 @@ async fn main() {
model_loaded = true;
progressive_loader = Some(loader);
}
Err(e) => error!("Progressive loader init failed: {e}"),
Err(e) => {
error!("{}", diagnose_model_load_error(mp, &data, &e.to_string()))
}
},
Err(e) => error!("Failed to read model file: {e}"),
}
@@ -6395,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()
@@ -7216,3 +7711,72 @@ mod mqtt_bridge_tests {
assert!(!snaps[0].presence);
}
}
#[cfg(test)]
mod model_load_diagnostic_tests {
use super::diagnose_model_load_error;
use std::path::Path;
#[test]
fn safetensors_is_named_and_points_at_894() {
// 8-byte LE header length then '{' — the safetensors signature.
let data = [0x10, 0, 0, 0, 0, 0, 0, 0, b'{', b'"'];
let msg = diagnose_model_load_error(
Path::new("models/wifi-densepose-pretrained/model.safetensors"),
&data,
"invalid magic at offset 0",
);
assert!(msg.contains("safetensors"), "{msg}");
assert!(msg.contains("#894"), "{msg}");
assert!(msg.contains("signal heuristics"), "{msg}");
}
#[test]
fn quantized_bin_is_identified() {
let data = [0x35, 0x57, 0x45, 0x77]; // the 0x77455735 the loader reports
let msg = diagnose_model_load_error(Path::new("model-q4.bin"), &data, "bad magic");
assert!(msg.contains("quantized weight blob"), "{msg}");
assert!(msg.contains("RVFS") || msg.contains("0x52564653"), "{msg}");
}
#[test]
fn jsonl_manifest_is_identified() {
let data = *b"{\"seg\":0}";
let msg = diagnose_model_load_error(Path::new("model.rvf.jsonl"), &data, "x");
assert!(msg.contains("JSONL manifest"), "{msg}");
}
#[test]
fn unknown_format_still_gives_guidance() {
let data = [0u8, 1, 2, 3];
let msg = diagnose_model_load_error(Path::new("weird.dat"), &data, "x");
assert!(msg.contains("RVF binary container"), "{msg}");
assert!(msg.contains("wifi-densepose-train"), "{msg}");
}
}
#[cfg(test)]
mod export_rvf_mode_tests {
use super::export_emits_placeholder_demo;
#[test]
fn standalone_export_emits_placeholder() {
// --export-rvf alone → the container-format demo (placeholder weights).
assert!(export_emits_placeholder_demo(true, false, false));
}
#[test]
fn export_with_train_does_not_short_circuit() {
// #894: `--train --export-rvf` must NOT emit a placeholder + skip
// training — it must fall through to the real training pipeline.
assert!(!export_emits_placeholder_demo(true, true, false));
assert!(!export_emits_placeholder_demo(true, false, true));
assert!(!export_emits_placeholder_demo(true, true, true));
}
#[test]
fn no_export_flag_never_emits() {
assert!(!export_emits_placeholder_demo(false, false, false));
assert!(!export_emits_placeholder_demo(false, true, false));
}
}
@@ -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.**
[![crates.io](https://img.shields.io/crates/v/wifi-densepose-worldgraph.svg)](https://crates.io/crates/wifi-densepose-worldgraph)
[![docs.rs](https://docs.rs/wifi-densepose-worldgraph/badge.svg)](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.**
[![crates.io](https://img.shields.io/crates/v/wifi-densepose-worldmodel.svg)](https://crates.io/crates/wifi-densepose-worldmodel)
[![docs.rs](https://docs.rs/wifi-densepose-worldmodel/badge.svg)](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).