* 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>
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.
* 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>
* 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>
`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.
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).
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.
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.
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.
field_bridge::occupancy_or_fallback returned FieldModel::estimate_occupancy
unbounded (internal ceiling 10), while the perturbation fallback below it
and score_to_person_count both cap at 3 ("1-3 for single ESP32"). On noisy
or under-calibrated CSI the eigenvalue count inflated → "10 persons when 1
present" (#894, seen when --model fails to load → heuristic mode). Bound the
eigenvalue path to a shared MAX_SINGLE_LINK_OCCUPANCY const (3) so every
single-link estimator agrees. Genuine higher counts come from the
multistatic fusion path. Build clean, field_bridge tests pass.
After the per-node discovery change, discovery configs are published the
first time a snapshot for a node_id arrives (not eagerly at startup). The
two discovery integration tests (discovery_topics_appear_on_broker,
privacy_mode_suppresses_biometric_discovery) spawned the publisher with an
empty broadcast channel and never sent a snapshot, so they collected []
and failed ("missing presence discovery topic in []").
Drive snapshots for the test node_id throughout the capture window (same
pattern as state_messages_published_on_snapshot_broadcast) so the per-node
device's discovery lands. Verified against a local mosquitto: 3 passed.
After the #872 MQTT wiring, the JSON->VitalsSnapshot bridge hard-coded a
single node_id (the MQTT client id) and the publisher used one
OwnedDiscoveryBuilder, so every physical node collapsed into a single
Home-Assistant device (identifiers:["wifi_densepose_wifi-densepose-1"]),
contradicting the one-device-per-node docs.
- Bridge (main.rs): emit one VitalsSnapshot per node in the sensing
update's nodes[] (each carries its own node_id + RSSI; shared aggregate
presence/vitals), falling back to a single aggregate snapshot when
there is no per-node data (wifi/simulate sources).
- Publisher (publisher.rs): add OwnedDiscoveryBuilder::for_node(), and
publish discovery + availability lazily on first sight of each node_id,
routing state to per-node topics. Heartbeat/refresh/offline-LWT iterate
all known nodes. Result: N distinct HA devices, one per node.
3 new unit tests (distinct nodes -> distinct wifi_densepose_<node>
identifiers); full MQTT suite 71 passed, example builds.
CI `cargo test --no-default-features (baseline regression)` failed with
`error: associated function compute is never used` under -D warnings.
compute() is only reachable via PrivacyModeRegistry (#[cfg(feature =
"std")]); without std there is no caller. Gate the impl to match its only
callers. Verified clean under --no-default-features, default, and
--features mqtt with RUSTFLAGS=-D warnings.
Co-Authored-By: claude-flow <ruv@ruv.net>
CI `clippy (-D warnings, --no-deps)` failed on patterns.rs:131 —
`row % 2 == 0` is flagged by clippy::manual_is_multiple_of. Use
`row.is_multiple_of(2)` (identical even-row check). Both CI clippy
variants (--no-default-features and --features full,train) now pass.
Co-Authored-By: claude-flow <ruv@ruv.net>
The pure-CSI per-node path clamped its own occupancy estimate before the
aggregator could read it. estimate_persons_from_correlation (DynamicMinCut)
returns 0-3, but it 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, leaving
node_max stuck at 1 for CSI-only nodes even when the min-cut cleanly
separated two people.
Replace the lossy /3.0 mapping with a threshold-aligned corr_persons_to_score
(1->0.40, 2->0.74, 3->0.96) whose steady state round-trips back to the same
count through the EMA + hysteresis bands, while still gating transient noise.
A convergence test replays the exact CSI-loop EMA and asserts min-cut=2 now
reports 2 / 3 reports 3 / 1 reports 1, plus a regression test documenting
that the old /3.0 mapping pinned two people to 1.
Full suite: 586 passed, 0 failed.
Co-Authored-By: claude-flow <ruv@ruv.net>
Person count was pinned to 1 because the aggregate was derived from
`smoothed_person_score`, an EMA-smoothed *activity* score (amplitude
variance / motion / spectral energy) that saturates near a single
occupant and cannot discriminate count. The count-aware per-node
estimates the ESP32 paths already compute (firmware n_persons, mincut
corr_persons) were stored in NodeState::prev_person_count then discarded
by the aggregator — the same dead-wiring class as #872.
Add `aggregate_person_count(activity_count, node_states)` = max(activity,
node_max) and use it at both ESP32 aggregation sites (edge-vitals + CSI
loop, Some + fallback arms). It can only raise the count when a node
positively reports more occupants, so the lone-occupant case is provably
never inflated (regression-guarded).
5 new unit tests + full suite: 582 passed, 0 failed.
Co-Authored-By: claude-flow <ruv@ruv.net>
#872 reported '--mqtt: unexpected argument' on the Docker image; prior
attempts chased a Docker *rebuild*, but the real cause was disconnected
*code*: the --mqtt* flags lived only in cli::Args (dead code — referenced
nowhere), while the binary parses a separate main::Args with no mqtt fields,
and main.rs never declared/started the mqtt:: publisher. So MQTT was fully
unwired: flags didn't parse, and the publisher never ran.
Fix:
- Extract the mqtt + privacy flags into a shared
(#[derive(clap::Args)]); retarget mqtt::config::{from_args,build_tls} to it.
- #[command(flatten)] MqttArgs into the binary's main::Args (using the *lib*
crate's type so it matches from_args), so --mqtt* now parse.
- Spawn the publisher on --mqtt: build MqttConfig, validate, and bridge the
existing JSON sensing broadcast into the typed VitalsSnapshot stream the
publisher consumes (defensive serde_json::Value mapping — absent fields
default, never wrong values). #[cfg(feature=mqtt)]-gated; without the
feature --mqtt WARNs and no-ops (documented contract). Fix the
mqtt_publisher example for the new signature.
Verified end-to-end against local mosquitto: publisher connects and emits
20 HA auto-discovery entities + live state (presence ON, person_count, …).
Tests: 577 pass default / 580 pass --features mqtt / 0 fail; both configs
build.
Co-Authored-By: claude-flow <ruv@ruv.net>
The cir_pipeline end-to-end test was gated on the same dominant_tap_ratio
floor; the windowed-ratio fix resolves it. All 6 ADR-134 P2 CIR tests
(cir_synthetic 5 + cir_pipeline 1) now pass. signal+cir: 472 pass / 0 fail.
Co-Authored-By: claude-flow <ruv@ruv.net>
Found the principled fix for the rms-delay-spread inflation (superseding my
prior 'needs ISTA work' note): the spurious ~15-20% tap at ~bin 150 is an
ALIAS of the near-zero dominant tap — the ISTA delay grid is circular (Φ is
DFT-like), so bins >= G/2 are non-causal negative delays. Computing the delay
spread over only the causal half [0, G/2) drops rms from 389ns to 65ns (true
value), cleanly and robustly (no fragile magnitude threshold). Un-ignores
should_produce_positive_rms_delay_spread.
ADR-134 P2 cir_synthetic now FULLY resolved: all 5 previously-ignored tests
pass via two physics-justified fixes (windowed dominant-ratio for super-
resolution leakage + causal-window rms for circular-grid aliasing). signal+cir:
471 pass / 0 fail / 0 ignored in cir_synthetic.
Co-Authored-By: claude-flow <ruv@ruv.net>
Diagnosed the one still-ignored CIR test: ISTA emits a spurious ~15-20%-of-
dominant tap at an implausible far delay (~bin 150 / ~3us) that inflates
rms_delay_spread to ~390ns (vs ~53ns true). It sits too close to the real
weakest tap (~30% of dominant) for a safe magnitude cutoff, so the proper fix
is ISTA recovery-quality work (grid de-aliasing / far-tap suppression), not a
band-aid threshold. Sharpened the #[ignore] note accordingly. signal+cir:
470 pass / 0 fail.
Co-Authored-By: claude-flow <ruv@ruv.net>
The CIR estimator's dominant_tap_ratio measured a single grid bin, but on the
3x super-resolved ISTA grid a single physical tap leaks across ~3 adjacent
bins — so the ratio under-counted the dominant tap and sat far below the
per-tier floors (HT20 0.158<0.30, HT40 0.133<0.35, HE20 0.102<0.40), forcing
the 3-tap recovery + 40MHz-ToF tests to be #[ignore]d.
Fix (data-backed via a lambda sweep): (1) compute dominant_tap_ratio over a
+/-1-bin window around the peak — the physical tap's true footprint; (2) tune
L1 lambda for sparse multipath (HT20 .05->.08, HT40 .03->.08, HE20 .03->.18).
Result: ratios 0.367/0.406/0.474, comfortably above floors with all 3 taps
preserved. Un-ignores should_recover_3tap_channel_{ht20,ht40,he20} and
should_return_tof_at_40mhz. signal crate: 470 pass / 0 fail; change isolated
to CIR (no external consumers). The rms-delay-spread test stays ignored with a
re-scoped note (far-tap robustness is separate remaining work).
Co-Authored-By: claude-flow <ruv@ruv.net>
Closes the last verification gap in the calibration feature: previously the
Python producer and Rust consumer were proven compatible only by format
matching. Now a real ~11KB adapter fitted by cog_calibrate.py on the in-repo
pose_v1.safetensors is committed as a fixture, and a Rust test loads it via
the engine and asserts is_calibrated() + that it changes inference output.
The full Python->Rust calibration contract is verified with a real artifact.
7/7 cog-pose tests pass.
Co-Authored-By: claude-flow <ruv@ruv.net>
The --adapter docs claimed the adapter is produced by
aether-arena/calibration/calibrate.py, but that reference tool targets the
MM-Fi *transformer* model and emits .npz with proj/head LoRA keys, while
this cog runs a *conv+MLP* model expecting safetensors with fc1.a/fc1.b/
fc2.a/fc2.b. Same LoRA mechanism, different model -> adapters are
model-specific and NOT interchangeable. Clarify the expected key layout and
that the Python tool is a mechanism reference, not a drop-in producer.
6/6 tests pass.
Co-Authored-By: claude-flow <ruv@ruv.net>
readme_quickstart_uses_canonical_public_api checked a multi-line needle
'pipeline\n .process' against the include_str! README. On a CRLF
checkout (Windows / core.autocrlf) the content is 'pipeline\r\n .process',
so the LF needle never matched and the test failed deterministically (only
surfaced once the worldmodel fix let cargo test --workspace run on Windows;
the test is #[cfg(feature=std)]-gated, enabled via workspace feature
unification). Normalize CRLF->LF before the check. Full workspace now green
3/3 runs on Windows.
Co-Authored-By: claude-flow <ruv@ruv.net>
bridge.rs imported tokio::net::UnixStream unconditionally, so the whole
workspace failed to build on Windows (E0432) — blocking cargo test
--workspace and the pre-merge gate there. The OccWorld Unix-socket bridge
is a Linux-appliance feature (Python inference server on the GPU host), so
gate it #[cfg(unix)] and add a #[cfg(not(unix))] send_recv that fails fast
with a clear 'unsupported on this target' Protocol error. Workspace now
builds on Windows; worldmodel 12 tests pass.
Co-Authored-By: claude-flow <ruv@ruv.net>
Completes the end-to-end product path: cog-pose-estimation run --config
<cfg> --adapter <room.safetensors> loads the shared base + a per-room LoRA
adapter for calibrated inference. Adds InferenceEngine::with_adapter()
(default weights + adapter) and logs when a calibration adapter is active.
6/6 tests pass.
Co-Authored-By: claude-flow <ruv@ruv.net>
Ports the calibration mechanism (ADR-150 §3.5-3.6, reference impl in
aether-arena/calibration/) into the real product pose engine. The Candle
InferenceEngine now loads an optional per-room adapter safetensors and
applies low-rank deltas (y + (x.A).B) on the fc1/fc2 head at inference.
Architecture-agnostic LoRA; base behaviour unchanged when no adapter.
New API: with_weights_and_adapter(), is_calibrated(). Tested: adapter
detection + output-change integration test (6/6 pass).
Co-Authored-By: claude-flow <ruv@ruv.net>
Per direction "remove the initial number, optimize for benchmark first" + "include
witness chain capabilities for proof and repeatability analysis":
- Empty board, no seeded numbers: ledger seeds to genesis only. Every result is a
real scoring-pipeline witness; RuView gets no hand-entered baseline.
- Real model scoring: aa_score_runner now loads predictions + an eval split
(--split/--pred) and scores them through the real ruview_metrics pose harness —
not just a synthetic fixture. Committed public smoke split (fixtures/smoke_*.json).
- Witness chain: each score emits a witness = inputs_sha256 (binds it to the exact
inputs) + proof_sha256 (cross-platform-stable score hash) + harness_version.
- Repeatability analysis: --repeat N runs the harness N× and fails if it ever
yields >=2 distinct proof hashes (16/16 identical locally).
- Witness ledger: ledger/ledger_tools.py — append-only, hash-chained, tamper-
evident (seed/append/verify); editing any past row breaks the chain.
- CI gate extended: determinism + repeatability(16) + real-scoring smoke + ledger
chain verify on every PR.
Co-Authored-By: claude-flow <ruv@ruv.net>
AetherArena ("AA") — the official, project-agnostic Spatial-Intelligence Benchmark
(ADR-149, Accepted). Iteration 1 of the long-horizon build:
- ADR-149 accepted: name locked (ruvnet/aether-arena), v0 metrics locked
(pose/presence/latency/determinism), dataset legality resolved (MM-Fi CC BY-NC
only; Wi-Pose excluded). Adds four-part framing, threat model, arena_score
formula, submission state machine, neutrality/governance, and the §7 acceptance test.
- aa_score_runner: deterministic scorer bin reusing the real ruview_metrics pose
harness on a fixed seed=42 fixture → RuViewTier-style verdict + cross-platform
SHA-256 proof hash. Builds --no-default-features (no torch/GPU). VERDICT: PASS.
- CI harness gate: .github/workflows/aether-arena-harness.yml runs the scorer on
every PR — the "PR that runs the harness as part of the build" requirement.
- Scaffold: aether-arena/{README,VERIFY,STATUS}.md + schema/aa-submission.toml.
- Horizon record persisted (.claude-flow/horizons/aether-arena-aa.json).
Infra = the deliverable; model SOTA (MM-Fi PCK@20) is a separate effort blocked on
ADR-079 data collection, tracked as a stretch goal, not an infra exit.
Co-Authored-By: claude-flow <ruv@ruv.net>
Drives a real SemanticBus: raw snapshot (fall_detected, past warmup) ->
FallRisk primitive -> SemanticStateRecord (provenance) -> single-signal rule
fires / multi-signal agreement rule does NOT (no false escalation) -> expired
record rejected. Proves the ADR-140 credibility path end to end.
Co-Authored-By: claude-flow <ruv@ruv.net>