Compare commits

...

17 Commits

Author SHA1 Message Date
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
rUv b12662a54d fix(mqtt): per-node HA devices use each node's own presence/motion (#872) (#918)
The MQTT bridge fanned out one Home-Assistant device per node (#898) but
applied the *room-level aggregate* classification to every node — so in a
multi-node setup a node in an empty corner inherited another node's
"present", and `motion_level: "absent"` was mis-mapped to full motion
(the aggregate match fell through `Some(_) => 1.0`).

Each node in the sensing broadcast's `nodes` array already carries its own
`classification` (`motion_level`/`presence`/`confidence`, see
PerNodeFeatureInfo) and RSSI. Now each per-node snapshot reads that node's
own classification, deferring to the room aggregate only for fields a node
omits. Vitals (breathing/heart rate) and person count stay room-level.

Extracted the JSON→VitalsSnapshot mapping into a pure, testable function
(`vitals_snapshots_from_sensing_json`) and added 4 unit tests covering
per-node divergence, partial-field fallback, the no-nodes aggregate path,
and the absent→zero-motion fix.

Supersedes #899, which targeted the right bug but read non-existent fields
(`node["motion_level"]` / `node["status"]` instead of the nested
`node["classification"]` + `stale`).

Verified: builds with `--features mqtt`; new tests pass; full crate unit
suite 432 + 114 passed, 0 failed.
2026-06-02 19:26:01 +02:00
rUv 573b00fd98 perf(ci): drop dead uvicorn start from perf job (#917)
Since #915 the perf job gates only on test_frame_budget.py, which drives
the CSIProcessor pipeline in-process and makes no HTTP calls. The
"Start application" step (uvicorn + `sleep 10`) was therefore dead weight:
it existed only for the now-excluded api_throughput/inference_speed tests,
wasted ~10-15 s per main-push run, and dumped ~50 misleading
"router requires hardware setup" ERROR lines into every CI log for a
server no test touched. MOCK_POSE_DATA is server-only, unused here.

Removed the step and the vestigial env. The gated test is unchanged and
passes (verified locally, 3/3).
2026-06-02 19:01:08 +02:00
rUv 91b0e625bd docs(#882): complete the "100% presence" retraction across all docs (#916)
The v1 "100% presence accuracy" headline was already retracted in the
README / user-guide intro / proof-of-capabilities — but 6 secondary
spots still flatly claimed "100% accuracy, never false alarms", which
made proof-of-capabilities.md's "replaced everywhere" assertion untrue.

Completed the retraction in-place with the honest label-free metric
(82.3% held-out temporal-triplet; v1 was a single-class recording where
a constant "yes" scores ~99.98%):

- docs/readme-details.md — 2 benchmark tables + the pre-trained-model row
- docs/user-guide.md — capability table, model-file comment, applications list
- CHANGELOG.md — annotated the historical entry in-place (kept as public
  record per built-in-public ethos, not rewritten)

Verified: no remaining flat "100% presence/accuracy" claim lacks a
retraction marker; proof-of-capabilities.md "replaced everywhere" is now
accurate.
2026-06-02 18:50:39 +02:00
54 changed files with 6406 additions and 186 deletions
+17 -20
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
@@ -267,20 +269,15 @@ jobs:
pip install -r requirements.txt
pip install pytest # the perf suite is pytest, not locust
- name: Start application
working-directory: archive/v1
env:
# No CSI hardware in CI — serve mock pose data so the pose endpoints
# respond 200 under load instead of erroring "requires real CSI data".
MOCK_POSE_DATA: "true"
run: |
uvicorn src.api.main:app --host 0.0.0.0 --port 8000 &
sleep 10
# No "Start application" step: the gated test (test_frame_budget.py) drives
# the CSIProcessor pipeline in-process and makes no HTTP calls, so the old
# uvicorn server + `sleep 10` were dead weight — they only existed for the
# now-excluded api_throughput/inference_speed tests, and on every run dumped
# ~50 misleading "router requires hardware setup" ERROR lines for a server
# no test touched. MOCK_POSE_DATA is server-only and unused here.
- name: Run performance tests
working-directory: archive/v1
env:
MOCK_POSE_DATA: "true"
run: |
# Gate only on the genuine, deterministic perf guard:
# test_frame_budget.py times the *real* CSIProcessor pipeline against
+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
+11 -1
View File
@@ -8,12 +8,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Fixed
- **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 +42,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`)
@@ -430,7 +440,7 @@ Model release (no new firmware binary). Firmware remains at v0.6.0-esp32.
- Security fix merged via PR #310.
### Performance
- Presence detection: 100% accuracy on 60,630 overnight samples.
- Presence detection: 100% accuracy on 60,630 overnight samples. *(Retracted — that recording was single-class (one sleeping person, 6,062/6,063 frames "present"), so a constant "yes" scores ~99.98%. Superseded by the honest 82.3% held-out temporal-triplet metric; see [#882](https://github.com/ruvnet/RuView/issues/882). Kept here as the in-place public record.)*
- Inference: 0.008 ms per sample, 164K embeddings/sec.
- Contrastive self-supervised training: 51.6% improvement over baseline.
+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) |
+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
@@ -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).
+3 -3
View File
@@ -122,7 +122,7 @@ node scripts/benchmark-ruvllm.js --model models/csi-ruvllm # benchmark
| What we measured | Result | Why it matters |
|-----------------|--------|---------------|
| **Presence detection** | **100% accuracy** | Never misses a person, never false alarms |
| **CSI embedding quality** | **82.3% held-out temporal-triplet** | Honest label-free metric on the last 20% by time (v1's "100% presence" was a single-class recording — retracted, [#882](https://github.com/ruvnet/RuView/issues/882)) |
| **Inference speed** | **0.008 ms** per embedding | 125,000x faster than real-time |
| **Throughput** | **164,183 embeddings/sec** | One Mac Mini handles 1,600+ ESP32 nodes |
| **Contrastive learning** | **51.6% improvement** | Strong pattern learning from real overnight data |
@@ -233,7 +233,7 @@ python firmware/esp32-csi-node/provision.py --port COM9 --hop-channels "1,6,11"
| **kNN similarity search** | "Find the 10 most similar states to right now" — anomaly detection, fingerprinting | Cognitum Seed |
| **Witness chain** | SHA-256 tamper-evident audit trail for every measurement (1,747 entries validated) | Cognitum Seed |
| **Camera-free pose training** | 17 COCO keypoints from 10 sensor signals — PIR, RSSI triangulation, subcarrier asymmetry, vibration, BME280 | 2x ESP32 + Seed |
| **Pre-trained model** | 82.8 KB (8 KB at 4-bit quantization), 100% presence accuracy, 0 skeleton violations | Download from release |
| **Pre-trained model** | 82.8 KB (8 KB at 4-bit quantization), 82.3% held-out temporal-triplet accuracy (v1's "100% presence" was single-class — retracted, [#882](https://github.com/ruvnet/RuView/issues/882)) | Download from release |
| **Sub-ms inference** | 0.012 ms latency, 171,472 embeddings/sec on M4 Pro | Any machine with Node.js |
| **SONA adaptation** | Adapts to new rooms in <1ms without retraining | ruvllm runtime |
| **LoRA room adapters** | Per-node fine-tuning with 2,048 parameters per adapter | Automatic |
@@ -262,7 +262,7 @@ node scripts/benchmark-ruvllm.js --model models/csi-ruvllm
| What we measured | Result | Why it matters |
|-----------------|--------|---------------|
| **Presence detection** | **100% accuracy** | Never misses a person, never false alarms |
| **CSI embedding quality** | **82.3% held-out temporal-triplet** | Honest label-free metric (v1's "100% presence" was single-class — retracted, [#882](https://github.com/ruvnet/RuView/issues/882)) |
| **Person counting** | **24/24 correct** (MinCut) | Fixed the #1 user-reported issue |
| **Inference speed** | **0.012 ms** per embedding | 83,000x faster than real-time |
| **Throughput** | **171,472 embeddings/sec** | One Mac Mini handles 1,700+ ESP32 nodes |
+5 -5
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 |
@@ -1119,7 +1119,7 @@ What it ships (and what it does not):
| Capability | Status |
|------------|--------|
| Presence detection (occupied / empty) | ✅ Trained head — 100% accuracy on validation |
| Presence detection (occupied / empty) | ✅ Trained head — v2 encoder reports 82.3% held-out temporal-triplet acc (v1's "100% on validation" was a single-class recording — retracted, [#882](https://github.com/ruvnet/RuView/issues/882)) |
| 128-dim CSI embeddings (re-ID, similarity, downstream training) | ✅ Trained encoder |
| Single-person breathing / heart-rate | ⚠️ Server still uses heuristic DSP — model does not replace this yet |
| 17-keypoint full-body pose | 🔬 No keypoint weights shipped yet — pose pipeline runs but without a learned head |
@@ -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:
@@ -1824,7 +1824,7 @@ huggingface-cli download ruvnet/wifi-densepose-pretrained --local-dir models/pre
# model.safetensors — 48 KB contrastive encoder
# model-q4.bin — 8 KB quantized (recommended)
# model-q2.bin — 4 KB ultra-compact (ESP32 edge)
# presence-head.json — presence detection head (100% accuracy)
# presence-head.json — presence detection head (v2 encoder: 82.3% held-out triplet acc)
# node-1.json — LoRA adapter for room 1
# node-2.json — LoRA adapter for room 2
```
@@ -1833,7 +1833,7 @@ huggingface-cli download ruvnet/wifi-densepose-pretrained --local-dir models/pre
The pre-trained encoder converts 8-dim CSI feature vectors into 128-dim embeddings. These embeddings power all 17 sensing applications:
- **Presence detection** — 100% accuracy, never misses, never false alarms
- **Presence detection** — v2 encoder: 82.3% held-out temporal-triplet accuracy (v1's "100%" was a single-class recording — retracted, [#882](https://github.com/ruvnet/RuView/issues/882))
- **Environment fingerprinting** — kNN search finds "states like this one"
- **Anomaly detection** — embeddings that don't match known clusters = anomaly
- **Activity classification** — different activities cluster in embedding space
@@ -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,27 @@ 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. ESP_IDF_VERSION_MAJOR is the canonical guard.
*/
#if ESP_IDF_VERSION_MAJOR >= 6
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
+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
+24 -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",
@@ -10894,10 +10912,10 @@ dependencies = [
"criterion",
"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 +10930,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"
@@ -11187,37 +11191,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 }
@@ -232,7 +232,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 +250,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;
}
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(())
}
@@ -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.
}
};
+3 -3
View File
@@ -15,12 +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"]
api = ["chrono/serde", "geo/use-serde"]
portable = ["low-power"]
low-power = []
distributed = ["tokio/sync"]
drone = ["distributed"]
serde = ["dep:serde", "chrono/serde", "geo/use-serde"]
serde = ["chrono/serde", "geo/use-serde"]
[dependencies]
# Workspace dependencies
@@ -43,7 +43,7 @@ thiserror = "2.0"
anyhow = "1.0"
# Serialization
serde = { version = "1.0", features = ["derive"], optional = true }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
# Time handling
@@ -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());
@@ -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"));
@@ -45,13 +45,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;
}
@@ -1114,7 +1114,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 +1131,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 +1170,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> {
@@ -4979,7 +5161,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={}",
@@ -5476,6 +5696,159 @@ async fn broadcast_tick_task(state: SharedState, tick_ms: u64) {
}
}
/// Map one sensing-broadcast JSON document into the `VitalsSnapshot`(s) to
/// publish over MQTT (issues #872/#898).
///
/// Multi-node sources carry a `nodes` array where **each node has its own
/// `classification`** (`motion_level`, `presence`, `confidence`) and RSSI — so
/// each node must surface its *own* presence/motion, not the room-level
/// aggregate. Previously the bridge applied the aggregate `classification` to
/// every per-node Home-Assistant device, so a node in an empty corner inherited
/// another node's "present" (and `motion_level: "absent"` was mis-mapped to full
/// motion). Vitals (breathing / heart rate) and the person count are room-level
/// and shared across the per-node devices. Falls back to a single aggregate
/// snapshot when there is no per-node data (e.g. wifi / simulate sources).
#[cfg(feature = "mqtt")]
fn vitals_snapshots_from_sensing_json(
v: &serde_json::Value,
base_id: &str,
) -> Vec<wifi_densepose_sensing_server::mqtt::state::VitalsSnapshot> {
use wifi_densepose_sensing_server::mqtt::state::VitalsSnapshot;
// motion_level string -> motion scalar. "absent"/"none"/"still"/"idle"/""
// are non-moving; anything else (walking, …) is motion. `fallback` is used
// when the field is absent so a partial per-node payload defers to the
// room aggregate rather than silently reading 0.
fn motion_of(level: Option<&str>, fallback: f64) -> f64 {
match level {
Some("none") | Some("still") | Some("idle") | Some("absent") | Some("") => 0.0,
Some(_) => 1.0,
None => fallback,
}
}
let ts = (v["timestamp"].as_f64().unwrap_or(0.0) * 1000.0) as i64;
let vit = &v["vital_signs"];
let breathing = vit["breathing_rate_bpm"].as_f64();
let hr = vit["heart_rate_bpm"].as_f64();
let n_persons = v["persons"]
.as_array()
.map(|a| a.len() as u32)
.or_else(|| v["estimated_persons"].as_u64().map(|x| x as u32))
.unwrap_or(0);
// Room-level aggregate: the no-nodes fallback, and the per-node default for
// any field a node omits.
let acls = &v["classification"];
let agg_presence = acls["presence"].as_bool().unwrap_or(false);
let agg_motion = motion_of(acls["motion_level"].as_str(), 0.0);
let agg_conf = acls["confidence"].as_f64().unwrap_or(0.0);
let mk = |node_id: String, presence: bool, motion: f64, conf: f64, rssi: Option<f64>| {
VitalsSnapshot {
node_id,
timestamp_ms: ts,
presence,
motion,
presence_score: if presence { conf.max(0.0) } else { 0.0 },
breathing_rate_bpm: breathing,
heartrate_bpm: hr,
n_persons,
rssi_dbm: rssi,
vital_confidence: conf,
..Default::default()
}
};
match v["nodes"].as_array() {
Some(arr) if !arr.is_empty() => arr
.iter()
.map(|node| {
let n = node["node_id"].as_u64().unwrap_or(0);
// Each node carries its OWN classification — use it, deferring to
// the room aggregate only for fields the node omits.
let ncls = &node["classification"];
let presence = ncls["presence"].as_bool().unwrap_or(agg_presence);
let motion = motion_of(ncls["motion_level"].as_str(), agg_motion);
let conf = ncls["confidence"].as_f64().unwrap_or(agg_conf);
mk(
format!("{base_id}-node{n}"),
presence,
motion,
conf,
node["rssi_dbm"].as_f64(),
)
})
.collect(),
_ => vec![mk(
base_id.to_string(),
agg_presence,
agg_motion,
agg_conf,
v["nodes"][0]["rssi_dbm"].as_f64(),
)],
}
}
/// 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.
@@ -5519,9 +5892,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");
@@ -5570,6 +5958,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)
@@ -6026,7 +6421,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...");
@@ -6037,10 +6442,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,
};
@@ -6113,7 +6531,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}"),
}
@@ -6200,56 +6620,13 @@ async fn main() {
let Ok(v) = serde_json::from_str::<serde_json::Value>(&json) else {
continue;
};
let cls = &v["classification"];
let vit = &v["vital_signs"];
let presence = cls["presence"].as_bool().unwrap_or(false);
let n_persons = v["persons"]
.as_array()
.map(|a| a.len() as u32)
.or_else(|| v["estimated_persons"].as_u64().map(|x| x as u32))
.unwrap_or(0);
let motion = match cls["motion_level"].as_str() {
Some("none") | Some("still") | Some("idle") | Some("") => 0.0,
Some(_) => 1.0,
None => 0.0,
};
let ts = (v["timestamp"].as_f64().unwrap_or(0.0) * 1000.0) as i64;
let conf = cls["confidence"].as_f64().unwrap_or(0.0);
let presence_score = if presence { conf.max(0.0) } else { 0.0 };
let breathing = vit["breathing_rate_bpm"].as_f64();
let hr = vit["heart_rate_bpm"].as_f64();
// #898: emit one snapshot per physical node so each
// surfaces as its own Home-Assistant device (with
// its own RSSI + availability). Falls back to a
// single aggregate snapshot when there is no
// per-node data (e.g. wifi / simulate sources).
let mk = |nid: String, rssi: Option<f64>| mqtt::state::VitalsSnapshot {
node_id: nid,
timestamp_ms: ts,
presence,
motion,
presence_score,
breathing_rate_bpm: breathing,
heartrate_bpm: hr,
n_persons,
rssi_dbm: rssi,
vital_confidence: conf,
..Default::default()
};
match v["nodes"].as_array() {
Some(arr) if !arr.is_empty() => {
for node in arr {
let n = node["node_id"].as_u64().unwrap_or(0);
let nid = format!("{node_id}-node{n}");
let _ = vtx.send(mk(nid, node["rssi_dbm"].as_f64()));
}
}
_ => {
let _ = vtx.send(mk(
node_id.clone(),
v["nodes"][0]["rssi_dbm"].as_f64(),
));
}
// #898/#872: emit one snapshot per physical node so
// each surfaces as its own Home-Assistant device with
// its *own* presence/motion/RSSI (see
// vitals_snapshots_from_sensing_json). Falls back to a
// single aggregate snapshot for per-node-less sources.
for snap in vitals_snapshots_from_sensing_json(&v, &node_id) {
let _ = vtx.send(snap);
}
}
});
@@ -7068,3 +7445,169 @@ mod rolling_p95_tests {
assert_eq!(p.len(), 1);
}
}
#[cfg(all(test, feature = "mqtt"))]
mod mqtt_bridge_tests {
use super::vitals_snapshots_from_sensing_json;
use serde_json::json;
/// Regression for the per-node presence bug (#872/#898): each node must
/// surface its OWN classification, not the room-level aggregate. Node 1 is
/// present+moving; node 2 is absent — node 2 must NOT inherit node 1's
/// "present".
#[test]
fn per_node_presence_uses_each_nodes_own_classification() {
let v = json!({
"timestamp": 1.0,
"classification": { "presence": true, "motion_level": "walking", "confidence": 0.9 },
"vital_signs": { "breathing_rate_bpm": 14.0, "heart_rate_bpm": 60.0 },
"persons": [{}, {}],
"nodes": [
{ "node_id": 1, "rssi_dbm": -40.0,
"classification": { "presence": true, "motion_level": "walking", "confidence": 0.8 } },
{ "node_id": 2, "rssi_dbm": -70.0,
"classification": { "presence": false, "motion_level": "absent", "confidence": 0.1 } }
]
});
let snaps = vitals_snapshots_from_sensing_json(&v, "ruview");
assert_eq!(snaps.len(), 2, "one snapshot per node");
let n1 = snaps.iter().find(|s| s.node_id == "ruview-node1").unwrap();
let n2 = snaps.iter().find(|s| s.node_id == "ruview-node2").unwrap();
assert!(n1.presence && n1.motion > 0.0, "node1 present + moving");
assert!(
!n2.presence && n2.motion == 0.0,
"node2 must be absent — not inherit the room aggregate"
);
// Per-node RSSI preserved.
assert_eq!(n1.rssi_dbm, Some(-40.0));
assert_eq!(n2.rssi_dbm, Some(-70.0));
// Vitals + person count are room-level, shared across node devices.
assert_eq!(n1.n_persons, 2);
assert_eq!(n2.n_persons, 2);
assert_eq!(n1.breathing_rate_bpm, Some(14.0));
assert_eq!(n2.heartrate_bpm, Some(60.0));
// presence_score is gated on presence.
assert!(n1.presence_score > 0.0);
assert_eq!(n2.presence_score, 0.0);
}
/// A node that omits a classification field defers to the room aggregate
/// rather than silently reading false/0.
#[test]
fn per_node_missing_fields_fall_back_to_aggregate() {
let v = json!({
"timestamp": 1.0,
"classification": { "presence": true, "motion_level": "still", "confidence": 0.7 },
"vital_signs": {},
"nodes": [ { "node_id": 3, "rssi_dbm": -55.0 } ] // no per-node classification
});
let snaps = vitals_snapshots_from_sensing_json(&v, "n");
assert_eq!(snaps.len(), 1);
assert_eq!(snaps[0].node_id, "n-node3");
assert!(snaps[0].presence, "defers to aggregate presence");
assert_eq!(snaps[0].motion, 0.0, "aggregate 'still' => no motion");
}
/// No `nodes` array (wifi / simulate sources): single aggregate snapshot
/// keyed by the base id.
#[test]
fn falls_back_to_single_aggregate_when_no_nodes() {
let v = json!({
"timestamp": 2.0,
"classification": { "presence": true, "motion_level": "idle", "confidence": 0.6 },
"vital_signs": { "breathing_rate_bpm": 12.0 },
"persons": [{}]
});
let snaps = vitals_snapshots_from_sensing_json(&v, "ruview");
assert_eq!(snaps.len(), 1);
assert_eq!(snaps[0].node_id, "ruview");
assert!(snaps[0].presence);
assert_eq!(snaps[0].motion, 0.0, "idle => no motion");
assert_eq!(snaps[0].n_persons, 1);
}
/// `motion_level: "absent"` must map to zero motion (the old aggregate
/// match fell through to `Some(_) => 1.0`, treating absent as full motion).
#[test]
fn absent_motion_level_is_zero_motion() {
let v = json!({
"timestamp": 0.0,
"classification": { "presence": false, "motion_level": "absent", "confidence": 0.0 },
"vital_signs": {}
});
let snaps = vitals_snapshots_from_sensing_json(&v, "x");
assert_eq!(snaps[0].motion, 0.0);
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));
}
}
@@ -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);
@@ -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).
@@ -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).