Files
ruvnet--RuView/docs/adr/ADR-151-room-calibration-specialist-training.md
T
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

26 KiB
Raw Blame History

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 modelsspecialised 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_sanitizerphase_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)

# 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.