mirror of
https://github.com/ruvnet/RuView
synced 2026-06-14 11:03:18 +00:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 42dcf49f4d | |||
| 74ecce3218 | |||
| fd1430e46f | |||
| 107232c0be | |||
| 287885776b | |||
| 29e937ef52 | |||
| 41665d3de9 | |||
| c6eacb7ff8 | |||
| 153bc0595b | |||
| 8fd4ee917d | |||
| 5c5112db0e | |||
| e3696da8d8 | |||
| 9457d441b2 | |||
| 626b4b2e97 | |||
| 260fceefe9 | |||
| e063de5970 |
+16
-2
@@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Security
|
||||
- **ADR-080 open HIGH findings closed on the Rust `wifi-densepose-sensing-server` boundary (ADR-164 G11).** The QE sweep's three HIGH findings — XFF-spoofing bypass, leaked stack traces, JWT-in-URL (CWE-598) — were logged against the Python v1 API and never re-verified against the shipped Rust sensing-server; the HOMECORE/M7 sweep (ADR-161) covered `homecore-server`, not this crate.
|
||||
- **#2 leaked internal errors (the one live exposure) — FIXED.** Six handlers in `main.rs` serialized the internal error `Display` straight into the JSON response body: `edge_registry_endpoint` returned a panicked `spawn_blocking` `JoinError` (`"task … panicked"`) in a `500`, plus the raw upstream error in a `503`; `delete_model`/`delete_recording`/`start_recording` returned `std::io::Error` strings (OS detail / path); `calibration_start`/`calibration_stop` returned the `FieldModel` error chain. New `error_response` module logs the full detail **server-side only** (with a correlation id) and returns a generic body (`{"error":"internal_error","correlation_id":…}`) — no `panicked`, no file paths, no Debug chain. 5 module tests (a leak-substring guard proven to fail on the reverted old body) + the existing handler suite.
|
||||
- **#1 XFF-spoofing bypass — VERIFIED ABSENT, regression-pinned.** The sensing-server has no XFF-trusting control to bypass: there is no IP-based rate-limiter or IP-allowlist, and neither `bearer_auth` (token-only) nor `host_validation` (Host-header only) reads `X-Forwarded-For`/`X-Forwarded-Host` (no `forwarded`/`peer_addr`/`client_ip` anywhere in the crate). Added regression tests proving a spoofed `X-Forwarded-For` never flips an auth decision and a spoofed `X-Forwarded-Host` never bypasses the Host allowlist.
|
||||
- **#3 JWT-in-URL (CWE-598) — VERIFIED ABSENT, regression-pinned.** `require_bearer` reads the token only from the `Authorization` header; the WebSocket handlers take no token query param and the sole `Query` extractor (`EdgeRegistryParams`) is a non-secret `refresh` flag. Added a regression proving `?token=`/`?access_token=` in the URL never authenticates while the header path still does.
|
||||
|
||||
### Fixed
|
||||
- **ADR-154 Milestone-1 — cleared the P1 deferred backlog in `wifi-densepose-signal` (§7.4 #1, #10; partial #9, #13).** Each fix pinned by a regression test that fails on the old behaviour; every claim graded MEASURED / DATA-GATED; no fabricated thresholds. Python proof unchanged (`f8e76f21…46f7a`, bit-exact — the CIR ghost-tap guard is not on the deterministic proof path).
|
||||
- **#1 (MEASURED metric / DATA-GATED threshold): circular phase variance.** `cir.rs::phase_variance` computed a *linear* sample variance over phase angles that wrap at ±π, so a tightly-clustered set straddling the branch cut reported spuriously HIGH dispersion — false-tripping the `> TAU` ghost-tap **guard** on real, tightly-clustered CIR taps. Replaced with Mardia's **circular variance** V = 1 − R̄, bounded **[0,1]** and invariant to where the cluster sits on the circle. The old TAU-scaled threshold is meaningless on [0,1]; re-derived against a named const `GHOST_TAP_CIRCULAR_VARIANCE_MAX = 0.99` (fires only when R̄ ≤ 0.01 — essentially uniform phase). The **metric is MEASURED**; the **threshold value is DATA-GATED** (a clean single-path ramp also sweeps the circle, so V alone can't separate clean from unsanitized without labelled frames — the default is deliberately conservative, strictly more permissive at the wrap boundary than the buggy linear guard). Fails-on-old: `phase_variance_circular_not_fooled_by_branch_cut` (old linear variance > TAU on wrap-straddling phases while circular V≈0, guard no longer trips) + `phase_variance_circular_is_bounded_and_extremal` (V∈[0,1], V≈0 identical, V≈1 uniform).
|
||||
- **#10 (MEASURED): Welford n=0/n=1 finiteness guard pinned.** The shared `WelfordStats` (`field_model.rs`) `count < 2` guards keep `variance`/`sample_variance`/`std_dev`/`z_score` finite at the boundaries, but the n=0 case was untested (same family as the §4 divide-by-(n−1) trio). Added `welford_finite_at_n0_and_n1` — finite + documented-sentinel (0.0) at n=0/n=1. Fails-on-old proof: removing the `sample_variance` guard makes the test panic with "attempt to subtract with overflow" at the `(count − 1)` underflow (guard restored).
|
||||
- **#9, #13 (DATA-GATED): de-magicked thresholds + boundary tests (values UNCHANGED).** Lifted the bare detection literals in `adversarial.rs` (`check`/`check_consistency`: Gini 0.8, energy ratios 2.0/0.1, consistency 0.1·mean, score weights), `coherence.rs::classify_drift` (0.85, 10) and `coherence_gate.rs` defaults (0.85/0.5/200/3.0) into named, documented consts marked EMPIRICAL DEFAULT pending labelled calibration. Added characterization/boundary tests pinning each decision at/just-below/just-above its threshold (`energy_ratio_high_boundary`, `energy_ratio_low_boundary`, `field_model_gini_boundary`, `consistency_active_fraction_boundary`, `classify_drift_*_boundary`, `*_consts_unchanged_from_literals`) so a future labelled-data retune is a visible, tested change. The operating **values were not changed**; the de-magicking + tests are MEASURED, the values stay DATA-GATED.
|
||||
- **Multistatic fusion guard was too tight for real TDM hardware (#1031).** `MultistaticConfig::default().guard_interval_us` was 5,000 µs (5 ms) with a comment claiming "well within the 50 ms TDMA cycle" — but on a real N-slot TDM schedule node `k` transmits in slot `k`, so two nodes are separated by the *slot offset*, not clock jitter. A real 2-node mesh (slots 0/1) measured an **18,194 µs** spread, so every real frame set exceeded the 5 ms guard and `fuse()` silently fell back to per-node sum/dedup — multistatic fusion never actually ran on hardware. Raised the default hard guard to **60 ms** (a full 50 ms TDMA cycle + 20% jitter headroom, derived from the slot model and documented in the field doc) and the soft guard to **20 ms** (just above the observed 18.2 ms 2-slot spread, so a normal cycle fuses cleanly with no privacy demotion). Added `MultistaticConfig::for_tdm_schedule(total_slots, slot_duration_us)` to derive the guard from a deployment's exact schedule, and a `WDP_TDM_SLOTS`+`WDP_TDM_SLOT_US` env seam in sensing-server. The honest per-node fallback remains for genuinely-mismatched frames — now the exception, not the default. Pinned by `fuse_real_tdm_spread_18194us_fuses_with_default_guard` (fails on the old 5 ms default) + `configurable_guard_rejects_too_large_spread` (guard still rejects a spread beyond one cycle).
|
||||
- **Published HuggingFace model was unloadable — RVF format mismatch (#894).** The `ProgressiveLoader` rejected the published `ruvnet/wifi-densepose-pretrained` model with the opaque `invalid magic at offset 0: expected 0x52564653 (RVFS), got 0x77455735`, then silently fell back to signal heuristics (the "10 persons for 1" garbage reporters saw). The HF repo ships `model.safetensors`, `model-q{2,4,8}.bin` (magic `0x77455735` = "5WEw"), and `model.rvf.jsonl` — none carry the binary-RVF magic. New `model_format` module **auto-detects** RVFS / safetensors / HF-quant-bin / JSONL by magic+name, returns a **typed actionable** `ModelLoadError` (lists accepted formats + the one-command convert path — never the opaque magic), and **converts** `model.safetensors` / `model.rvf.jsonl` → RVF in-memory so the published full-precision model now loads via `--model`. A `--convert-model <in> --convert-out <out>` CLI subcommand gives a one-command offline path; the silent heuristics fallback is now a loud, actionable error. **Honest scope:** the converter wires the format/load path (safetensors F32 tensors → RVF weight segment, manifest written, Layer A/B/C all succeed, weights round-trip) — it does **not** claim end-to-end pose accuracy, since the HF pose-decoder architecture differs from this crate's inference head (still data-gated in #894). Quantized `.bin` blobs are rejected with a typed error pointing at the safetensors path. Pinned by `safetensors_converts_and_loads` + `hf_quant_classifies_to_actionable_error` (both fail on the old opaque-magic path).
|
||||
|
||||
### Changed
|
||||
- **Mesh partition risk now demotes the privacy class and is witnessed (ADR-032).** The dynamic min-cut guard's `at_risk` signal was advisory-only (it fed the recalibration advisor). It now also contributes to the ADR-141 privacy demotion alongside fusion- and array-level contradictions: a mesh close to partitioning makes the fused belief less trustworthy, so the cycle emits at a more restricted class (monotonic — information only removed). Because `effective_class` feeds the BLAKE3 witness, a fragmenting array now shifts the witness — partition risk is auditable, not just logged. The mesh computation moved ahead of the demotion step in `process_cycle`; new `mesh_guard_mut()` exposes risk-threshold tuning. Test proves a forced-risk 3-node cycle demotes PrivateHome Anonymous→Restricted and shifts the witness vs a clean *same-topology* baseline (the only delta between the two cycles is the forced risk).
|
||||
|
||||
@@ -31,7 +45,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- **Dynamic min-cut mesh partition guard in the streaming engine (`mesh_guard`).** Maintains a `ruvector-mincut` exact min-cut over the live mesh coupling graph (nodes = sensing nodes, coupling = product of fusion attention weights), surfacing per cycle: the global **cut value** (how close the array is to splitting — a structural measure per-node heuristics miss), the **weak side** (which specific nodes would partition: failure/jamming triage feeding ADR-032 posture), and an **at-risk flag** that counts as a structural event for the drift→recalibration advisor. Surfaced as `TrustedOutput::mesh`. **Measured cost policy** (criterion, 12-node mesh): weights are quantized (1/64; a *nonzero* coupling below one quantum saturates to quantum 1 so quantization never erases a live coupling — without the floor, balanced meshes of ≥ 65 nodes had every ~1/n coupling erased and sat permanently "at risk") and updates change-gated, so the steady-state cycle does zero graph work (~7.3 µs, ~23× cheaper than building); on any real change a full exact rebuild (~171 µs) is used because one `DynamicMinCut` delete+insert measured ~240 µs — the incremental machinery's overhead targets much larger graphs, so rebuild-on-change is the measured optimum at mesh scale (one-edge case −28% after the policy switch). Degenerate cases fail toward risk: a node with zero coupling is reported as already partitioned (cut 0). 9 mesh-guard tests + an engine-level wiring test; full `process_cycle` with the guard: ~33 µs for 4 nodes (50 ms budget).
|
||||
- **Opt-in FFT operator for the CIR ISTA solver (8–14× measured).** Φ is a sub-DFT, so each ISTA mat-vec can run as one length-G FFT (O(G log G)) instead of a dense O(K·G) product. New `CirConfig::fft_operator` (default **false** — the dense path stays the bit-exact witness default; the FFT evaluates the same sums in a different order, so enabling it shifts float results and requires regenerating any pinned witness). `FftOperator` (rustfft, planned once at construction, scratch reused across the ISTA loop) dispatches inside `ista_solve`; warm-start/Lipschitz stay dense at construction. Measured (criterion, same run): ht20 2.22 ms → 265 µs (**8.4×**), ht40 10.26 ms → 717 µs (**14.3×**); the real HE40 grid (K=484, G=1452) scales further. 3 new tests: FFT↔dense matvec equivalence to float tolerance (ht20 + he40 grids), end-to-end dominant-tap agreement on a single-path frame, and all default configs keep FFT off. New `cir_estimate_fft` bench group.
|
||||
- **Per-room adapter provenance + drift→recalibration advisor in the streaming engine.** Closes the trust-chain gap where an ~11 KB per-room LoRA adapter (ADR-150 §3.4) could silently change inference without the witness noticing. `StreamingEngine::set_room_adapter(AdapterInfo)` pins the adapter's content-derived id into provenance `model_version` (`rfenc-v1+adapter:<id>`) — and therefore into the BLAKE3 witness — so swapping or clearing adapter weights always shifts the witness (engine test proves base → adapter → other-adapter → cleared all witness differently, and cleared == base). New `RecalibrationAdvisor` recommends re-running the ADR-135 baseline / refitting the adapter on sustained low fusion coherence (streak threshold, default 60 cycles ≈ 3 s at 20 Hz) or an ADR-142 change-point; surfaced as `TrustedOutput::recalibration_recommended` and recorded on the sensing-server's `EngineBridge` alongside the witness. Bridge plumbing: `EngineBridge::{set_room_adapter, clear_room_adapter}` + live-path test that the adapter id flows into the live witness. *Scope note: this is the deployable provenance/trigger half of the "retrained model" roadmap item — fitting the adapter itself runs in the existing external calibration service (`aether-arena/calibration/`), and a trained RF-encoder checkpoint still does not exist in-tree.*
|
||||
- **RuView beyond-SOTA research series** (`docs/research/ruview-beyond-sota/`, 6 docs) — research-swarm output defining the beyond-SOTA bar and the path to it: system capability audit (role→crate maturity matrix, gap analysis, risk register), web-verified 2026 SOTA landscape per capability axis (incl. ratified IEEE 802.11bf-2025), 8-pillar target architecture on the ADR-136 contract spine (no rewrite), 6-layer benchmark/validation methodology (all 15 criterion bench targets inventoried; ADR-149 statistical protocol), and a determinism-safe optimization roadmap. Includes session validation evidence: 2,797 workspace tests / 0 failed, Python proof PASS (bit-exact), paired pre/post criterion runs.
|
||||
- **RuView beyond-SOTA research series** (`docs/research/ruview-beyond-sota/`, 6 docs) — research-swarm output defining the beyond-SOTA bar and the path to it: system capability audit (role→crate maturity matrix, gap analysis, risk register), web-verified 2026 SOTA landscape per capability axis (incl. ratified IEEE 802.11bf-2025), 8-pillar target architecture on the ADR-136 contract spine (no rewrite), 6-layer benchmark/validation methodology (all 15 criterion bench targets inventoried; ADR-171 statistical protocol), and a determinism-safe optimization roadmap. Includes session validation evidence: 2,797 workspace tests / 0 failed, Python proof PASS (bit-exact), paired pre/post criterion runs.
|
||||
|
||||
### Performance
|
||||
- **CIR estimator warm-start precompute** — the diagonal Tikhonov preconditioner `diag(Φ^H Φ)+λI` and its CSR matrix were rebuilt every frame although they depend only on Φ and λ (fixed at `CirEstimator::new`); now precomputed at construction (`ruvsense/cir.rs`). Bit-identical floats (summation order unchanged, witness chain unaffected). Measured: `cir_estimate/he40` −3.9% (p<0.01), multiband groups −1.2/−1.4%; smaller configs within container noise.
|
||||
@@ -75,7 +89,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- `ruview-swarm` benchmarks (criterion, release): MARL actor inference 3.3 µs, RRT-APF planning 0.043 ms, multi-view CSI fusion 58.5 ns, 3-view localization 1.732 m (beats Wi2SAR 5 m SOTA baseline), 4-drone SAR coverage 223 s for 400×400 m (under 240 s target).
|
||||
|
||||
### Added
|
||||
- **ADR-147 — OccWorld world model integration** (`wifi-densepose-worldmodel` v0.3.0 published to crates.io). 15-frame trajectory prediction at 209 ms / 3.37 GB VRAM on RTX 5080. Phase 3 domain adapter `scripts/ruview_occ_dataset.py` (`RuViewOccDataset`) converts WorldGraph snapshots to OccWorld tensors with indoor class remapping + zero ego-poses (validated). Phase 5 retraining pipeline `scripts/occworld_retrain.py` — VQVAE + transformer fine-tuning on RuView occupancy snapshots. See [ADR-147](docs/adr/ADR-147-nvidia-cosmos-world-foundation-model-integration.md) · [benchmark proof](docs/adr/ADR-147-benchmark-proof.md).
|
||||
- **ADR-147 — OccWorld world model integration** (`wifi-densepose-worldmodel` v0.3.0 published to crates.io). 15-frame trajectory prediction at 209 ms / 3.37 GB VRAM on RTX 5080. Phase 3 domain adapter `scripts/ruview_occ_dataset.py` (`RuViewOccDataset`) converts WorldGraph snapshots to OccWorld tensors with indoor class remapping + zero ego-poses (validated). Phase 5 retraining pipeline `scripts/occworld_retrain.py` — VQVAE + transformer fine-tuning on RuView occupancy snapshots. See [ADR-147](docs/adr/ADR-147-nvidia-cosmos-world-foundation-model-integration.md) · [benchmark proof](docs/adr/ADR-168-benchmark-proof.md).
|
||||
|
||||
### Added
|
||||
- **ADR-125 (APPLE-FABRIC) — RuView ↔ Apple Home native HAP bridge proposal + reference impl** (issue #796). New ADR-125 lays out a three-phase plan to expose RuView as a discoverable HomeKit accessory on the LAN so a HomePod (as Home Hub) sees presence / vitals / BFLD-derived events natively — zero Home-Assistant intermediary. Two architectural decisions resolved in the ADR per design review: (1) **one HAP bridge with N child accessories** (single pairing, matches Hue/Eve pattern), and (2) **identity-risk mapping is semantic, not probabilistic** — `identity_risk_score` and Soul-Signature match probability never cross the HAP boundary; instead three thresholded events are exposed (`Unknown Presence`, `Unexpected Occupancy`, `Unrecognized Activity Pattern`) so RuView reads as calm-tech ambient awareness, not surveillance UX. ADR-125 §2.1.a reference impl ships now: `scripts/hap-test-sensor.py` (HAP-1.1 bridge advertised over mDNS, paired with operator's iPhone) + `scripts/c6-presence-watcher.py` (parses ESP32 `RV_FEATURE_STATE_MAGIC = 0xC5110006` UDP packets with IEEE CRC32 validation, hysteresis, and a Python port of `wifi-densepose-bfld::PrivacyClass` that enforces ADR-125 §2.1.d invariant I1 at the HomeKit edge — only `Anonymous` (2) and `Restricted` (3) frames may cross; `Raw`/`Derived` are refused with exit code 2 and the cited ADR clause). Validated end-to-end on real hardware (no mocks): ESP32-C6 on `ruv.net` → UDP/5005 → mac-mini watcher → BFLD gate → HAP bridge → iPhone Home app shows `Unknown Presence` live characteristic flip. **Empirical**: 50-51 valid CRC-passing feature_state packets per 10 s window from the live C6; zero CRC errors. P2 (Rust-native HAP via the `hap` crate, replaces the Python sidecar) and P3 (Matter Controller once `matter-rs` stabilizes) follow.
|
||||
|
||||
@@ -194,7 +194,7 @@ The separate **17-keypoint pose-estimation model** is now published at [`ruvnet/
|
||||
| **Efficiency frontier** | [`docs/benchmarks/wifi-pose-efficiency-frontier.md`](docs/benchmarks/wifi-pose-efficiency-frontier.md) | SOTA-beating WiFi pose in a 20 KB int4 edge model |
|
||||
| **Pretrained encoder** | [`ruvnet/wifi-densepose-pretrained`](https://huggingface.co/ruvnet/wifi-densepose-pretrained) | 82.3% held-out temporal-triplet, 8 KB int4 |
|
||||
| **Reproducible proof (Trust Kill Switch)** | [`archive/v1/data/proof/verify.py`](archive/v1/data/proof/verify.py) + [`expected_features.sha256`](archive/v1/data/proof/expected_features.sha256) | one-command deterministic pipeline replay (SHA-256 of output vs published hash) |
|
||||
| **Benchmark-proof ADR** | [ADR-147](docs/adr/ADR-147-benchmark-proof.md) | how the numbers are produced and verified |
|
||||
| **Benchmark-proof ADR** | [ADR-168](docs/adr/ADR-168-benchmark-proof.md) | how the numbers are produced and verified |
|
||||
| **Witness attestation** | [`docs/WITNESS-LOG-028.md`](docs/WITNESS-LOG-028.md) | 33-row capability attestation matrix with per-claim evidence |
|
||||
|
||||
```bash
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
# Edge-Skill Synthetic-Ground-Truth Validation — RESULTS
|
||||
|
||||
**Crate:** `v2/crates/wifi-densepose-wasm-edge` (workspace-EXCLUDED — build from its own dir)
|
||||
**Branch:** `feat/edge-skills-synthetic-validation`
|
||||
**ADR:** [ADR-160](../../docs/adr/ADR-160-edge-skill-library-honest-labeling.md)
|
||||
**Date:** 2026-06-13
|
||||
**Harness:** `tests/synthetic_validation.rs`
|
||||
|
||||
> **HONESTY BOUNDARY — read first.** Everything below is **synthetic-ground-truth
|
||||
> validation**: a signal is *planted* with a known answer, the **real** detector
|
||||
> is run, and detection accuracy / precision / recall / rate-error is **measured**.
|
||||
> This is **NOT field accuracy.** A skill that recovers a planted sinusoid here is
|
||||
> proven to do the math it claims on a *constructed* signal; it is **NOT** proven
|
||||
> to work on real CSI in a real room. Skills whose detection target cannot be
|
||||
> honestly planted (clinical, weapon, affect, sleep-stage, sign-language) are
|
||||
> **NOT** given a number — they are listed under **DATA-GATED** with the real
|
||||
> data each would require.
|
||||
|
||||
## Reproduce
|
||||
|
||||
```bash
|
||||
cd v2/crates/wifi-densepose-wasm-edge # workspace-excluded; build here
|
||||
cargo test --features std --test synthetic_validation -- --nocapture
|
||||
# also runs under the medical tier (med_* skills stay DATA-GATED, not validated):
|
||||
cargo test --features std,medical-experimental --test synthetic_validation -- --nocapture
|
||||
```
|
||||
|
||||
Each `MEASURED-on-synthetic | …` line printed by the harness is the source of the
|
||||
table below. Numbers are deterministic (no RNG; pseudo-noise uses a fixed LCG seed).
|
||||
|
||||
---
|
||||
|
||||
## MEASURED-on-synthetic (constructible skills)
|
||||
|
||||
| Skill | What was planted (ground truth) | Result | Grade |
|
||||
|-------|----------------------------------|--------|-------|
|
||||
| **vital_trend** | BPM held N≥6 calls at each threshold band (brady/tachy-pnea <12 / >25, brady/tachy-cardia <50 / >120, apnea breathing<1.0 for ≥20) vs normal | **acc 1.000, prec 1.000, recall 1.000** (TP5 FP0 TN5 FN0) | MEASURED |
|
||||
| **exo_time_crystal** | period-2 coordinated motion vs pseudo-noise + flat | **acc 1.000** (TP1 FP0 TN2 FN0) | MEASURED † |
|
||||
| **exo_ghost_hunter** (hidden breathing) | phase sinusoid at lag-8 (breathing band 5–15) in an empty room vs flat phase | **acc 1.000**; planted score **1.000**, flat **0.000** | MEASURED |
|
||||
| **occupancy** | 220-frame flat-amplitude calibration, then strong per-zone amplitude variance vs flat | **acc 1.000** (TP1 FP0 TN1 FN0) | MEASURED |
|
||||
| **intrusion** | calibrate→arm (330 quiet frames), then per-subcarrier Δphase>1.5 + Δamp≫3σ vs quiet | **acc 1.000** (TP1 FP0 TN1 FN0) | MEASURED |
|
||||
| **exo_rain_detect** | empty room, 60-frame baseline, then broadband variance (8/8 groups, ratio≫2.5) for ≥10 frames vs stable-low | **acc 1.000** (TP1 FP0 TN1 FN0) | MEASURED |
|
||||
| **sig_flash_attention** | sustained high phase+amplitude in each of the 8 subcarrier groups; assert reported attention peak == planted group | **peak-localization 8/8 = 1.000** | MEASURED |
|
||||
| **spt_spiking_tracker** | sparse (2-subcarrier) large phase-delta in each of the 4 zones; assert tracked zone == planted zone | **zone-localization 4/4 = 1.000** | MEASURED ‡ |
|
||||
| **sig_optimal_transport** | sustained large frame-to-frame amplitude-distribution change vs stationary | **acc 1.000** (TP1 FP0 TN1 FN0) | MEASURED |
|
||||
| **sig_mincut_person_match** | 2 persons with distinct stable per-region variance signatures over 40 frames | **person ids assigned, 0 id-swaps / 40 frames** | MEASURED |
|
||||
| **lrn_dtw_gesture_learn** | stillness → 3 identical gesture rehearsals → enrollment | **template enrolled (templates=1)** | MEASURED (enroll) §|
|
||||
| **sig_sparse_recovery** | 30 clean frames to init, then 8/32 (25%) nulled subcarriers | **dropout-detect + recovery-trigger = PASS** | MEASURED (trigger) ¶|
|
||||
|
||||
### Caveats on individual results
|
||||
|
||||
† **exo_time_crystal — honest discriminative limit.** A *pure* periodic signal
|
||||
already has autocorrelation peaks at lag L **and** 2L (natural harmonics), so this
|
||||
"period-doubling" detector cannot separate a true period-2 sub-harmonic from a
|
||||
plain periodic signal — an earlier plant using a clean sine produced a *false
|
||||
positive* (recorded during development). The construct it **can** discriminate
|
||||
with known ground truth is **periodic-coordination vs aperiodic** (noise/flat),
|
||||
which is what is measured (1.000). The original "sub-harmonic vs clean period"
|
||||
claim is **NOT** validatable with this algorithm.
|
||||
|
||||
‡ **spt_spiking_tracker — plant must be sparse.** With weights init'd home=1.0 /
|
||||
cross=0.25, firing all 8 inputs in a zone (8×0.25=2.0 > threshold 1.0) overdrives
|
||||
*every* output neuron and the tracker collapses to zone 0 (measured 1/4 during
|
||||
development). Firing only 2 inputs (home 2.0 fires, cross 0.5 silent) yields clean
|
||||
4/4 zone localization. The validatable claim is *single-zone* localization.
|
||||
|
||||
§ **lrn_dtw_gesture_learn — enrollment validated; replay-match NOT.** The
|
||||
deterministic, constructible part (stillness → 3 identical rehearsals → a template
|
||||
is enrolled) is MEASURED. The DTW *replay match* (731) did **not** fire on the
|
||||
identical replay in this run (`match_same=false`) — replay-recognition accuracy is
|
||||
**reported, not asserted**, and is not claimed as validated.
|
||||
|
||||
¶ **sig_sparse_recovery — trigger validated; recovery accuracy is NEGATIVE.**
|
||||
The dropout-detection + ISTA-recovery *trigger* pipeline fires correctly on >10%
|
||||
planted nulls (asserted). But the **measured recovery accuracy is NOT a win**:
|
||||
recovered RMSE **1.0045** vs unrecovered-null RMSE **0.9830** (**−2.2%**, i.e.
|
||||
slightly *worse* than leaving the nulls at zero) on a neighbor-correlated signal.
|
||||
The tridiagonal correlation model's fixed point does not equal the planted truth.
|
||||
**The recovery's reconstruction quality is therefore NOT validated as effective on
|
||||
synthetic data** — only its detection/trigger path is. Reported honestly; no
|
||||
positive number claimed.
|
||||
|
||||
---
|
||||
|
||||
## DATA-GATED — NOT validatable on synthetic data
|
||||
|
||||
Planting a "seizure-like" / "weapon-like" / "happy-like" synthetic signal and
|
||||
claiming the detector "works" validates **nothing real** and is exactly the
|
||||
AI-slop this project fights. These skills run real DSP (per ADR-160, 0 stubs) and
|
||||
keep their ADR-160 disclaimers, but get **no accuracy number** here. Each needs
|
||||
the specific real, labelled data listed:
|
||||
|
||||
| Skill | Why not constructible on synthetic | Real data required |
|
||||
|-------|------------------------------------|--------------------|
|
||||
| `med_seizure_detect` | "seizure-like" motion is not a seizure; no ground-truth signature exists synthetically | Clinical EEG-/video-labelled tonic-clonic seizure CSI from instrumented patients |
|
||||
| `med_sleep_apnea` | a planted breathing-pause is not clinical apnea (AHI scoring, hypopnea, desaturation) | Polysomnography-labelled (PSG) overnight CSI with scored apnea/hypopnea events |
|
||||
| `med_cardiac_arrhythmia` | a synthetic HR sequence cannot encode true arrhythmia morphology | ECG-labelled CSI (AFib/PVC/etc.) from clinical monitoring |
|
||||
| `med_respiratory_distress` | distress is a clinical gestalt, not a plantable rate | Clinician-labelled respiratory-distress CSI episodes |
|
||||
| `med_gait_analysis` | clinical gait metrics need a reference motion-capture standard | Mocap-/force-plate-labelled gait CSI |
|
||||
| `sec_weapon_detect` | a high variance ratio is RF reflectivity, **not** weapon discrimination (ADR-160 §A3 already renamed the event to `HIGH_METAL_REFLECTIVITY`) | Labelled metal-object-vs-no-object CSI with controlled object classes |
|
||||
| `exo_emotion_detect` | affect is not recoverable from a planted heuristic; outputs are proxies (ADR-160 §A2) | Validated affect-labelled CSI (self-report / physiological ground truth) |
|
||||
| `exo_happiness_score` | "happiness" is a gait-energy proxy, not a measured affect (ADR-160 §A2) | Validated affect/valence-labelled CSI |
|
||||
| `exo_dream_stage` | sleep staging needs PSG reference (EEG/EOG/EMG) | PSG-staged overnight CSI |
|
||||
| `exo_gesture_language` | coarse gesture clusters ≠ true sign language (ADR-160 §A4) | Labelled ASL letter/word CSI dataset |
|
||||
|
||||
> The above are **not failures** — they are the honest boundary. A smaller set of
|
||||
> genuinely-measured skills plus this explicit gated list is the deliverable, per
|
||||
> the prove-everything directive.
|
||||
|
||||
---
|
||||
|
||||
## Skills not in either list
|
||||
|
||||
The remaining edge skills (smart-building / retail / industrial occupancy-style,
|
||||
the other `sig_*`/`lrn_*`/`spt_*`/`tmp_*`/`qnt_*`/`aut_*`/`ais_*` algorithm-named
|
||||
modules) are **wired and exercised live** in the unified pipeline integration test
|
||||
(`tests/pipeline_all.rs`, all 59 default / 64 medical skills run without panic over
|
||||
300 synthetic frames) but were **not** given an individual planted-ground-truth
|
||||
accuracy number here. They are honest REAL-DSP modules (ADR-160) whose physical
|
||||
observable could be planted with more harness work; that is deferred, not claimed.
|
||||
|
||||
## Test counts (full crate suite)
|
||||
|
||||
```
|
||||
DEFAULT (--features std): 631 passed, 0 failed
|
||||
(lib 504; budget 25; honest_labeling 10; pipeline_all 4; synthetic_validation 12; bench 1; vendor 75)
|
||||
MEDICAL (--features std,medical-experimental): 669 passed, 0 failed
|
||||
(lib 542; +16 same new tests; med_* stay DATA-GATED, not validated)
|
||||
```
|
||||
|
||||
(M6 baseline was 615 / 653; the new pipeline_all (4) + synthetic_validation (12)
|
||||
tests add 16 to each tier.)
|
||||
@@ -5,7 +5,7 @@
|
||||
| Status | Proposed |
|
||||
| Date | 2026-03-06 |
|
||||
| Deciders | ruv |
|
||||
| Depends on | ADR-012 (ESP32 CSI Mesh), ADR-039 (Edge Intelligence), ADR-040 (WASM Programmable Sensing), ADR-044 (Provisioning Enhancements), ADR-050 (Security Hardening), ADR-051 (Server Decomposition) |
|
||||
| Depends on | ADR-012 (ESP32 CSI Mesh), ADR-039 (Edge Intelligence), ADR-040 (WASM Programmable Sensing), ADR-044 (Provisioning Enhancements), ADR-166 (Security Hardening, renumbered from ADR-050), ADR-051 (Server Decomposition) |
|
||||
| Issue | [#177](https://github.com/ruvnet/RuView/issues/177) |
|
||||
|
||||
## Context
|
||||
@@ -211,7 +211,7 @@ pub struct FlashProgress {
|
||||
// commands/ota.rs
|
||||
|
||||
/// Push firmware to a node via HTTP OTA (port 8032).
|
||||
/// Includes PSK authentication per ADR-050.
|
||||
/// Includes PSK authentication per ADR-166.
|
||||
#[tauri::command]
|
||||
async fn ota_update(
|
||||
node_ip: String,
|
||||
@@ -801,7 +801,7 @@ Total estimated effort: ~11 weeks for a single developer.
|
||||
- ADR-039: ESP32 Edge Intelligence
|
||||
- ADR-040: WASM Programmable Sensing
|
||||
- ADR-044: Provisioning Tool Enhancements
|
||||
- ADR-050: Quality Engineering — Security Hardening
|
||||
- ADR-166: Quality Engineering — Security Hardening (renumbered from ADR-050)
|
||||
- ADR-051: Sensing Server Decomposition
|
||||
- `firmware/esp32-csi-node/` — ESP32 firmware source
|
||||
- `firmware/esp32-csi-node/provision.py` — Current provisioning script
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# ADR-080: QE Analysis Remediation Plan
|
||||
|
||||
- **Status:** Proposed
|
||||
- **Status:** Proposed — P0 security findings #1–#3 **RESOLVED** on the shipped Rust sensing-server boundary (2026-06-13; closes ADR-164 G11)
|
||||
- **Date:** 2026-04-06
|
||||
- **Source:** [QE Analysis Gist (2026-04-05)](https://gist.github.com/proffesor-for-testing/a6b84d7a4e26b7bbef0cf12f932925b7)
|
||||
- **Full Reports:** [proffesor-for-testing/RuView `qe-reports` branch](https://github.com/proffesor-for-testing/RuView/tree/qe-reports/docs/qe-reports)
|
||||
@@ -13,25 +13,38 @@ An 8-agent QE swarm analyzed ~305K lines across Rust, Python, C firmware, and Ty
|
||||
|
||||
Address the 15 prioritized issues from the QE analysis in three waves: P0 (immediate), P1 (this sprint), P2 (this quarter).
|
||||
|
||||
## Security P0 closure note (2026-06-13) — Rust sensing-server boundary
|
||||
|
||||
The three P0 security findings below were logged against the **Python v1** API
|
||||
(`archive/v1/src/…`). ADR-164 G11 re-scoped them to the *shipped* boundary:
|
||||
`wifi-densepose-sensing-server` (Rust). They were verified against the current
|
||||
Rust crate and closed on branch `fix/adr-080-sensing-server-security`. Each fix
|
||||
(or already-fixed finding) is pinned by a test that fails on the old behavior.
|
||||
**The Python v1 paths remain as-is** — v1 is archived and not the shipped
|
||||
surface; this closure governs the live Rust server only.
|
||||
|
||||
## P0 — Fix Immediately
|
||||
|
||||
### 1. Rate Limiter Bypass (Security HIGH)
|
||||
### 1. Rate Limiter Bypass / XFF spoofing (Security HIGH) — **RESOLVED (verified absent on Rust boundary)**
|
||||
|
||||
- **Location:** `archive/v1/src/middleware/rate_limit.py:200-206`
|
||||
- **Original location (v1):** `archive/v1/src/middleware/rate_limit.py:200-206`
|
||||
- **Problem:** Trusts `X-Forwarded-For` without validation. Any client bypasses rate limits via header spoofing.
|
||||
- **Fix:** Validate forwarded headers against trusted proxy list, or use connection IP directly.
|
||||
- **Rust verification (2026-06-13):** The Rust sensing-server has **no XFF-trusting control to bypass** — there is no IP-based rate-limiter and no IP-allowlist, and neither security middleware reads a forwarded header. `bearer_auth.rs` authenticates on the token alone (`require_bearer` inspects only the `AUTHORIZATION` header); `host_validation.rs` decides on the `Host` header only. A repo-wide grep for `x-forwarded-for|forwarded|peer_addr|client_ip|real-ip` over `wifi-densepose-sensing-server` returns nothing. The only "rate limiter" is the MQTT *sample-rate* gate (`mqtt/state.rs`), a per-entity publish throttle with no IP/header input.
|
||||
- **Resolution:** No code change needed (no vulnerable surface). Regression tests pin the immunity: `bearer_auth::tests::xff_header_never_affects_auth_decision` (spoofed XFF never flips a 401↔200 decision) and `host_validation::tests::forwarded_headers_never_bypass_host_allowlist` (spoofed `X-Forwarded-Host: localhost` never lets a foreign `Host: evil.com` past the allowlist). Residual: if an IP-based control is ever added, it must derive the peer from the socket (`ConnectInfo<SocketAddr>`) and only honor XFF from an explicit `--trusted-proxy` CIDR — captured as guidance in the test docstrings.
|
||||
|
||||
### 2. Exception Details Leaked in Responses (Security HIGH)
|
||||
### 2. Exception Details Leaked in Responses (Security HIGH, CWE-209) — **RESOLVED**
|
||||
|
||||
- **Location:** `archive/v1/src/api/routers/pose.py:140`, `stream.py:297`, +5 endpoints
|
||||
- **Problem:** Stack traces visible regardless of environment.
|
||||
- **Fix:** Wrap with generic error responses in production; log details server-side only.
|
||||
- **Original location (v1):** `archive/v1/src/api/routers/pose.py:140`, `stream.py:297`, +5 endpoints
|
||||
- **Problem:** Internal error/stack-trace detail serialized into client responses.
|
||||
- **Rust finding (2026-06-13):** Six handlers in `wifi-densepose-sensing-server/src/main.rs` serialized the internal error `Display` into the JSON body: `edge_registry_endpoint` returned a panicked `spawn_blocking` `JoinError` (`"task … panicked"`) in a `500` and the raw upstream error in a `503`; `delete_model`/`delete_recording`/`start_recording` returned `std::io::Error` strings (OS detail / path); `calibration_start`/`calibration_stop` returned the `FieldModel` error chain.
|
||||
- **Fix:** New `src/error_response.rs` module — `internal_error` / `internal_error_json` / `upstream_unavailable` log the full detail **server-side only** (tagged with a correlation id) and return a generic body (`{"error":"internal_error","correlation_id":…}`) with no `panicked`, no file paths, no Debug chain. All six call-sites rewired. Pinned by `error_response::tests::internal_error_body_does_not_leak_detail` (leak-substring guard, verified to fail on the reverted old body) + 4 sibling tests.
|
||||
|
||||
### 3. WebSocket JWT in URL (Security HIGH, CWE-598)
|
||||
### 3. WebSocket JWT in URL (Security HIGH, CWE-598) — **RESOLVED (verified absent on Rust boundary)**
|
||||
|
||||
- **Location:** `archive/v1/src/api/routers/stream.py:74`, `archive/v1/src/middleware/auth.py:243`
|
||||
- **Original location (v1):** `archive/v1/src/api/routers/stream.py:74`, `archive/v1/src/middleware/auth.py:243`
|
||||
- **Problem:** Tokens in query strings visible in logs/proxies/browser history.
|
||||
- **Fix:** Use WebSocket subprotocol or first-message auth pattern.
|
||||
- **Rust verification (2026-06-13):** The Rust sensing-server never reads a token from the URL. `require_bearer` (`bearer_auth.rs`) inspects only the `Authorization` header; the WebSocket handlers (`ws_sensing_handler`/`ws_introspection_handler`/`ws_pose_handler`) take a bare `WebSocketUpgrade` with no `Query` extractor; the single `Query` in the crate (`EdgeRegistryParams`) is a non-secret `refresh` flag.
|
||||
- **Resolution:** No code change needed (no query-token path exists). Regression test `bearer_auth::tests::query_string_token_is_never_accepted` proves `?token=`/`?access_token=` in the URL never authenticates (stays `401`) while the same token in the header succeeds (`200`) — verified to fail if a query-token path is re-introduced.
|
||||
|
||||
### 4. Rust Tests Not in CI
|
||||
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
# ADR-132: HOMECORE-RECORDER — State History + Semantic Search
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Accepted |
|
||||
| **Date** | 2026-05-25 |
|
||||
| **Deciders** | ruv |
|
||||
| **Codename** | **HOMECORE-RECORDER** |
|
||||
| **Crate** | `v2/crates/homecore-recorder` |
|
||||
| **Relates to** | [ADR-126](ADR-126-ruview-native-ha-port-master.md) (HOMECORE master — series map row ADR-132), [ADR-127](ADR-127-homecore-state-machine-rust.md) (HOMECORE-CORE state machine), [ADR-124](ADR-124-rvagent-mcp-ruvector-npm-integration.md) (ruvector/SENSE-BRIDGE), [ADR-130](ADR-130-homecore-rest-websocket-api.md) (HOMECORE-API query surface, downstream) |
|
||||
| **Tracking issue** | [#800](https://github.com/ruvnet/RuView/pull/800) (HOMECORE intake) |
|
||||
|
||||
> **Documented retroactively (2026-06-12).** The `homecore-recorder` crate shipped under
|
||||
> the ADR-126 series map (which planned an "ADR-132 HOMECORE-RECORDER") but the standalone
|
||||
> ADR file was never written; the crate's `Cargo.toml`, `README.md`, `lib.rs`, `schema.rs`,
|
||||
> and `semantic.rs` all cite "ADR-132". This ADR reverse-documents the decision that the
|
||||
> shipped, tested code already embodies (ADR-164 Gap G3 / Coverage-Gaps Lens §A). It does
|
||||
> **not** introduce new design; it records what is built. Date reflects the crate's intake
|
||||
> era (first commit `e96ebaea8`, 2026-05-25); real-impl pass landed in `7c8071145`
|
||||
> (2026-06-11).
|
||||
|
||||
---
|
||||
|
||||
## 1. Context
|
||||
|
||||
ADR-126 (the HOMECORE master) decided to reimplement Home Assistant (HA) natively in Rust.
|
||||
HA persists every state change to a SQLite *recorder* database; downstream features
|
||||
(history graphs, the logbook, long-term statistics, automation conditions that reference
|
||||
past state) all read that store. HOMECORE therefore needs a durable state-history backbone.
|
||||
|
||||
Two forces shape the decision:
|
||||
|
||||
1. **Migration / coexistence.** Users adopting HOMECORE will have an existing HA
|
||||
`recorder` database. Reusing HA's on-disk schema (rather than inventing a new one) lets
|
||||
HOMECORE read an existing HA `home-assistant_v2.db` directly and lets HA-aware tooling
|
||||
read HOMECORE's store. This is the same trust boundary that `homecore-migrate`
|
||||
(ADR-165) handles for `.storage/*.json`.
|
||||
2. **Semantic queries.** HA history is queried with SQL `BETWEEN`/`WHERE` clauses. The
|
||||
HOMECORE platform already carries ruvector (ADR-124) for vector search, so the recorder
|
||||
can additionally embed state changes and answer natural-language queries
|
||||
("which kitchen devices were warm at 3 PM?") via k-NN — a capability HA does not have.
|
||||
|
||||
The recorder is the **durable-state surface**: if it is wrong, history, logbook, and
|
||||
historical-condition automations are all wrong. ADR-164 flagged it as a CRITICAL coverage
|
||||
gap precisely because such a load-bearing crate had no governing ADR.
|
||||
|
||||
## 2. Decision
|
||||
|
||||
Ship `homecore-recorder` as a SQLite state-history recorder with an HA-compatible schema
|
||||
and an optional ruvector-backed semantic index, in three phases. P1 and P2 are built and
|
||||
tested; P3 is planned.
|
||||
|
||||
### 2.1 Storage — SQLite with the HA recorder schema (P1, shipped)
|
||||
|
||||
- Persist via `sqlx` with the SQLite backend only (no Postgres, no TLS feature set).
|
||||
- Mirror HA recorder **schema v48** so the store is bidirectionally readable
|
||||
(`src/schema.rs`):
|
||||
- `state_attributes` — shared attribute JSON blobs, deduped by an FNV-1a 64-bit hash
|
||||
stored as a signed `i64` (matches HA's dedup key);
|
||||
- `states` — one row per state write (`entity_id`, `state`, `attributes_id` FK,
|
||||
`last_changed_ts`/`last_updated_ts` as REAL Unix seconds, `context_id` UUID);
|
||||
- `events` — domain events (`event_type`, `event_data` JSON, `time_fired_ts`);
|
||||
- `recorder_runs` — boot/shutdown bookends for history-gap detection.
|
||||
- All DDL uses `CREATE TABLE IF NOT EXISTS`, so schema application is idempotent and safe
|
||||
on every startup.
|
||||
- Default persistence path `.homecore/home.db` (configurable).
|
||||
|
||||
### 2.2 Capture — listener on the HOMECORE event bus (P1, shipped)
|
||||
|
||||
- `RecorderListener` subscribes to the HOMECORE event bus (ADR-127) and captures
|
||||
`StateChanged` events, writing snapshots through `Recorder` (`src/listener.rs`,
|
||||
`src/db.rs`).
|
||||
- A `DedupEngine` (`src/dedup.rs`) skips redundant writes when the state hash is unchanged,
|
||||
matching HA's stateful-listener behaviour.
|
||||
|
||||
### 2.3 Semantic search — ruvector HNSW (P2, shipped, feature-gated)
|
||||
|
||||
- Behind the `ruvector` Cargo feature, the `Recorder` additionally calls a `SemanticIndex`
|
||||
implementation (`src/semantic.rs`) that embeds state attributes and stores vectors in a
|
||||
`ruvector-core` HNSW index for k-NN search.
|
||||
- P2 embeddings are **hash-based** (sha2) — a deliberate, honest placeholder. They give a
|
||||
working HNSW surface without claiming sentence-level semantic quality.
|
||||
- When the feature is off, `NullSemanticIndex` satisfies the `SemanticIndex` trait bound
|
||||
with no allocation, so the structural recorder ships independently of ruvector.
|
||||
|
||||
### 2.4 Real sentence embeddings (P3, planned — not yet built)
|
||||
|
||||
- Replace the hash embeddings with ruvector-attention sentence embeddings (dim → 384). Not
|
||||
implemented; tracked as a follow-up. The README and `Cargo.toml` label this P3 explicitly.
|
||||
|
||||
### 2.5 Test evidence (as shipped)
|
||||
|
||||
- P1: 14 tests (`cargo test -p homecore-recorder --no-default-features`).
|
||||
- P2: 20 tests (`cargo test -p homecore-recorder --features ruvector`).
|
||||
|
||||
## 3. Consequences
|
||||
|
||||
**Positive.**
|
||||
|
||||
- HA-schema compatibility makes migration (ADR-165) and coexistence cheap: HOMECORE can
|
||||
read an existing HA `recorder.db`, and any SQLite tool can read HOMECORE's history.
|
||||
- The semantic index is **additive** and feature-gated: the durable structural recorder has
|
||||
no hard dependency on ruvector, so the storage backbone ships first.
|
||||
- Standard SQLite means no proprietary export format; history is directly queryable.
|
||||
|
||||
**Negative / honest limits.**
|
||||
|
||||
- P2 semantic search uses **hash embeddings**, not real sentence embeddings — query quality
|
||||
is limited until P3. This is disclosed in the crate docs and here; it must not be cited as
|
||||
semantic-quality-validated.
|
||||
- No per-crate benchmarks exist yet; the latency figures in the README
|
||||
(state-write p50 < 2 ms, semantic search < 10 ms on 1 M records) are design targets /
|
||||
estimates, **needs verification** with a criterion baseline.
|
||||
- Pinning to HA schema v48 couples HOMECORE to a specific HA recorder schema generation;
|
||||
future HA schema bumps require an explicit migration step.
|
||||
|
||||
**Neutral.**
|
||||
|
||||
- This ADR governs the recorder crate only. The query/REST surface over recorder data is
|
||||
HOMECORE-API (ADR-130, P3); automation conditions on historical state are
|
||||
HOMECORE-automation (ADR-129, P3).
|
||||
|
||||
## 4. Links
|
||||
|
||||
- Crate: `v2/crates/homecore-recorder/` — `Cargo.toml`, `README.md`, `src/lib.rs`,
|
||||
`src/db.rs`, `src/schema.rs`, `src/dedup.rs`, `src/listener.rs`, `src/semantic.rs`.
|
||||
- [ADR-126](ADR-126-ruview-native-ha-port-master.md) — HOMECORE master (series map: ADR-132 = HOMECORE-RECORDER).
|
||||
- [ADR-165](ADR-165-homecore-migrate-from-home-assistant.md) — HOMECORE-MIGRATE (reads HA `.storage`; P2 exports a side-by-side recorder DB).
|
||||
- [ADR-164](ADR-164-adr-corpus-gap-analysis.md) — gap analysis that surfaced this missing ADR (Gap G3).
|
||||
- [Home Assistant Recorder integration](https://www.home-assistant.io/integrations/recorder/).
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Proposed |
|
||||
| **Status** | Accepted — partial (built + tested building block; integration glue pending — see §8 Implementation Status, commit `11f89727f`) |
|
||||
| **Date** | 2026-05-28 |
|
||||
| **Deciders** | ruv |
|
||||
| **Codebase target** | `wifi-densepose-core` (`types.rs`: `CsiFrame`/`CsiMetadata`); `wifi-densepose-signal/src/ruvsense/mod.rs` (`RuvSensePipeline`, six-stage flow); `v2/Cargo.toml` (workspace topology) |
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Proposed |
|
||||
| **Status** | Accepted — partial (built + tested building block; integration glue pending — see Implementation Status, commit `4fa3847ac`) |
|
||||
| **Date** | 2026-05-28 |
|
||||
| **Deciders** | ruv |
|
||||
| **Codebase target** | `wifi-densepose-signal` (`ruvsense/multistatic.rs` — `fuse`, `attention_weighted_fusion`); `wifi-densepose-ruvector` (`viewpoint/fusion.rs` — `MultistaticArray`); `wifi-densepose-bfld` (`event.rs`) |
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Proposed |
|
||||
| **Status** | Accepted — partial (built + tested building block; integration glue pending — see Implementation Status, commit `fc7674bde`) |
|
||||
| **Date** | 2026-05-28 |
|
||||
| **Deciders** | ruv |
|
||||
| **Codebase target** | `wifi-densepose-signal` (`ruvsense/multiband.rs`, `ruvsense/multistatic.rs`); `wifi-densepose-ruvector` (`viewpoint/geometry.rs`, `viewpoint/coherence.rs`, `viewpoint/attention.rs`, `viewpoint/fusion.rs`) |
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Proposed |
|
||||
| **Status** | Accepted — partial (built + tested building block; integration glue pending — see Implementation Status, commit `521a012d8`) |
|
||||
| **Date** | 2026-05-28 |
|
||||
| **Deciders** | ruv |
|
||||
| **Codebase target** | New module/crate `wifi-densepose-worldgraph` alongside `v2/crates/wifi-densepose-geo` and `v2/crates/homecore`; petgraph bridge pattern from `v2/crates/ruv-neural/ruv-neural-graph/src/petgraph_bridge.rs`; integrates `homecore/src/registry.rs` `area_id` and `wifi-densepose-mat/src/domain/scan_zone.rs` |
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Proposed |
|
||||
| **Status** | Accepted — partial (built + tested building block; integration glue pending — see Implementation Status, commit `169a355bd`) |
|
||||
| **Date** | 2026-05-28 |
|
||||
| **Deciders** | ruv |
|
||||
| **Codebase target** | `wifi-densepose-sensing-server/src/semantic/` (`bus.rs`, `common.rs`); `homecore/src/state.rs` + `event.rs`; `homecore-assist` |
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Proposed |
|
||||
| **Status** | Accepted — partial (built + tested building block; integration glue pending — see Implementation Status, commit `7d88eb84c`) |
|
||||
| **Date** | 2026-05-28 |
|
||||
| **Deciders** | ruv |
|
||||
| **Codebase target** | `wifi-densepose-bfld` (new module `mode.rs` + `attestation.rs`; extends `lib.rs` `PrivacyClass`, `sink.rs`, `privacy_gate.rs`, `identity_risk.rs`, `emitter.rs`, `ha_discovery.rs`) |
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Proposed |
|
||||
| **Status** | Accepted — partial (built + tested building block; integration glue pending — see Implementation Status, commit `1f8e180d6`) |
|
||||
| **Date** | 2026-05-28 |
|
||||
| **Deciders** | ruv |
|
||||
| **Codebase target** | `wifi-densepose-signal` (`ruvsense/longitudinal.rs`, `ruvsense/attractor_drift.rs`, `ruvsense/calibration.rs`, `ruvsense/field_model.rs`, `ruvsense/tomography.rs`); `wifi-densepose-bfld` (`privacy_gate.rs`) |
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Proposed |
|
||||
| **Status** | Accepted — partial (built + tested building block, v1 fixed-map default; v2 dataset-gated — see Implementation Status, commit `2d4f3dea5`) |
|
||||
| **Date** | 2026-05-28 |
|
||||
| **Deciders** | ruv |
|
||||
| **Codebase target** | `wifi-densepose-signal` (`ruvsense/field_model.rs`, new `ruvsense/rf_slam.rs`); `wifi-densepose-mat` (`tracking/kalman.rs`, `localization/triangulation.rs`); `wifi-densepose-geo`; `wifi-densepose-ruvector` (`mat/triangulation.rs`) |
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Proposed |
|
||||
| **Status** | Accepted — partial (built + tested building block; no UWB radio in fleet — see Implementation Status, commit `b10bc2e9a`) |
|
||||
| **Date** | 2026-05-28 |
|
||||
| **Deciders** | ruv |
|
||||
| **Codebase target** | `wifi-densepose-hardware` (new UWB driver/parser/auto-detect in `src/`); `wifi-densepose-signal` (`ruvsense/pose_tracker.rs` constraint-aware Kalman update); `wifi-densepose-mat` (`localization/fusion.rs` constraint integration) |
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Proposed |
|
||||
| **Status** | Accepted — partial (built + tested building block; integration glue pending — see Implementation Status, commit `0f336b7d3`) |
|
||||
| **Date** | 2026-05-28 |
|
||||
| **Deciders** | ruv |
|
||||
| **Codebase target** | `wifi-densepose-train` (`src/eval.rs`, `src/metrics.rs`, `src/ruview_metrics.rs`, `src/proof.rs`); `wifi-densepose-signal` (`src/bin/*_proof_runner.rs`); `wifi-densepose-cli` |
|
||||
|
||||
@@ -9,8 +9,10 @@
|
||||
| Relates to | ADR-134, ADR-136, ADR-139, ADR-140, ADR-143, ADR-144, ADR-146, ADR-147 |
|
||||
|
||||
> **Scope note:** ADR-147 deferred Cosmos WFM to "ADR-148" as an offline data generator.
|
||||
> That item is promoted to ADR-149. This ADR takes 148 to address the broader drone swarm
|
||||
> control architecture, which is the first consumer of ADR-147's OccWorld occupancy output.
|
||||
> That item is promoted to ADR-171 (the swarm-benchmarking/evaluation companion to this ADR;
|
||||
> renumbered from ADR-149 to resolve the ADR-149 duplicate-number collision). This ADR takes
|
||||
> 148 to address the broader drone swarm control architecture, which is the first consumer of
|
||||
> ADR-147's OccWorld occupancy output.
|
||||
|
||||
---
|
||||
|
||||
@@ -874,9 +876,9 @@ validated; ITAR/EAR classification completed by export counsel.
|
||||
| GPS spoofing of full swarm simultaneously | Medium | Low | UWB mesh cross-check among all nodes; ≥ 3 nodes must agree on position to confirm |
|
||||
| 1000-UAV scale claims (not validated) | Low | High | SWARM+ demonstrated in simulation only; scale claims capped at 50 for production targets |
|
||||
|
||||
### 12.3 Open Issues (Forward to ADR-149)
|
||||
### 12.3 Open Issues (Forward to ADR-171)
|
||||
|
||||
- Cosmos WFM offline training data generation (deferred from ADR-147) — ADR-149
|
||||
- Cosmos WFM offline training data generation (deferred from ADR-147) — ADR-171
|
||||
- Fixed-wing hybrid platform support (endurance missions) — future ADR
|
||||
- Underwater-aerial cross-domain handoff protocol — future ADR
|
||||
- Quantum-enhanced task assignment (E6) — future ADR when hardware matures
|
||||
@@ -998,4 +1000,4 @@ Implementation tracked at: https://github.com/ruvnet/RuView/issues/861
|
||||
|
||||
*ADR authored with research support from `ruflo-goals:deep-researcher` (2026-05-30).
|
||||
Implementation progress tracked by `ruflo-goals:horizon-tracker`.
|
||||
OccWorld integration basis: ADR-147. Next: ADR-149 (Cosmos WFM offline data generation).*
|
||||
OccWorld integration basis: ADR-147. Next: ADR-171 (Cosmos WFM offline data generation; renumbered from ADR-149).*
|
||||
|
||||
@@ -195,13 +195,15 @@ The §2–§5 fixes are **ACCEPTED and committed**: dead CIR gate fixed, NaN byp
|
||||
- Evaluate the **diffusion CIR prior** (public weights, MEASURED) as an offline quality ceiling — *not* an edge target.
|
||||
- Bayesian multi-AP fusion (2512.02462, CLAIMED) — comparison only, pending released code.
|
||||
|
||||
### 7.4 Deferred Milestone-0 review findings (the ~45 not fixed here — explicit backlog)
|
||||
### 7.4 Deferred Milestone-0 review findings (explicit backlog)
|
||||
|
||||
Catalogued so nothing is silently dropped. Priority: **P1** correctness-adjacent, **P2** perf, **P3** clarity/style.
|
||||
|
||||
**Milestone-1 update (2026-06-13):** the **four P1 backlog items** (#1, #9, #10, #13) are now cleared — #1 and #10 **RESOLVED (MEASURED)**, #9 and #13 **RESOLVED-PARTIAL (DATA-GATED:** de-magicked + boundary-tested, operating values unchanged**)**. ~41 P2/P3 items remain deferred. Each fix is pinned by a regression test that fails on the old behaviour (commits `fd32f094a`, `4a9f2bcf4`, `d672fa602`, `5193f6369`); workspace `--no-default-features` green, Python proof unchanged (bit-exact).
|
||||
|
||||
| # | Module | Finding | Pri | Why deferred |
|
||||
|---|--------|---------|-----|--------------|
|
||||
| 1 | cir.rs ~937 | `phase_variance` uses **linear** variance on **wrapped** angles (doc says "variance of phase angles") — spuriously inflates near ±π | P1 | Used as the `> TAU` ghost-tap *guard*; a correct circular variance is bounded [0,1] and would need the threshold re-derived. Semantic change — defer with a real recalibration, don't risk a silent gate regression in a perf/correctness pass. |
|
||||
| 1 | cir.rs ~937 | `phase_variance` uses **linear** variance on **wrapped** angles (doc says "variance of phase angles") — spuriously inflates near ±π | P1 | **RESOLVED (`fd32f094a`) — metric MEASURED, threshold DATA-GATED.** Replaced with Mardia's circular variance V = 1 − R̄ ∈ **[0,1]**, invariant to the cluster's position on the circle (branch-cut artefact gone). Guard re-derived against the bounded metric via named const `GHOST_TAP_CIRCULAR_VARIANCE_MAX = 0.99` (fires only when R̄ ≤ 0.01 — essentially uniform phase). The **threshold value is DATA-GATED**: a clean single-path ramp also sweeps the circle, so V alone can't separate clean from unsanitized without labelled frames — the default is deliberately conservative (strictly more permissive at the wrap boundary than the buggy linear guard). Fails-on-old: `phase_variance_circular_not_fooled_by_branch_cut` (old linear variance > TAU on wrap-straddling phases while circular V≈0, guard no longer trips), `phase_variance_circular_is_bounded_and_extremal`. |
|
||||
| 2 | calibration.rs ~311 | `subtract_in_place` had a vacuous `if active_input {ki} else {ki}` branch implying a full-FFT→bin remap that didn't exist | P3 | **Resolved here** (branch removed, sequential-convention documented to match the sibling `extract_first_stream`). Listed for visibility — behavior unchanged. |
|
||||
| 3 | spectrogram.rs / bvp.rs | FFT planner built once-per-call (already amortized across frames) | P2 | Marginal vs the per-frame PSD site; cache if these become hot. |
|
||||
| 4 | features.rs ~347 | Doppler FFT planner planned once per call, reused across subcarriers | P2 | Already amortized within the call. |
|
||||
@@ -209,11 +211,11 @@ Catalogued so nothing is silently dropped. Priority: **P1** correctness-adjacent
|
||||
| 6 | tomography.rs | ISTA L1 solver re-allocates voxel buffers per solve | P2 | Bench first. |
|
||||
| 7 | pose_tracker.rs | Kalman gain matrices reallocated per update | P2 | Bench first. |
|
||||
| 8 | field_model.rs | SVD recomputed on every perturbation extract | P2 | Incremental SVD is a real project, not a micro-fix. |
|
||||
| 9 | coherence.rs / coherence_gate.rs | Z-score thresholds are magic constants, untested at boundaries | P1 | Needs labelled data to set defensible thresholds. |
|
||||
| 10 | longitudinal.rs | Welford update not numerically guarded for n=0 | P1 | Add `n>=1` guard + test (same family as §4). |
|
||||
| 9 | coherence.rs / coherence_gate.rs | Z-score thresholds are magic constants, untested at boundaries | P1 | **RESOLVED-PARTIAL (`5193f6369`) — DATA-GATED.** De-magicked `classify_drift` (`DRIFT_STABLE_SCORE=0.85`, `DRIFT_STEP_CHANGE_MAX_STALE=10`) and the `coherence_gate.rs` defaults (`DEFAULT_ACCEPT_THRESHOLD`/`…REJECT…`/`…MAX_STALE_FRAMES`/`…PREDICT_ONLY_NOISE`) into named, documented consts marked EMPIRICAL DEFAULT; added at/just-below/just-above boundary tests (`classify_drift_*_boundary`) + `*_consts_unchanged_from_literals`. **Operating values explicitly NOT changed** — defensible values still need labelled stable/drifting traces. The gate already exposed these via `GatePolicyConfig` (config seam). |
|
||||
| 10 | longitudinal.rs | Welford update not numerically guarded for n=0 | P1 | **RESOLVED (`4a9f2bcf4`) — MEASURED.** The shared `WelfordStats` (`field_model.rs`, consumed by longitudinal.rs) `count < 2` guards already prevent the n=0 NaN / n=1 div0 / `(count−1)` underflow, but the boundary was untested. Added `welford_finite_at_n0_and_n1` (finite + documented 0.0 sentinel at n=0/n=1). Fails-on-old proof: removing the `sample_variance` guard makes the test panic with "attempt to subtract with overflow" at the `(count − 1)` underflow. |
|
||||
| 11 | cross_room.rs | Fingerprint hash collisions unhandled | P2 | Low collision prob; needs design. |
|
||||
| 12 | gesture.rs | `euclidean_distance` no length-mismatch guard | P3 | Caller-enforced; add `debug_assert`. |
|
||||
| 13 | adversarial.rs | Gini/consistency thresholds are magic constants | P1 | Same labelled-data dependency as #9. |
|
||||
| 13 | adversarial.rs | Gini/consistency thresholds are magic constants | P1 | **RESOLVED-PARTIAL (`d672fa602`) — DATA-GATED.** Lifted the bare literals in `check`/`check_consistency` (`FIELD_MODEL_GINI_VIOLATION=0.8`, `ENERGY_RATIO_HIGH_VIOLATION=2.0`, `ENERGY_RATIO_LOW_VIOLATION=0.1`, `CONSISTENCY_ACTIVE_FRACTION_OF_MEAN=0.1`, `SCORE_W_*`) into named, documented consts marked EMPIRICAL DEFAULT; added at/just-below/just-above boundary tests (`energy_ratio_high_boundary`, `energy_ratio_low_boundary`, `field_model_gini_boundary`, `consistency_active_fraction_boundary`) + `tuning_consts_unchanged_from_literals`. **Operating values explicitly NOT changed** — defensible values still need labelled spoofed/clean CSI (Wi-Spoof, §6.2/§7.3). Bumping a const fails a boundary test (verified). |
|
||||
| 14 | cir.rs | `fft_operator` path changes the witness hash (documented) — no test that it's *numerically close* to dense | P2 | Add a tolerance test. |
|
||||
| 15 | multistatic.rs | `cir_gate_coherence` only estimates the **first** node/channel; multi-node CIR consensus unused | P2 | Design item (which node's CIR is authoritative?). |
|
||||
| 16 | phase_align.rs | Iterative LO offset estimation has no convergence cap test | P2 | Add iteration-cap test. |
|
||||
@@ -223,12 +225,12 @@ Catalogued so nothing is silently dropped. Priority: **P1** correctness-adjacent
|
||||
| 20 | spectrogram.rs | `compute_multi_subcarrier_spectrogram` re-plans per subcarrier via `compute_spectrogram` | P2 | Hoist the planner (relates to #3). |
|
||||
| 21–45 | (assorted) | Remaining clarity/doc/magic-constant/missing-boundary-test findings across `ruvsense/*`, `features.rs`, `motion.rs` | P3 | Bulk-addressable in a dedicated "test-the-boundaries + de-magic-constant" follow-up; not high-leverage individually. |
|
||||
|
||||
> **Horizon-ledger one-liner.** Milestone-0 DONE: dead CIR gate (FIXED+proved), NaN/inf adversarial bypass (FIXED+proved), divide-by-(n−1) window trio (FIXED+proved), calibration dead-branch (FIXED), PSD FFT-planner cache (MEASURED), DTW band (MEASURED). DEFERRED to follow-up: the ~45 findings in §7.4 (P1: phase_variance circular bug #1, Welford guard #10, threshold magic-constants #9/#13; P2/P3: the rest) — none silently dropped.
|
||||
> **Horizon-ledger one-liner.** Milestone-0 DONE: dead CIR gate (FIXED+proved), NaN/inf adversarial bypass (FIXED+proved), divide-by-(n−1) window trio (FIXED+proved), calibration dead-branch (FIXED), PSD FFT-planner cache (MEASURED), DTW band (MEASURED). **Milestone-1 DONE (2026-06-13): all four P1 backlog items cleared — circular phase variance #1 (RESOLVED/MEASURED metric, DATA-GATED threshold), Welford n=0 guard #10 (RESOLVED/MEASURED), threshold magic-constants #9 & #13 (RESOLVED-PARTIAL/DATA-GATED — de-magicked + boundary-tested, values unchanged).** DEFERRED to follow-up: the ~41 remaining P2/P3 findings in §7.4 — none silently dropped.
|
||||
|
||||
---
|
||||
|
||||
## 8. Consequences
|
||||
|
||||
- **Positive:** the ADR-134 CIR gate is alive for the first time in production; the adversarial detector can no longer be NaN-bypassed; three latent divide-by-zero NaN sources are gone; the per-frame PSD path and gesture DTW are measurably faster with bit-identical output; the SOTA landscape and a concrete LISTA-for-CIR roadmap are graded and recorded.
|
||||
- **Negative / honest limits:** `canonical56()` models the canonical grid as a contiguous 56-tone band — a reasonable physical interpretation of a *resampled* grid, but not a literal hardware tone map; the CIR gate still uses only the first node's CIR (#15); the `phase_variance` circular bug (#1) remains until it can be re-thresholded with data.
|
||||
- **Negative / honest limits:** `canonical56()` models the canonical grid as a contiguous 56-tone band — a reasonable physical interpretation of a *resampled* grid, but not a literal hardware tone map; the CIR gate still uses only the first node's CIR (#15). The `phase_variance` **metric** is now correct (Mardia circular variance, Milestone-1 #1), so the branch-cut false-trip is gone — but its ghost-tap **threshold** (`GHOST_TAP_CIRCULAR_VARIANCE_MAX = 0.99`) is a conservative DATA-GATED default, not a calibrated operating point, and still awaits labelled sanitized/unsanitized frames to tune. Likewise the de-magicked coherence/adversarial thresholds (#9/#13) keep their pre-existing empirical values pending labelled calibration.
|
||||
- **Neutral:** no public API removed; `with_cir_ht20()` kept (warned); files stay scoped; new bench is additive.
|
||||
|
||||
@@ -178,10 +178,33 @@ label or behavior change, consistent with leaving their claim surface intact.)
|
||||
|
||||
## Deferred Backlog (Nothing Dropped)
|
||||
|
||||
- **Per-skill accuracy validation** — **DATA-GATED**. Validating any med_*/affect/
|
||||
sign-language claim requires labelled clinical/affective/ASL data and reference
|
||||
standards that do not exist in this repo. The disclaimers + feature gate are the
|
||||
honest stand-in. Nothing is claimed that is not measured.
|
||||
- **Per-skill accuracy validation** — **PARTIALLY MEASURED-on-synthetic**
|
||||
(2026-06-13). For the subset of skills whose detection target is *constructible*
|
||||
with known ground truth, a synthetic-ground-truth harness
|
||||
(`tests/synthetic_validation.rs`, 12 tests) plants signals with known answers,
|
||||
runs the real detector, and **measures** detection accuracy / rate-error:
|
||||
`vital_trend`, `exo_time_crystal` (periodic-vs-aperiodic — its sub-harmonic-vs-
|
||||
clean-period claim is NOT separable, recorded honestly), `exo_ghost_hunter`
|
||||
(hidden breathing), `occupancy`, `intrusion`, `exo_rain_detect`,
|
||||
`sig_flash_attention` (8/8 peak localization), `spt_spiking_tracker` (4/4 zone
|
||||
localization, sparse plant), `sig_optimal_transport`, `sig_mincut_person_match`
|
||||
(0 id-swaps), `lrn_dtw_gesture_learn` (enrollment) — all 1.000 where claimed;
|
||||
`sig_sparse_recovery`'s recovery accuracy is reported **negative** (−2.2% vs
|
||||
unrecovered baseline) — only its trigger path is validated. Full numbers +
|
||||
reproduce commands in `benchmarks/edge-skills/RESULTS.md`.
|
||||
The **med_*/affect/sign-language/weapon** claims remain **DATA-GATED**:
|
||||
validating them requires labelled clinical/affective/ASL/metal-object data and
|
||||
reference standards that do not exist in this repo. Planting a "seizure-/weapon-/
|
||||
happy-like" synthetic signal validates nothing real and is explicitly refused;
|
||||
RESULTS.md lists each with the real data it needs. The disclaimers + feature gate
|
||||
are the honest stand-in. Nothing is claimed that is not measured.
|
||||
- **Unified edge pipeline** — **MEASURED** (2026-06-13). `src/pipeline_all.rs`
|
||||
(`EdgePipeline`) + `src/skill_registry.rs` register **every** runtime skill
|
||||
behind one uniform `EdgeSkill` trait and run them all per CSI frame; `med_*` are
|
||||
registered only under `--features medical-experimental` (preserves the §A1 gate).
|
||||
`tests/pipeline_all.rs` (4 tests) proves all 59 default / 64 medical skills run
|
||||
without panic over 300 synthetic frames with a well-formed aggregated event
|
||||
stream. `examples/run_all_skills.rs` is a runnable demo. No skill DSP changed.
|
||||
- **Criterion benches for `process_frame` budget claims** — **DONE (host)**
|
||||
(ADR-163, 2026-06-12). `benches/process_frame_bench.rs` benches the heaviest
|
||||
hot paths (`exo_time_crystal` 256×128 autocorrelation, `exo_ghost_hunter`
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
# ADR-164: ADR Corpus Gap Analysis & Remediation Backlog
|
||||
|
||||
- **Status:** proposed
|
||||
- **Date:** 2026-06-12
|
||||
- **Deciders:** ruv
|
||||
- **Tags:** governance, meta
|
||||
|
||||
## Context
|
||||
|
||||
The corpus has grown to **162 ADR entries across 156 distinct files** (ADR-001 through ADR-171; the 5 duplicate-number collisions / 6 displaced files originally noted here were RESOLVED by renumbering the displaced files to ADR-166…171 — see Gap Register G1). It now spans nine subsystems — signal/DSP, NN/training, ESP32 firmware, RuvSense multistatic, RuView desktop, Cognitum cogs, HOMECORE (HA reimplementation), BFLD privacy, and the streaming engine — written over roughly a year by many agent-driven sessions.
|
||||
|
||||
Two forces motivate a corpus-wide gap analysis *now*:
|
||||
|
||||
1. **The beyond-SOTA / anti-AI-slop sweep (ADR-154–163) just landed.** That sweep is itself a structured retraction layer: each ADR exists *because* an earlier accepted-or-shipped claim was found false (a dead CIR coherence gate, a fake-gradient TTA path, a self-certifying proof, a WebSocket auth bypass, an inflated survivor count). The sweep hardened five subsystems but was narrowly scoped — it never touched the two largest capability gaps (camera-teacher training validation; federation/BFLD privacy chains). A ledger is needed to record what the sweep retracted and what it left open.
|
||||
2. **The status field can no longer be trusted as a source of truth.** A five-lens audit (status-distribution, supersession-chains, contradictions, coverage-gaps, data-hardware-gated) found ~24 ADRs mislabeled `Proposed` while their own commit-pinned Implementation-Status notes report them built and tested; 6 ADR numbers collide; 3 files have no Status header at all. An auditor reading headers would conclude "not built" for landed code, and "built/Accepted" for unvalidated capability.
|
||||
|
||||
The detailed lens outputs and the full per-ADR census live in `docs/adr/gap-analysis/` (`lens-findings.md`, `census.md`). This ADR is the authoritative summary and remediation backlog.
|
||||
|
||||
## Decision
|
||||
|
||||
**This ADR is the authoritative gap ledger and remediation backlog for the ADR corpus as of 2026-06-12.** It does not change any subsystem behavior. It records, with cited ADR ids:
|
||||
|
||||
- the status/impl distribution and the bookkeeping-drift problem;
|
||||
- a prioritized Gap Register with a recommended action per gap;
|
||||
- supersession-integrity defects;
|
||||
- the contradiction/retraction list (the anti-slop centerpiece);
|
||||
- shipped capabilities with no governing ADR;
|
||||
- the genuinely open data/hardware-gated backlog.
|
||||
|
||||
Until the Gap Register items are worked, **treat the ADR Status header as advisory, not authoritative**, and treat any accuracy number authored before ADR-155 landed as CLAIMED (not MEASURED) until re-derived through the post-155 leak-free validation split.
|
||||
|
||||
## Status Distribution
|
||||
|
||||
Counts are approximate (`~`) where a status string is non-canonical or dual-valued; the per-ADR breakdown is in `census.md`.
|
||||
|
||||
| Status bucket | Count | impl_state | Count |
|
||||
|---|---|---|---|
|
||||
| Accepted (incl. partial/in-progress/Phase-1 variants) | ~56 | implemented | ~36 |
|
||||
| Proposed (incl. conditional/research-only) | ~88 | partial | ~50 |
|
||||
| Superseded | 1 (ADR-002) | proposed-only | ~64 |
|
||||
| Rejected | 1 (ADR-098) | stale-or-contradicted | 3 (029/030/031) |
|
||||
| Missing / no Status header | 3 (ADR-168-proof [was 147], ADR-167-ddd [was 052], ADR-134) | unknown | 5 (034/044/167-ddd/168-proof/…) |
|
||||
| Mixed/dual status in one ADR | 3 (115, 149×2, 133) | superseded | 1 (ADR-002) |
|
||||
|
||||
**Headline:** ~114 of 162 ADRs (≈70%) are decisions that never fully landed (proposed-only + partial + stale + unknown). The dominant failure mode is **stale Status headers**, not abandoned work.
|
||||
|
||||
## Gap Register
|
||||
|
||||
Severity: CRITICAL (corpus integrity / tooling-breaking / life-safety / security) · HIGH · MEDIUM · LOW. Action vocabulary: *implement · supersede · mark-stale · write-missing-ADR · close-as-gated · renumber · reconcile-docs*.
|
||||
|
||||
| ID | Gap | Severity | Affected ADRs | Recommended action |
|
||||
|----|-----|----------|---------------|--------------------|
|
||||
| G1 | ~~6 duplicate ADR numbers (two ADRs answer to one number; breaks index/`/adr` tooling)~~ **RESOLVED (duplicate-number item)** | CRITICAL | 050×2, 052×2, 147×3, 148×2, 149×2; 134 (identity split, separate) | ~~renumber 2-of-3 at 147, 1 each at 050/148/149; demote 052-ddd to appendix; resolve 134 identity~~ **DONE: displaced files renumbered to the next free numbers (166–171), keepers = first-committed file per number (date ties broken by inbound-ref count / parent-appendix relationship): 050 keeps provisioning-tool-enhancements → quality-engineering-security-hardening = ADR-166; 052 keeps tauri-desktop-frontend → ddd-bounded-contexts appendix = ADR-167 (still linked to parent 052); 147 keeps nvidia-cosmos/OccWorld → benchmark-proof = ADR-168, adam-mode-light-theme = ADR-169; 148 keeps drone-swarm-control-system → yoga-mode-pose-system = ADR-170; 149 keeps public-community-leaderboard-huggingface → swarm-benchmarking-evaluation-methodology = ADR-171. In-file headers, intra-file self-refs, all inbound cross-references (README index, census, lens-findings, user-guide, CHANGELOG, proof-of-capabilities, research docs), and this register updated. `ls docs/adr/ADR-*.md | … | uniq -d` is now EMPTY. The ADR-134 identity split is NOT a filename collision; resolved separately under G3 (→ ADR-165).** |
|
||||
| G2 | 3 files with no Status header (cannot triage) — **INVESTIGATED in `docs/adr-gap-remediation-1`: only 2 genuinely lack one, both owner-gated** | CRITICAL | ADR-168-benchmark-proof (was 147), ADR-167-ddd-appendix (was 052), ~~134-CIR~~ | add canonical `## Status`; relocate ADR-168-proof to `benchmarks/`; label ADR-167-ddd as appendix — **NOTE: ADR-134-CIR DOES have a Status (`\| Status \| Proposed \|` in its header table) — mislabeled here. The two real misses (ADR-168-benchmark-proof [was 147], ADR-167-ddd [was 052]) were inside the owner-gated duplicate-number collisions (147×3, 052×2); those collisions are now resolved (G1) but the missing Status headers themselves remain owner-gated, so left untouched pending owner. The early ADRs (048/049/068/070 etc.) use `\| Status \|` not `\| **Status** \|` — different-format-but-present, not missing. Net: 0 headers added.** |
|
||||
| G3 | ~~Shipped crates cite a non-existent or wrong-identity governing ADR~~ **RESOLVED in `docs/adr-gap-remediation-1`** | CRITICAL | homecore-recorder→"ADR-132" (no file); homecore-migrate→"ADR-134" (file is CIR) | ~~write-missing-ADR (HOMECORE-RECORDER, HOMECORE-MIGRATE)~~ DONE: wrote ADR-132 (recorder, Accepted) + ADR-165 (migrate, Accepted — P1 scaffold); repointed migrate's ADR-134 refs → ADR-165 |
|
||||
| G4 | Anti-slop retractions: accuracy/security/function provably false until sweep landed | CRITICAL | 155, 154, 079, 161 (see Contradictions) | already fixed in-code by 154/155/161/162; this ledger records the retraction |
|
||||
| G5 | ~~10 streaming-engine ADRs marked `Proposed` while §Impl-Status reports Built + commits + tests~~ **RESOLVED in `docs/adr-gap-remediation-1`** | HIGH | 136–145 | ~~mark-stale → "Accepted — partial (integration glue pending)" (one batch)~~ DONE: all 10 (136–145) flipped to "Accepted — partial"; each retains its commit-pinned Implementation-Status note. NB: notes describe *building blocks built + tested*, **not** live-path integration — "partial" is the honest label, not full "Accepted" |
|
||||
| G6 | Stale `Proposed` headers on built+published code | HIGH | 029/030/031, 095/096, 152, 154–157, 024/027/072, 150 | mark-stale; reconcile with downstream/CLAUDE.md evidence |
|
||||
| G7 | Status-graph inversion: Accepted ADR depends on Proposed parent | HIGH | 032→029/030/031; 053→052; 048→045; 077→075/076; 104→103 | promote parents to match built reality, or downgrade dependents |
|
||||
| G8 | ADR-002 supersession not reciprocated by successors; 5 children stranded | HIGH | 002→016/017; children 003/007/008/009/010 | reconcile-docs (add reciprocal language or downgrade); split 002 to "partially superseded" |
|
||||
| G9 | Streaming-engine integrator crate has no governing ADR (composition/back-pressure/live-path seam) | HIGH | wifi-densepose-engine (composes 135–146) | write-missing-ADR |
|
||||
| G10 | CLAUDE.md doc-vs-header drift (doc says one status, header another) | HIGH | 017, 024, 027, 072, 152 | reconcile-docs |
|
||||
| G11 | ~~Open security HIGH findings, gate FAILED, never marked done~~ **RESOLVED (2026-06-13, branch `fix/adr-080-sensing-server-security`)** | HIGH | 080 (XFF bypass, leaked stack traces, JWT-in-URL CWE-598) | ~~implement (sensing-server boundary — NOT covered by HOMECORE sweep 161/162)~~ DONE: verified all three against the *current Rust* `wifi-densepose-sensing-server`. **#2 leaked errors** was the one live exposure — 6 `main.rs` handlers serialized internal `Display`/`JoinError` into response bodies; fixed via a new `error_response` module (generic body + correlation id, detail logged server-side only). **#1 XFF** and **#3 JWT-in-URL** were verified *absent* on the Rust boundary (no IP-rate-limit/allowlist reads XFF; token is header-only, WS handlers take no query token) and pinned with regression tests that fail if either is re-introduced. ADR-080 P0 §1–3 marked RESOLVED. |
|
||||
| G12 | ADR-052→054 edge unacknowledged by successor; likely mis-modeled (impl, not replacement) | MEDIUM | 052-tauri, 054 | reconcile-docs (054 is the impl plan *for* 052, not a replacement) |
|
||||
| G13 | Capability governed only by remediation/deploy ADR, no creation/architecture ADR | MEDIUM | wasm-edge (only 160/163); occworld-candle (147 blessed Python path only); pointcloud (094 = viewer deploy only) | write-missing-ADR (taxonomy/ABI for wasm-edge; Candle backend swap; pointcloud data contract) |
|
||||
| G14 | Conflicting decisions on one topic, none superseding the others | MEDIUM | person-count 037/075/103; PQ-sign 007/109; fed key-exchange 107/108; provisioning 050/060/052; audit 010/028; RVF-WASM 009-vs-shipped | reconcile (pick one, supersede the rest) |
|
||||
| G15 | ~50 Proposed-forever chains pollute every gap analysis | MEDIUM | 003/007–010, 105–109, 118–125, HOMECORE 124–133, 033/046/049/067/074/085 | close-as-gated or mark Deferred/Rejected + open tracking issues |
|
||||
| G16 | De-facto supersessions never recorded (lifecycle graph incomplete) | MEDIUM | 098/099, 063/064, 042/153, 050/060, 035/023, 100/109, 117 retracts PyPI v1.1.0 | reconcile (add supersedes/superseded_by fields) |
|
||||
| G17 | Accepted but no implementation evidence ("unverified done") | MEDIUM | 034 (FieldView app — no crate); 044 (wifi-densepose-geo — bare Accepted, no Date/Deciders) | implement or downgrade to Proposed |
|
||||
| G18 | Workspace has ~38 crates; CLAUDE.md publishing list (12-step) and crate table (15) are stale | MEDIUM | corpus-wide (crate-graph topology) | write-missing-ADR (crate-graph / publish boundaries) + reconcile CLAUDE.md |
|
||||
|
||||
## Supersession Integrity
|
||||
|
||||
Only **3 formal supersession edges** exist; all three are defective (see G8/G12; full detail in `lens-findings.md` Lens 2):
|
||||
|
||||
- **ADR-002 → ADR-016 / ADR-017** is one-directional. ADR-016 never mentions ADR-002 (its References list only 014/015); ADR-017 only *corrects* ADR-002's "fictional crate names" and never says "supersede." The census `supersedes:["ADR-002"]` on 016/017 is **file-unsupported** — the superseded ADR points up at two successors that do not point back.
|
||||
- **ADR-002 is an umbrella** whose children 003/007/008/009/010 are still `Proposed`. ADR-016/017 realize only the training/signal/MAT integration points; the RVF-container (003), PQ-crypto (007), Raft (008), WASM-edge-runtime (009), and witness-chains (010) decisions are **neither implemented nor formally superseded**. Marking the parent fully "Superseded" silently buries 5 live-but-abandoned child decisions. Recommended: split ADR-002 to "partially superseded."
|
||||
- **ADR-052-tauri → ADR-054** is declared by the predecessor but ADR-054 contains zero references to ADR-052. ADR-054 ("Full Implementation", in progress) is the impl plan *for* 052, not a replacement — likely a mis-modeled edge.
|
||||
- **No cycles** detected. The graph is clean structurally; the defect is missing reciprocity and ~7 unrecorded de-facto supersessions (G16).
|
||||
|
||||
## Contradictions & Retractions (anti-slop centerpiece)
|
||||
|
||||
The four CRITICAL items are the corpus's load-bearing AI-slop admissions — each an accepted-or-shipped surface whose stated accuracy/security/function was provably false until the sweep landed. **Every accuracy number predating ADR-155 should be treated as CLAIMED until re-derived through the post-155 leak-free split.** Source-cited evidence is in `lens-findings.md` Lens 3.
|
||||
|
||||
- **[CRITICAL] ADR-155** retracts every prior NN accuracy/TTA/proof claim: real MM-Fi training validated against a *synthetic* val set with stride-1 (~99%) window leakage (§2.2); a *fake gradient* `grad += v*0.01` in the TTA path (§2.3); a *self-certifying* proof that blessed whatever the pipeline emitted and PASSed on 1e-9 float noise (§2.4).
|
||||
- **[CRITICAL] ADR-154** proves the ADR-134 CIR coherence gate was **dead in production for every canonical 56-tone frame** (`SubcarrierMismatch`, 0 Ok / 8 mismatch), silently degrading coherence to freq-only. Any "CIR-enhanced coherence/ToF" claim before this fix overstated reality.
|
||||
- **[CRITICAL] ADR-079** carries three mutually inconsistent values for its own central metric: proxy PCK@20 = 2.5% (prose) vs 35.3% (baseline table — equal to the *target*) vs 0% upper-body joints; #640 measured 0% on real local data. An Accepted ADR whose headline 10–20x improvement is self-refuting.
|
||||
- **[CRITICAL] ADR-161** fixes a HOMECORE WebSocket **auth bypass** (any non-empty token accepted) + reply-theater + no-op automation; **ADR-162** then enforces plugin Ed25519 signature verification, capability isolation, and bounded RunModes — retracting ADR-128/129/130's implied security guarantees.
|
||||
- **[HIGH]** ADR-152 self-refutes 1 of 25 claims (ESP WiFi-6 "drop-in" REFUTED 0-3); CLAUDE.md's "WiFlow-STD MEASURED-EQUIVALENT ~96% PCK" contradicts §F1's own gating (97.25% is CLAIMED until measurements (a)–(c) run). ADR-150 retracts the implied cross-subject capability (81.63% in-domain vs ~11.6% leakage-free cross-subject; DANN ~0 gain). ADR-159 ships real models but discloses person-count `training_class1_accuracy = 0.343` and renames "learned multi-person counter" → "presence detector," gutting ADR-103/104's claim.
|
||||
- **[MEDIUM]** ADR-163 leaves the ESP32/Xtensa on-hardware latency figure UNMEASURED; ADR-098↔099 partial reversal on midstream; ADR-147 self-retracts Cosmos for OccWorld.
|
||||
|
||||
## Coverage Gaps (shipped capability, no/broken governing ADR)
|
||||
|
||||
- ~~**CRITICAL — `homecore-recorder`** (SQLite state history + semantic search) cites "ADR-132", which **does not exist**. The durable-state backbone is ungoverned. → write HOMECORE-RECORDER ADR.~~ **RESOLVED in `docs/adr-gap-remediation-1`:** ADR-132 written (`ADR-132-homecore-recorder-history-semantic-search.md`, Status: Accepted — reverse-documented from the shipped crate).
|
||||
- ~~**CRITICAL — `homecore-migrate`** (reads untrusted Python-HA `.storage/*.json`) cites "ADR-134", but on-disk ADR-134 is CIR. A data-integrity-sensitive importer governed by a phantom identity. → resolve 134 collision + write HOMECORE-MIGRATE ADR (trust boundary).~~ **RESOLVED in `docs/adr-gap-remediation-1`:** ADR-165 written (`ADR-165-homecore-migrate-from-home-assistant.md`, Status: Accepted — P1 scaffold); crate's `ADR-134` refs repointed → ADR-165; on-disk ADR-134 (CIR) left intact. ADR-126's series-map row (which labels the *role* "ADR-134 HOMECORE-MIGRATE") is owner-gated and unchanged.
|
||||
- **HIGH — `wifi-densepose-engine`** composes ADR-135..146 onto the live 20 Hz path but **no ADR governs the integrator contract** (ordering, back-pressure, "one pipeline cycle" boundary).
|
||||
- **MEDIUM — `wasm-edge`** (~70 skills) governed only by remediation ADRs 160/163 — no creation/taxonomy/ABI ADR. **`occworld-candle`** is a Rust-native backend swap ADR-147 explicitly deferred. **`pointcloud`** has only a viewer-deploy ADR (094), no data-format contract.
|
||||
- **MEDIUM — workspace topology:** ~38 crates exist; the CLAUDE.md 15-crate table and 12-step publishing order are stale, and no ADR governs crate-graph/publish boundaries at this scale.
|
||||
- Verified-governed (scoped out): worldmodel→147, worldgraph→139, cog-*→101/103/116, ruview-swarm→148, nvsim→089/092, bfld→118-123/141, calibration→151, homecore-hap→125, geo→044, desktop→052/054.
|
||||
|
||||
## Open / Gated Backlog (genuinely unresolved, honestly labeled)
|
||||
|
||||
The ADR-154–163 sweep was narrowly scoped. The two largest **capability** gaps it did not touch:
|
||||
|
||||
- **CRITICAL — Camera-teacher training validation (ADR-079 / 072 / 150).** P7–P9 Pending; blocker is a real synchronized camera+ESP32 paired-capture session + GPU training on the fleet (ruvultra RTX 5080). Cross-subject collapse (11.6%) is data-gated on a heterogeneous multi-subject CSI dataset, per ADR-150 §F3 / ADR-152 F3 (the lever is *more data*, not capacity). Accepted-on-paper, not proven.
|
||||
- **HIGH — Federation + BFLD privacy chains (ADR-105–109, 118–125).** All Proposed-only, ACs unchecked. Blockers: KIT BFId dataset (121), Pi5/Nexmon CBFR capture hardware (123 — ESP32 structurally cannot sniff CBFR), Soul-Signature + cog-ha-matter (122/125). The privacy control *plane* (ADR-141) is built; the *capture/scoring* chain it gates is not.
|
||||
- ~~**HIGH — Sensing-server security (ADR-080).** Distinct from the HOMECORE boundary the sweep fixed; XFF bypass / stack-trace leakage / JWT-in-URL remain open.~~ **RESOLVED (2026-06-13, G11):** verified against the current Rust sensing-server — stack-trace leakage was the one live finding (fixed via `error_response` generic bodies); XFF bypass and JWT-in-URL were verified absent and regression-pinned. See ADR-080 P0 §1–3.
|
||||
- **MEDIUM — gold-standard deferrals (model to follow):** ADR-163 (ESP32 on-hardware latency UNMEASURED), ADR-160 (medical/affect/weapon NOT validated, relabelled), ADR-158 (RF-through-rubble + learned counter DATA-GATED). Code is real, the claim is withheld pending absent hardware/labelled data — labels are honest.
|
||||
- **MEDIUM — purely hardware/data-gated Proposed decisions (no overreach):** ADR-023, 027, 042, 063/064, 065/066, 070, 073/078, 083, 086, 091, 103, 110 (HE-CSI needs ESP-IDF ≥5.5), 113, 114, 134/135, 143-v2, 144. *needs verification* where flags rely on downstream prose rather than direct file inspection.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive.** One authoritative ledger replaces scattered, drifting status fields. The anti-slop retractions are recorded in a citable place, so the "AI slop" accusation is met with a structured admission + fix-trail rather than denial. The Gap Register is a concrete, severity-ordered work queue. Batch-fixing G5 (10 streaming-engine headers) and G1/G2 (numbering + missing headers) is high-ROI and unblocks ADR tooling.
|
||||
|
||||
**Negative.** This ADR is a snapshot; it goes stale the moment the next ADR lands. Counts marked `~` are approximate and a few impl_state values are *needs verification* (downstream-prose-derived, not file-confirmed). Acting on the register (renumbering, status flips, supersession edits) touches ~30 files and risks transient cross-reference breakage if not done atomically.
|
||||
|
||||
**Neutral.** No subsystem behavior changes. Renumbering decisions (which of the colliding files keeps each number) are deferred to the follow-up remediation PR — this ADR records the collision, not the resolution. Whether to close abandoned chains as `Rejected` vs `Deferred` is a judgment call left to the deciders per chain.
|
||||
|
||||
## Links
|
||||
|
||||
- `docs/adr/gap-analysis/census.md` — full per-ADR census (162 entries).
|
||||
- `docs/adr/gap-analysis/lens-findings.md` — five-lens findings (status-distribution, supersession-chains, contradictions, coverage-gaps, data-hardware-gated), verbatim.
|
||||
- Anti-slop sweep: ADR-154, ADR-155, ADR-156, ADR-157, ADR-158, ADR-159, ADR-160, ADR-161, ADR-162, ADR-163.
|
||||
- Most-cited defects: ADR-079, ADR-134, ADR-002, ADR-136–145, ADR-152.
|
||||
- Governance: CLAUDE.md (crate table + publishing order — stale per G18); ADR-038 (prior roadmap census, now stale).
|
||||
@@ -0,0 +1,129 @@
|
||||
# ADR-165: HOMECORE-MIGRATE — Migration Tooling from Python Home Assistant
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Accepted — P1 scaffold (full conversion deferred to P2) |
|
||||
| **Date** | 2026-05-25 |
|
||||
| **Deciders** | ruv |
|
||||
| **Codename** | **HOMECORE-MIGRATE** |
|
||||
| **Crate** | `v2/crates/homecore-migrate` |
|
||||
| **Relates to** | [ADR-126](ADR-126-ruview-native-ha-port-master.md) (HOMECORE master — series map row "ADR-134 HOMECORE-MIGRATE"), [ADR-127](ADR-127-homecore-state-machine-rust.md) (HOMECORE-CORE), [ADR-132](ADR-132-homecore-recorder-history-semantic-search.md) (HOMECORE-RECORDER — P2 side-by-side export target) |
|
||||
| **Tracking issue** | [#800](https://github.com/ruvnet/RuView/pull/800) (HOMECORE intake) |
|
||||
|
||||
> **Number-collision resolution (2026-06-12).** The HOMECORE series in ADR-126 §4 planned
|
||||
> "ADR-134 = HOMECORE-MIGRATE", and the `homecore-migrate` crate cites "ADR-134" throughout.
|
||||
> But the on-disk `ADR-134-csi-to-cir-time-domain-multipath.md` is a **different, unrelated
|
||||
> decision** (First-Class CIR Support, a signal-processing tier). The migrate crate was
|
||||
> therefore governed by a phantom identity (ADR-164 Gap G3 / Coverage-Gaps Lens §A). This
|
||||
> ADR takes the next free number (**165**) and becomes the real governing record for
|
||||
> HOMECORE-MIGRATE; the `ADR-134` references inside `v2/crates/homecore-migrate/` are
|
||||
> repointed to ADR-165. The real ADR-134 (CIR) is untouched. ADR-126's series-map row still
|
||||
> labels the *role* "ADR-134 HOMECORE-MIGRATE" for historical traceability; that registry
|
||||
> renumber is owner-gated and left for the follow-up. This ADR reverse-documents the shipped
|
||||
> P1 scaffold; it introduces no new design.
|
||||
|
||||
---
|
||||
|
||||
## 1. Context
|
||||
|
||||
ADR-126 decided to reimplement Home Assistant (HA) natively in Rust. A user adopting
|
||||
HOMECORE has an existing HA install whose configuration lives in two places on disk:
|
||||
|
||||
- `.storage/*.json` — versioned JSON envelopes (`{ version, minor_version, data }`) holding
|
||||
the entity registry, device registry, and config entries;
|
||||
- top-level YAML — `secrets.yaml`, `automations.yaml`.
|
||||
|
||||
To migrate, HOMECORE must read this foreign, **untrusted** on-disk state. It is untrusted in
|
||||
the security sense: the schema can drift between HA releases, and silently mis-parsing a
|
||||
registry would corrupt the imported home. ADR-164 flagged this as a CRITICAL coverage gap —
|
||||
a data-integrity-sensitive importer governed by a non-existent ADR identity.
|
||||
|
||||
The decision an ADR must pin here is the **trust boundary and import contract**: which HA
|
||||
files are read, how schema versions are validated, and what happens on an unknown version.
|
||||
|
||||
## 2. Decision
|
||||
|
||||
Ship `homecore-migrate` as a CLI + library that reads an existing HA filesystem and imports
|
||||
its configuration into HOMECORE. P1 is a **scaffold**: it parses and inspects everything and
|
||||
converts the entity registry; full conversion of the remaining artifacts is deferred to P2.
|
||||
|
||||
### 2.1 Storage reader + versioned format gate (P1, shipped)
|
||||
|
||||
- `HaStorageDir` / `HaStorageEnvelope` read HA's `.storage/` directory; `read_envelope(path)`
|
||||
deserializes a `.storage/*.json` envelope (`src/storage.rs`).
|
||||
- Versioned parsers live under `storage_format::v<N>` (e.g. `v13` for the entity registry)
|
||||
(`src/storage_format/`).
|
||||
- **Schema-version validation is the load-bearing safety rule (§6 Q5 of this ADR):** an
|
||||
unknown `minor_version` is a **hard error** (`MigrateError::UnsupportedSchemaVersion`),
|
||||
never a silent best-effort parse. Better to refuse than to corrupt.
|
||||
|
||||
### 2.2 Per-artifact parsers (P1, shipped)
|
||||
|
||||
- `entity_registry::load()` — `core.entity_registry` → `Vec<homecore::EntityEntry>`
|
||||
(ready for import).
|
||||
- `device_registry::load()` — `core.device_registry` → `Vec<DeviceImport>` (P1 diagnostic;
|
||||
full conversion P2).
|
||||
- `config_entries::load()` — `core.config_entries` → domain counts + integration names
|
||||
(the format is undocumented per §6 Q5; treated diagnostically).
|
||||
- `secrets::load_secrets()` — `secrets.yaml` → `HashMap<String, String>` (resolution P2).
|
||||
- `automations::load()` — `automations.yaml` → count + ID/alias list (conversion P2).
|
||||
|
||||
### 2.3 CLI (P1, shipped)
|
||||
|
||||
- `homecore-migrate inspect <ha-dir>` previews what will be migrated (entity/device/config
|
||||
counts, redacted secret/automation lists) (`src/cli.rs`, `src/main.rs`).
|
||||
- `import-entities` and `export-for-sidecar` are declared but their full behaviour is P2.
|
||||
|
||||
### 2.4 Structured errors (P1, shipped)
|
||||
|
||||
- `MigrateError` carries context (`path`, line/field) for I/O, JSON, YAML, missing-field,
|
||||
unsupported-schema-version, and entity-id parse failures (`src/lib.rs`).
|
||||
|
||||
### 2.5 Deferred to P2+ (NOT built — honestly labelled)
|
||||
|
||||
- Convert `config_entries` → HOMECORE plugin manifests.
|
||||
- Convert `automations.yaml` → `homecore-automation` YAML.
|
||||
- Side-by-side runtime mode (requires `homecore-recorder`, ADR-132; behind the `recorder`
|
||||
Cargo feature, currently a no-op stub).
|
||||
- `!secret` reference resolution in non-secrets YAML files.
|
||||
|
||||
### 2.6 Test evidence (as shipped)
|
||||
|
||||
- 19 tests (`cargo test -p homecore-migrate`), per the crate README badge.
|
||||
|
||||
## 3. Consequences
|
||||
|
||||
**Positive.**
|
||||
|
||||
- The trust boundary is explicit: unknown HA schema versions are rejected, not guessed, so a
|
||||
schema drift fails loudly instead of corrupting an imported home.
|
||||
- Reusing HA's own `.storage` and YAML formats means no intermediate export step; the tool
|
||||
reads a live HA install directly.
|
||||
- P1 `inspect` gives users a no-risk dry run before any write.
|
||||
|
||||
**Negative / honest limits.**
|
||||
|
||||
- P1 is a **scaffold**: only the entity registry is conversion-ready. Device registry,
|
||||
config-entry→plugin, automation, and secret-resolution conversions are P2 and **not yet
|
||||
built** — the Status field and crate docs say so.
|
||||
- The side-by-side recorder export depends on ADR-132 and is currently a feature-gated
|
||||
no-op.
|
||||
- Performance figures in the README (envelope parse < 5 ms, 1 000-entity load < 50 ms) are
|
||||
estimates, **needs verification** with a benchmark.
|
||||
|
||||
**Neutral.**
|
||||
|
||||
- This resolves only the *identity* of the migrate decision (134→165). The broader 6-way
|
||||
duplicate-number cleanup (incl. ADR-126's series-map registry row) is owner-gated.
|
||||
|
||||
## 4. Links
|
||||
|
||||
- Crate: `v2/crates/homecore-migrate/` — `Cargo.toml`, `README.md`, `src/lib.rs`,
|
||||
`src/storage.rs`, `src/storage_format/`, `src/entity_registry.rs`,
|
||||
`src/device_registry.rs`, `src/config_entries.rs`, `src/secrets.rs`,
|
||||
`src/automations.rs`, `src/cli.rs`, `src/main.rs`.
|
||||
- [ADR-126](ADR-126-ruview-native-ha-port-master.md) — HOMECORE master (series map: HOMECORE-MIGRATE).
|
||||
- [ADR-132](ADR-132-homecore-recorder-history-semantic-search.md) — HOMECORE-RECORDER (P2 side-by-side export target).
|
||||
- [ADR-134](ADR-134-csi-to-cir-time-domain-multipath.md) — First-Class CIR Support (the *unrelated* decision the crate was mistakenly citing).
|
||||
- [ADR-164](ADR-164-adr-corpus-gap-analysis.md) — gap analysis that surfaced this collision (Gap G3).
|
||||
- [Home Assistant `.storage` format](https://developers.home-assistant.io/docs/storage/).
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
# ADR-050: Quality Engineering Response — Security Hardening & Code Quality
|
||||
# ADR-166: Quality Engineering Response — Security Hardening & Code Quality
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
@@ -1,4 +1,8 @@
|
||||
# ADR-052 Appendix: DDD Bounded Contexts — Tauri Desktop Frontend
|
||||
# ADR-167 Appendix: DDD Bounded Contexts — Tauri Desktop Frontend
|
||||
|
||||
> Appendix to [ADR-052](ADR-052-tauri-desktop-frontend.md). Renumbered from ADR-052
|
||||
> to ADR-167 to resolve the ADR-052 duplicate-number collision (per ADR-164 Gap Register
|
||||
> G1); the parent decision remains ADR-052.
|
||||
|
||||
This document maps out the domain model for the RuView Tauri desktop application
|
||||
described in ADR-052. It defines bounded contexts, their aggregates, entities,
|
||||
@@ -158,7 +162,7 @@ Represents an over-the-air firmware update to a running node.
|
||||
| `target_node` | `MacAddress` | Target node MAC |
|
||||
| `target_ip` | `IpAddr` | Target node IP |
|
||||
| `firmware` | `FirmwareBinary` | The binary being pushed |
|
||||
| `psk` | `Option<SecureString>` | PSK for authentication (ADR-050) |
|
||||
| `psk` | `Option<SecureString>` | PSK for authentication (ADR-166) |
|
||||
| `phase` | `OtaPhase` | Uploading / Rebooting / Verifying / Done / Failed |
|
||||
| `progress` | `Progress` | Upload progress |
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# ADR-147 Benchmark Proof — OccWorld on RTX 5080
|
||||
# ADR-168 Benchmark Proof — OccWorld on RTX 5080
|
||||
Date: 2026-05-29
|
||||
Hardware: NVIDIA GeForce RTX 5080 (15.47 GB VRAM), CUDA 12.8
|
||||
Model: OccWorld TransVQVAE (random weights — pre-domain-fine-tuning baseline)
|
||||
@@ -0,0 +1,226 @@
|
||||
# ADR-169: adam-mode — light theme toggle for the three.js realtime demo
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Proposed |
|
||||
| **Date** | 2026-06-02 |
|
||||
| **Deciders** | ruv |
|
||||
| **Codename** | **adam-mode** |
|
||||
| **Scope** | `examples/three.js/demos/05-skinned-realtime.html` (primary), demos 01–04 (follow-on) |
|
||||
| **Relates to** | ADR-019 (sensing-only UI), ADR-035 (live sensing UI accuracy) |
|
||||
| **Tracking issue** | none yet |
|
||||
|
||||
---
|
||||
|
||||
## 1. Context
|
||||
|
||||
`examples/three.js/demos/05-skinned-realtime.html` (build stamp `2026-05-15-fps-tune`) is the live MediaPipe → Mixamo retargeting + ESP32 CSI overlay demo. It currently ships a single, opinionated **dark theme**:
|
||||
|
||||
- Body `--bg: #050507` (near-black), `--text: #d8c69a` (warm beige).
|
||||
- Amber accents (`--amber: #ffb840`, `--amber-hot: #ffe09f`) on panels and controls.
|
||||
- Two full-screen overlays: a radial-vignette `.overlay-frame` and a 50%-opacity CRT-style `.scanlines` layer.
|
||||
- Three.js scene matches: `scene.background = new THREE.Color(0x050507)` and `scene.fog = new THREE.FogExp2(0x050507, 0.06)` (lines 269–270).
|
||||
|
||||
The dark/amber CRT aesthetic is intentional for screen-recording and "command-centre" feel, but it has real failure modes:
|
||||
|
||||
1. **Daylight visibility** — Demoing the live capture on a laptop in a sunlit room is unreadable; the dark background absorbs ambient glare and the amber-on-dark contrast disappears.
|
||||
2. **Recording for embedded/print contexts** — When the demo's screen is captured for documentation, blog posts, or HA blueprints, the dark theme bleeds into surrounding white content and looks heavy.
|
||||
3. **Accessibility** — A subset of users with light-sensitive retinas (the inverse of typical photophobia) report the high amber-on-near-black combination strains them; high-contrast light themes are easier.
|
||||
4. **Operator pairing with a light-mode IDE** — Many operators run a light-mode browser alongside a dark-mode IDE and want the demo to match the browser, not the IDE.
|
||||
|
||||
A toggle is the right answer because none of these reasons are universal — some sessions and some users want each mode.
|
||||
|
||||
### 1.1 What this ADR is *not*
|
||||
|
||||
- Not a redesign. The amber accent stays; only the surface colours and overlays swap. The information density, panel layout, and three.js scene geometry are unchanged.
|
||||
- Not a multi-theme system. We add exactly two themes: the existing dark (default, unnamed) and **adam-mode** (light). Future themes would need a new ADR.
|
||||
- Not a backend / data-model change. Pure presentation.
|
||||
- Not yet propagated to demos 01–04. Those follow-on after adam-mode lands on demo 05 and is validated.
|
||||
|
||||
## 2. Decision
|
||||
|
||||
Add a **client-side theme toggle** to `05-skinned-realtime.html` that switches between the existing dark theme and a new light theme called **adam-mode**, driven by a `data-theme="adam"` attribute on `<body>` plus a sibling `:root[data-theme="adam"]` CSS block that re-defines the existing custom properties. A new toggle button in the existing `#helpers` panel switches between modes and persists the choice in `localStorage` under the key `ruview.theme`.
|
||||
|
||||
### 2.1 CSS — the colour swap
|
||||
|
||||
Add immediately after the existing `:root { ... }` block in `<style>`:
|
||||
|
||||
```css
|
||||
:root[data-theme="adam"] {
|
||||
--bg: #f6f2ea;
|
||||
--bg-panel: rgba(252, 250, 246, 0.92);
|
||||
--amber: #b8741a; /* deeper amber, readable on cream */
|
||||
--amber-hot: #8a5612; /* deepest amber for emphasis text */
|
||||
--cyan: #1a6f8a; /* slate cyan */
|
||||
--magenta: #a8348a; /* slate magenta */
|
||||
--text: #2a241c; /* near-black warm */
|
||||
--text-mute: #7a6f5d; /* warm grey */
|
||||
--green: #1f7a32; /* forest green */
|
||||
--red: #b03a1a; /* burnt sienna */
|
||||
--border: rgba(184, 116, 26, 0.28);
|
||||
}
|
||||
```
|
||||
|
||||
Every existing element already reads from these custom properties, so the swap is automatic for panels, text, borders, and bar fills. No per-element CSS rewrites required.
|
||||
|
||||
### 2.2 Overlay handling
|
||||
|
||||
The vignette and scanlines are dark-theme aesthetics. In adam-mode they would muddy the cream background. Two new rules:
|
||||
|
||||
```css
|
||||
:root[data-theme="adam"] .overlay-frame {
|
||||
background:
|
||||
radial-gradient(ellipse at center, transparent 70%, rgba(184,116,26,0.10) 100%),
|
||||
linear-gradient(180deg, rgba(184,116,26,0.06) 0%, transparent 18%, transparent 82%, rgba(184,116,26,0.08) 100%);
|
||||
}
|
||||
:root[data-theme="adam"] .scanlines {
|
||||
opacity: 0.15;
|
||||
mix-blend-mode: multiply;
|
||||
}
|
||||
```
|
||||
|
||||
The vignette is preserved but inverted in colour and lightened; scanlines drop to 15 % opacity and switch from `overlay` to `multiply` blend so they read as faint paper texture rather than CRT lines.
|
||||
|
||||
### 2.3 Three.js scene reactivity
|
||||
|
||||
Two scene colours are hard-coded at construction (lines 269–270). Replace them with a function call that reads the current theme:
|
||||
|
||||
```js
|
||||
function themeSceneColors(theme) {
|
||||
return theme === 'adam'
|
||||
? { bg: 0xf6f2ea, fogDensity: 0.025 }
|
||||
: { bg: 0x050507, fogDensity: 0.06 };
|
||||
}
|
||||
function applySceneTheme(theme) {
|
||||
const c = themeSceneColors(theme);
|
||||
scene.background = new THREE.Color(c.bg);
|
||||
scene.fog = new THREE.FogExp2(c.bg, c.fogDensity);
|
||||
renderer.setClearColor(c.bg, 1.0);
|
||||
}
|
||||
```
|
||||
|
||||
Called once after `renderer` is constructed, then again from the toggle handler.
|
||||
|
||||
`scene.fog` density drops in adam-mode because exponential fog on a light background reads as "haze" much more strongly than on dark — 0.06 → 0.025 keeps the falloff visible without losing the figure into the background.
|
||||
|
||||
### 2.4 UI toggle
|
||||
|
||||
Add to the `#helpers` panel (top of its labels list):
|
||||
|
||||
```html
|
||||
<label class="theme-toggle">
|
||||
<input type="checkbox" id="adam-mode-toggle">
|
||||
<span>adam-mode (light)</span>
|
||||
<span class="swatch" style="background: var(--amber)"></span>
|
||||
</label>
|
||||
```
|
||||
|
||||
Handler:
|
||||
|
||||
```js
|
||||
const THEME_KEY = 'ruview.theme';
|
||||
const root = document.documentElement;
|
||||
const toggle = document.getElementById('adam-mode-toggle');
|
||||
|
||||
function applyTheme(theme) {
|
||||
if (theme === 'adam') {
|
||||
root.setAttribute('data-theme', 'adam');
|
||||
toggle.checked = true;
|
||||
} else {
|
||||
root.removeAttribute('data-theme');
|
||||
toggle.checked = false;
|
||||
}
|
||||
applySceneTheme(theme);
|
||||
try { localStorage.setItem(THEME_KEY, theme); } catch (_) {}
|
||||
}
|
||||
|
||||
const initialTheme = (() => {
|
||||
try { return localStorage.getItem(THEME_KEY) || 'dark'; }
|
||||
catch (_) { return 'dark'; }
|
||||
})();
|
||||
applyTheme(initialTheme);
|
||||
|
||||
toggle.addEventListener('change', e => {
|
||||
applyTheme(e.target.checked ? 'adam' : 'dark');
|
||||
});
|
||||
```
|
||||
|
||||
### 2.5 Why "adam-mode" as the codename
|
||||
|
||||
The user picked the name. It is a project-specific brand — distinct from the generic "light mode" terminology that other modes (`--theme=high-contrast`, `--theme=print`) may eventually need. Keeping a codename makes the toggle searchable in the codebase, the localStorage key portable across the demo set, and avoids ambiguity if dark itself is later renamed.
|
||||
|
||||
The string `"adam"` is the only literal value the `data-theme` attribute and the `localStorage` key ever take. `"dark"` is the implicit default (no attribute, no stored value).
|
||||
|
||||
### 2.6 Rejected alternatives
|
||||
|
||||
| Alternative | Rejected because |
|
||||
|---|---|
|
||||
| Use `prefers-color-scheme: light` only, no toggle | Operators frequently want the opposite of their OS preference for screen-recording or daylight desk use. Auto-only frustrates the actual use case. |
|
||||
| Ship two separate HTML files (`05-…-dark.html`, `05-…-light.html`) | Doubles maintenance for every future demo edit. No path to per-session toggle. |
|
||||
| Build a full multi-theme system with a runtime registry | Premature. Two themes don't need a registry; the `data-theme="adam"` attribute is the registry. |
|
||||
| Use Tailwind / DaisyUI / a CSS framework | Demos are intentionally stand-alone single-file HTML for portability. No build step exists; adding one for theming is wrong shape. |
|
||||
| Adopt the cognitum-v0 / HOMECORE design tokens (`--hc-*` from `examples/frontend/`) | That design system is dark-only by intent (ADR-131). adam-mode is the light counterpart needed in *demo* contexts, not HA dashboard contexts. |
|
||||
| Make adam-mode the default | Breaks the dark-aesthetic recording context this demo was originally built for. Default stays dark; toggle stays opt-in. |
|
||||
|
||||
## 3. Consequences
|
||||
|
||||
### 3.1 Positive
|
||||
|
||||
- Demo is usable in daylight, in printed documentation, on light-mode browsers, and by users who find the dark-amber combination fatiguing.
|
||||
- Toggle persists across reloads via `localStorage` — set once, sticks.
|
||||
- No structural change to information density, panel layout, or three.js scene geometry. Operators familiar with the dark theme can switch and still find every readout in the same place.
|
||||
- Implementation is contained — a single `<style>` block addition, a single button, a ~25-line JS handler, and a swap of two scene-construction lines.
|
||||
|
||||
### 3.2 Negative
|
||||
|
||||
- Two themes to maintain. Any future colour change requires updating both `:root` blocks. Mitigated by keeping the existing custom-property names — adam-mode's values are the only edits.
|
||||
- The vignette + scanlines lose some of the CRT charm in adam-mode. Tradeoff accepted by design.
|
||||
- One additional `localStorage` slot consumed per origin (`ruview.theme`).
|
||||
- The amber accent in adam-mode (`#b8741a`) is visibly different from the dark-mode amber (`#ffb840`) — they share the same CSS variable name but a screenshot from each mode is not pixel-comparable. This is the correct call for accessibility (the bright amber is unreadable on cream) but does mean side-by-side comparisons need both screenshots labelled.
|
||||
|
||||
### 3.3 Risks
|
||||
|
||||
| Risk | Likelihood | Mitigation |
|
||||
|---|---|---|
|
||||
| Future demo edits update one `:root` block and forget the other | Medium | A lint script in `scripts/` could grep both blocks for matching key sets; documented as P2 follow-up. |
|
||||
| `localStorage` blocked by privacy settings | Low | All accesses are wrapped in try/catch; falls back to dark. |
|
||||
| Three.js fog density of 0.025 still washes out the model on adam-mode | Low | Empirically tuned during implementation; if it does, drop to 0.015 or remove fog entirely in adam-mode. |
|
||||
| User on a high-DPI display sees scanlines as visible paper texture even at 15 % opacity | Low | If reported, drop to 8 % or hide scanlines entirely in adam-mode. |
|
||||
|
||||
## 4. Implementation plan
|
||||
|
||||
Tiny scope — single file. No swarm needed.
|
||||
|
||||
1. Add `:root[data-theme="adam"]` CSS block and the two overlay overrides.
|
||||
2. Refactor scene background + fog into the two helper functions `themeSceneColors()` and `applySceneTheme()`.
|
||||
3. Add `<label>` markup and handler script.
|
||||
4. Verify in a browser at http://127.0.0.1:8765/examples/three.js/demos/05-skinned-realtime.html — toggle on, reload, confirm adam-mode persists; toggle off, reload, confirm dark persists.
|
||||
5. Smoke-screenshot both modes; commit.
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- Toggle checkbox visible in `#helpers` panel.
|
||||
- Clicking the toggle swaps colours within one frame.
|
||||
- Reload preserves last choice.
|
||||
- Three.js scene background follows the toggle (no dark frame visible behind a light HUD or vice-versa).
|
||||
- Existing dark-theme appearance is byte-identical when toggle is off.
|
||||
|
||||
## 5. Test plan
|
||||
|
||||
- Manual visual check in two themes (no automated visual regression — demos aren't in the CI test loop today).
|
||||
- `view-source` confirms the new CSS block, the toggle markup, and the handler are present.
|
||||
- DevTools `localStorage` shows `ruview.theme` after a toggle.
|
||||
- Three.js inspector (or a `console.log(scene.background.getHexString())`) confirms scene colour swap.
|
||||
|
||||
## 6. Follow-on work (out of scope for this ADR)
|
||||
|
||||
- Roll adam-mode into demos 01–04. Each demo has its own `<style>` block; the same `data-theme="adam"` selector and the same JS handler can be copied.
|
||||
- Honor `prefers-color-scheme: light` on first load *if* `localStorage` has no stored choice. Trivial three-line addition.
|
||||
- Add a high-contrast theme for accessibility (separate ADR).
|
||||
- Lint script that asserts both `:root` blocks declare the same custom-property names.
|
||||
|
||||
## 7. Related ADRs
|
||||
|
||||
- [ADR-019](ADR-019-sensing-only-ui-mode.md) — sensing-only UI mode (Gaussian splats viewer)
|
||||
- [ADR-035](ADR-035-live-sensing-ui-accuracy.md) — live sensing UI accuracy norms (which this demo follows)
|
||||
- [ADR-131](docs/adr/ADR-131-...) — HOMECORE / cognitum-v0 design tokens (dark-only, separate context)
|
||||
@@ -0,0 +1,643 @@
|
||||
# ADR-170: yoga-mode — pose detection, classification, and scoring for the three.js realtime demo
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Proposed |
|
||||
| **Date** | 2026-06-02 |
|
||||
| **Deciders** | ruv |
|
||||
| **Codename** | **yoga-mode** |
|
||||
| **Scope** | `examples/three.js/demos/05-skinned-realtime.html` (primary); new `examples/three.js/demos/06-yoga-mode.html` (secondary, slimmed-down) |
|
||||
| **Relates to** | ADR-169 (adam-mode light theme), ADR-019 (sensing-only UI), ADR-035 (live sensing UI accuracy) |
|
||||
| **Tracking issue** | none yet |
|
||||
|
||||
---
|
||||
|
||||
## 1. Context
|
||||
|
||||
`examples/three.js/demos/05-skinned-realtime.html` already runs the full MediaPipe Pose Heavy pipeline at ~30 Hz: 33 BlazePose landmarks flow through a one-euro-filter bank into joint-angle extraction and then into a Mixamo X Bot IK retarget. The `#pose-panel` HUD shows landmark count, visibility, and pose FPS. The `#helpers` panel (ADR-097) has adam-mode (ADR-169) and eight visualisation toggles.
|
||||
|
||||
This infrastructure is complete. Every frame, per-joint angles are already computable from the existing `liveKp` world-space landmark array. What does not yet exist is any layer that interprets those angles as a known yoga pose, scores the user's alignment against a target shape, and guides the user through a structured sequence.
|
||||
|
||||
### 1.1 Why yoga-mode in this demo
|
||||
|
||||
Three concrete use-cases drive this:
|
||||
|
||||
1. **Developer self-test for the retargeting pipeline.** Cycling through a Sun Salutation A is a systematic, reproducible way to exercise every major joint (shoulder, elbow, hip, knee, spine). A pose-scoring overlay makes regression immediately visible — if a code change breaks elbow retargeting, the yoga classifier will output a depressed alignment score on Chaturanga even before a visual inspection.
|
||||
|
||||
2. **Public demonstration value.** The demo is served at `http://127.0.0.1:8765/examples/three.js/demos/05-skinned-realtime.html` and shown to evaluators. A guided instructional mode that scores real-time body alignment against Tadasana or Downward Dog is immediately intelligible to a non-technical audience in a way that raw CSI amplitude bars are not.
|
||||
|
||||
3. **Future bridge to the Rust host.** The Rust-side `wifi-densepose-signal/src/ruvsense/pose_tracker.rs` maintains a 17-keypoint Kalman tracker in COCO convention. yoga-mode in the demo operates on the 33-landmark MediaPipe convention. These are not the same: MediaPipe indices 0–32 (BlazePose) map non-trivially to COCO 0–16. Deciding the mapping now — even in a pure-JS context — canonicalises it for the eventual Rust integration.
|
||||
|
||||
### 1.2 What this ADR is *not*
|
||||
|
||||
- Not a backend service. No WebSocket endpoint, no session record, no cloud upload. Pure client-side HTML.
|
||||
- Not a fitness-app competitor. The scope is Sun Salutation A (8 poses). The full 84-asana classical corpus is out of scope.
|
||||
- Not an integration with the Rust `pose_tracker.rs`. That bridge is documented here as a future consequence, not an immediate deliverable.
|
||||
- Not a redesign of demo 05. Panel layout, three.js scene geometry, and the CSI overlay are unchanged.
|
||||
- Not a new design system. yoga-mode inherits every existing CSS custom property.
|
||||
|
||||
### 1.3 COCO-17 ↔ BlazePose-33 mapping note
|
||||
|
||||
The Rust tracker uses COCO 17-keypoint indices (0=nose, 5=left-shoulder, 6=right-shoulder, 7=left-elbow, 8=right-elbow, 9=left-wrist, 10=right-wrist, 11=left-hip, 12=right-hip, 13=left-knee, 14=right-knee, 15=left-ankle, 16=right-ankle). MediaPipe BlazePose-33 uses a different, denser scheme where shoulders are at 11–12, elbows at 13–14, wrists at 15–16, hips at 23–24, knees at 25–26, ankles at 27–28.
|
||||
|
||||
The mapping for the 13 joints used in yoga-mode angle computation is:
|
||||
|
||||
| Joint role | COCO idx | BlazePose idx |
|
||||
|---|---|---|
|
||||
| nose | 0 | 0 |
|
||||
| left shoulder | 5 | 11 |
|
||||
| right shoulder | 6 | 12 |
|
||||
| left elbow | 7 | 13 |
|
||||
| right elbow | 8 | 14 |
|
||||
| left wrist | 9 | 15 |
|
||||
| right wrist | 10 | 16 |
|
||||
| left hip | 11 | 23 |
|
||||
| right hip | 12 | 24 |
|
||||
| left knee | 13 | 25 |
|
||||
| right knee | 14 | 26 |
|
||||
| left ankle | 15 | 27 |
|
||||
| right ankle | 16 | 28 |
|
||||
|
||||
When the Rust host integration is implemented, the joint-angle features extracted by yoga-mode in JS and by `pose_tracker.rs` in Rust will be computed from the same physical joints via this table. No translation layer is needed at runtime — yoga-mode always uses BlazePose indices; `pose_tracker.rs` always uses COCO indices.
|
||||
|
||||
### 1.4 Biomechanical basis for joint-angle targets
|
||||
|
||||
The joint-angle targets in this ADR are grounded in peer-reviewed measurements. Perez-Testor et al. (2019, PMC6521759) captured 10 trained practitioners performing Surya Namaskar A on a 12-camera Vicon system at 100 Hz, reporting sagittal-plane joint angles at each pose transition. Key ranges: elbow 22°–116°, hip 15° extension to 134° flexion, knee 3° hyperextension to 140° flexion, spine 44° extension to 58° flexion, shoulder 56°–183°. These empirical ranges set the upper and lower bounds for the tolerance bands in this ADR's pose templates. Where Perez-Testor does not report a joint (e.g. wrist flexion for Chaturanga arm angle), the Iyengar geometry — "elbows at 90° bent close to the body" — supplies the target value. A 2023 PMC yoga-pose review (PMC10280249) confirming angle-heuristic approaches as the most reliable real-time classification method validates the algorithmic choice.
|
||||
|
||||
---
|
||||
|
||||
## 2. Decision
|
||||
|
||||
### 2.1 Pose taxonomy — Sun Salutation A, 8 poses
|
||||
|
||||
Sun Salutation A is chosen for the first ship. It satisfies three criteria simultaneously: the poses are geometrically distinct from each other (no two share the same joint-angle signature), they form a complete bilateral sequence (both left and right sides are exercised), and they are among the best-documented asanas in the biomechanics literature. The Sanskrit and English names are unambiguous in the Ashtanga tradition.
|
||||
|
||||
The 8 poses in sequence order with their one-line joint-angle signatures:
|
||||
|
||||
| Stage | Sanskrit | English | Joint-angle signature |
|
||||
|---|---|---|---|
|
||||
| 1 | Tāḍāsana | Mountain Pose | All limbs extended: knees 180°, hips 180°, elbows 180°, spine vertical |
|
||||
| 2 | Ūrdhva Hastāsana | Upward Salute | Arms overhead: shoulders ~180° abducted, elbows 180°, torso elongated |
|
||||
| 3 | Uttānāsana | Standing Forward Fold | Hips ~0–30° (full fold), knees 180°, elbows relaxed, spine flexed |
|
||||
| 4 | Ardha Uttānāsana | Half Lift / Flat-Back | Hips ~90° (parallel torso), knees 180°, spine neutral (horizontal) |
|
||||
| 5 | Catvāri (Chaturanga Daṇḍāsana) | Four-Limbed Staff | Hips 180° (plank line), elbows ~90°, shoulders depressed, body horizontal |
|
||||
| 6 | Ūrdhva Mukha Śvānāsana | Upward-Facing Dog | Hips extended ~160°+, shoulders over wrists, spine extended, knees off floor |
|
||||
| 7 | Adho Mukha Śvānāsana | Downward-Facing Dog | Hips ~80–110° (inverted V), knees 180°, shoulders ~180° (arms overhead), spine long |
|
||||
| 8 | Uttānāsana | Standing Forward Fold (return) | Same as stage 3 — mirrors the descent; re-classified as stage 8 for sequence tracking |
|
||||
|
||||
"All 84 classical asanas" is explicitly rejected. Even the 26-pose Bikram set is rejected — the goal is a complete, self-contained instructional sequence for a 2–3 minute demo session, not exhaustive coverage. Eight poses are the minimum for a meaningful sequence narrative and the maximum that fits a single UI strip without horizontal scrolling on a 1080p screen.
|
||||
|
||||
### 2.2 Detection algorithm — joint-angle threshold matching with weighted scoring
|
||||
|
||||
**Chosen: joint-angle threshold matching.** For each frame, compute the angle at 6–10 named joints (one angle per joint, defined as the interior angle at the vertex formed by three landmarks). Compare each computed angle to the per-pose target. Score by weighted absolute deviation. Classify the argmax.
|
||||
|
||||
**Why not the alternatives:**
|
||||
|
||||
| Alternative | Verdict | Reason |
|
||||
|---|---|---|
|
||||
| Skeleton-as-vector cosine similarity | Rejected | Position-sensitive: a person standing 2 m from the camera vs. 1 m produces different vectors. Joint angles are translation- and scale-invariant by construction. |
|
||||
| Small MLP trained on a labelled dataset | Rejected | No labelled dataset exists in this codebase. Training a reliable MLP for 8 poses would require hundreds of labelled examples per class, a train/test split, and a model serialization format — none of which belongs in a single-file demo HTML. Joint-angle matching achieves the same discrimination for 8 geometrically distinct poses with zero training data. |
|
||||
| MediaPipe Tasks PoseClassifier (EfficientNet-based) | Rejected | Requires loading a separate `.task` bundle (~4 MB), adds a network dependency to the demo's offline-capable design, and uses a black-box embedding — undebuggable when a pose is misclassified. Threshold matching is fully inspectable in DevTools. |
|
||||
| DTW template matching on full landmark sequences | Rejected | Appropriate for gesture recognition over time (ADR-014's `gesture.rs`), not static pose classification. Sun Salutation transitions are slow (2–5 seconds per pose); per-frame angle scoring is sufficient. |
|
||||
|
||||
**Joint angle computation.** For three landmark positions A (proximal), B (vertex), C (distal), the interior angle at B is:
|
||||
|
||||
```
|
||||
angle_B = arccos( dot(A-B, C-B) / (|A-B| * |C-B|) ) in degrees
|
||||
```
|
||||
|
||||
This is computed in world-space from the existing `liveKp` THREE.Vector3 array. The computation is purely arithmetic — no matrix inversion, no DFT. At 30 Hz on any modern laptop it is unmeasurably fast relative to the MediaPipe inference cost.
|
||||
|
||||
**Named joints used in yoga-mode.** Joint names, their three-landmark triplets (proximal-vertex-distal), and the BlazePose indices:
|
||||
|
||||
| Joint name | Triplet (P-V-D) | Indices |
|
||||
|---|---|---|
|
||||
| `left_elbow` | shoulder→elbow→wrist | 11→13→15 |
|
||||
| `right_elbow` | shoulder→elbow→wrist | 12→14→16 |
|
||||
| `left_knee` | hip→knee→ankle | 23→25→27 |
|
||||
| `right_knee` | hip→knee→ankle | 24→26→28 |
|
||||
| `left_hip` | shoulder→hip→knee | 11→23→25 |
|
||||
| `right_hip` | shoulder→hip→knee | 12→24→26 |
|
||||
| `left_shoulder` | hip→shoulder→elbow | 23→11→13 |
|
||||
| `right_shoulder` | hip→shoulder→elbow | 24→12→14 |
|
||||
| `torso_lean` | hip-midpoint→shoulder-midpoint→vertical | synthetic |
|
||||
|
||||
`torso_lean` is the angle between the hip-to-shoulder axis and the world vertical (Y axis). It distinguishes standing-upright (≈0°) from folded-forward (≈90°) from plank-horizontal (≈90° in a different axis pattern). In practice, it is implemented as `acos(dot(hipToShoulder.normalize(), UP_VECTOR))` where `UP_VECTOR = (0,1,0)`.
|
||||
|
||||
### 2.3 Pose template format — inline JSON, single-file portable
|
||||
|
||||
Templates live as a JS object literal inside the `<script>` block of the demo file. A sibling `poses.json` would break the single-file portability that makes demos easy to share and locally serve. The inline approach imposes no additional HTTP request and no CORS constraint.
|
||||
|
||||
**Schema** (one template per pose):
|
||||
|
||||
```js
|
||||
{
|
||||
id: "tadasana", // machine-readable ID, localStorage key fragment
|
||||
name_en: "Mountain Pose", // English common name
|
||||
name_sa: "Tāḍāsana", // Sanskrit with diacritics
|
||||
stage: 1, // position in the Sun Salutation A sequence (1-8)
|
||||
joint_targets: {
|
||||
left_elbow: { angle_deg: 180, tolerance_deg: 15, weight: 0.5 },
|
||||
right_elbow: { angle_deg: 180, tolerance_deg: 15, weight: 0.5 },
|
||||
left_knee: { angle_deg: 180, tolerance_deg: 10, weight: 1.0 },
|
||||
right_knee: { angle_deg: 180, tolerance_deg: 10, weight: 1.0 },
|
||||
left_hip: { angle_deg: 180, tolerance_deg: 12, weight: 0.8 },
|
||||
right_hip: { angle_deg: 180, tolerance_deg: 12, weight: 0.8 },
|
||||
torso_lean: { angle_deg: 0, tolerance_deg: 12, weight: 1.2 },
|
||||
},
|
||||
instruction: "Stand tall. Feet hip-width, weight even. Arms relaxed at your sides. Lengthen through the crown.",
|
||||
min_hold_s: 3, // seconds the pose must be held to count as completed
|
||||
}
|
||||
```
|
||||
|
||||
**Schema decisions:**
|
||||
|
||||
- `tolerance_deg` is the half-width of the pass band. An angle within `[target - tolerance, target + tolerance]` contributes full score for that joint. Beyond the tolerance band the score degrades linearly to zero at `target ± (tolerance * 3)`, then clamps to zero. This linear-outside-band behaviour prevents cliff edges where being 16° off scores identically to 90° off.
|
||||
|
||||
- `weight` carries the importance signal. High-weight joints (torso_lean 1.2, knees 1.0) dominate the aggregate score. Low-weight joints (elbows 0.5 in Tadasana, where arm position is relaxed) have less influence. A weight of 0 would mask a joint entirely — used when the joint is not visible (see §2.7 graceful degradation).
|
||||
|
||||
- `min_hold_s` is per-template. Tadasana and Uttanasana are grounding poses that benefit from a 3-second hold. Chaturanga is a strength pose where 2 seconds is already challenging. The value lives in the template, not as a global constant, so future operators can tune it per pose without touching logic.
|
||||
|
||||
- There is no `max_hold_s`. Holding a pose longer than `min_hold_s` does not penalise the score.
|
||||
|
||||
**Why `tolerance_deg` over explicit pass/fail thresholds.** A binary pass/fail at a hard threshold creates a jarring UX: the alignment bar slams between 0% and 100% at a single degree of motion. Linear-outside-band degradation provides smooth visual feedback that guides the user toward the target incrementally.
|
||||
|
||||
### 2.4 Scoring formula
|
||||
|
||||
Per-frame alignment score for pose *p*, given measured angle `θ_j` at joint *j*:
|
||||
|
||||
```
|
||||
delta_j = |θ_j − target_j.angle_deg|
|
||||
|
||||
band_score_j =
|
||||
1.0 if delta_j ≤ tolerance_j
|
||||
1.0 − (delta_j − tolerance_j) / (2 * tolerance_j) if delta_j ≤ 3 * tolerance_j
|
||||
0.0 otherwise
|
||||
|
||||
raw_score_p = Σ_j ( weight_j * band_score_j ) / Σ_j ( weight_j )
|
||||
|
||||
alignment_score_p = clamp(raw_score_p, 0.0, 1.0)
|
||||
```
|
||||
|
||||
`alignment_score_p` is a value in [0, 1]. Displayed in the `#yoga-panel` as an integer percentage (0–100) with one decimal place for the progress ring to animate smoothly.
|
||||
|
||||
**Hold-time component.** The classifier reports a pose as *completed* when two conditions are simultaneously true:
|
||||
1. The pose has been the argmax classifier output for a contiguous streak of `K = 6` frames (see §2.5).
|
||||
2. Within that streak, the alignment score has remained above 0.6 (60%) for at least `min_hold_s` seconds.
|
||||
|
||||
Completion is a one-shot event per pose per sequence pass. It fires once, advances the sequence indicator, and triggers the audible cue. The user must drop out of the pose and re-enter it to re-trigger completion — this prevents accidental re-completion during a rest pause.
|
||||
|
||||
**Why 60% as the hold threshold.** At 60%, the user's joint angles are within the tolerance band on the majority of weighted joints. A strict 80% threshold would frustrate beginners; a lenient 40% threshold would fire on casual near-misses. 60% is consistent with the threshold used in the Google ML Kit PoseClassifier sample and the Perez-Testor study's reported inter-practitioner variance (mean joint-angle SD of ~10° across joints, which maps to roughly a 30% score drop relative to a perfect practitioner on a 15° tolerance band).
|
||||
|
||||
**Why not include a velocity component (punish fast transitions).** Velocity would require a second derivative of the landmark positions, which is already noisy from MediaPipe jitter even after the one-euro filter. Minimum hold time (2–3 s) implicitly penalises rushing through poses without adding noise sensitivity.
|
||||
|
||||
### 2.5 Pose classification flow and debounce
|
||||
|
||||
Every frame, after `ingestPoseLandmarks()` populates `liveKp`:
|
||||
|
||||
```js
|
||||
function classifyPose() {
|
||||
if (!yogaMode.enabled || !liveValid) return;
|
||||
computeJointAngles(); // fills yogaMode.angles from liveKp
|
||||
for (const p of yogaMode.activePoses) {
|
||||
p.frameScore = scorePose(p); // per-frame alignment_score_p
|
||||
}
|
||||
const best = yogaMode.activePoses.reduce((a, b) =>
|
||||
b.frameScore > a.frameScore ? b : a
|
||||
);
|
||||
if (best.frameScore > SCORE_NO_POSE_FLOOR) {
|
||||
yogaMode.streak = (yogaMode.candidate === best.id)
|
||||
? yogaMode.streak + 1 : 1;
|
||||
yogaMode.candidate = best.id;
|
||||
} else {
|
||||
yogaMode.streak = 0;
|
||||
yogaMode.candidate = null;
|
||||
}
|
||||
if (yogaMode.streak >= K_FRAMES && yogaMode.candidate !== yogaMode.current) {
|
||||
yogaMode.current = yogaMode.candidate;
|
||||
onPoseTransition(yogaMode.current);
|
||||
}
|
||||
updateYogaHUD();
|
||||
}
|
||||
```
|
||||
|
||||
**K = 6 frames** (debounce depth). At 30 Hz this corresponds to a 200 ms lag from first matching pose to classification announcement. This is long enough to suppress a one-frame flicker from a mediocre landmark result but short enough to feel instantaneous to a human moving at yoga pace (typical transition speed: 1–3 seconds).
|
||||
|
||||
Lowering K to 3 creates flickering when the user is near a pose boundary. Raising K to 12 introduces a 400 ms lag that makes the HUD feel unresponsive on quick transitions (e.g. Uttanasana → Ardha Uttanasana takes ~1 second in a vigorous practice). K = 6 is the correct value given the ~30 Hz landmark update rate.
|
||||
|
||||
**SCORE_NO_POSE_FLOOR = 0.40.** If no pose scores above 40%, yoga-mode reports "no recognised pose" and does not transition. This prevents the classifier from latching onto the closest-matching pose during, say, walking across the room or sitting at a desk. At 40%, at least a plurality of the weighted joints must be within their tolerance band — a constraint that a non-yoga posture reliably fails.
|
||||
|
||||
### 2.6 UI surfaces
|
||||
|
||||
**Toggle in `#helpers` panel.** Added below the adam-mode row:
|
||||
|
||||
```html
|
||||
<label class="yoga-toggle">
|
||||
<input type="checkbox" id="yoga-mode-toggle">
|
||||
<span>yoga-mode (instructional)</span>
|
||||
<span class="swatch" style="color: var(--green)"></span>
|
||||
</label>
|
||||
```
|
||||
|
||||
yoga-mode is orthogonal to adam-mode: both can be active simultaneously. It uses `data-yoga="on"` on `<body>`, not `data-theme`. The attribute is distinct so that CSS selectors like `:root[data-theme="adam"]` and `:root[data-yoga="on"]` compose without conflict.
|
||||
|
||||
**`#yoga-panel` — bottom-centre overlay.** A new `<div id="yoga-panel" class="panel">` appears at the bottom centre of the viewport when yoga-mode is enabled. It is hidden (`display: none`) when yoga-mode is off, so it does not interfere with the existing layout.
|
||||
|
||||
The panel contains:
|
||||
|
||||
1. **Current pose name** — large (18px), Sanskrit name above English name below, amber colour. Falls back to "—" when no pose is recognised.
|
||||
2. **Alignment score ring** — a small SVG `<circle>` progress ring (r=22, stroke-dasharray) updating on every classified frame. Score 0–100 shown as integer inside the ring.
|
||||
3. **Hold-time progress bar** — a `<div class="bar-track">` identical in style to the CSI bars, filling from 0% to 100% as the hold-time accumulates. Resets on pose transition.
|
||||
4. **Instruction text** — one line from the current pose's `instruction` field, `font-size: 10px`, `color: var(--text-mute)`.
|
||||
5. **Visibility warning** — a `<span class="yoga-warn">` shown in `var(--red)` when `torso_not_visible` is true (see §2.7).
|
||||
|
||||
**Sequence strip — top-centre.** A horizontal strip of 8 thumbnail slots (`<div class="yoga-strip">`) spanning the top of the viewport (z-index above the titlecard, below `#info`). Each slot contains the pose's stage number and a 3-letter abbreviation (TAD, URD, UTT, ARD, CAT, UPD, DOG, UT2). Slots are styled:
|
||||
|
||||
- **Dimmed** (opacity 0.3, `var(--text-mute)` text) — not yet reached.
|
||||
- **Active** (opacity 1.0, `var(--amber)` border glow, pulsing) — current pose.
|
||||
- **Completed** (opacity 0.7, `var(--green)` checkmark `✓`, no glow) — held for `min_hold_s` seconds.
|
||||
|
||||
The strip does not scroll. Eight slots at ~90px each fit a 720px-wide viewport. On narrower screens the strip compresses gracefully because the slots use `flex: 1` within a `display: flex` container.
|
||||
|
||||
**Audible cue.** A single `<audio id="yoga-bell" src="data:audio/wav;base64,..." preload="auto">` element. The WAV is a 0.4-second C5 bell tone encoded inline as base64 (~12 KB). This preserves the single-file portability. It fires once on pose completion via `yogaBell.currentTime = 0; yogaBell.play()`. A `muted` toggle in `#helpers` (beneath the yoga-mode checkbox) allows the user to silence it: `<label><input type="checkbox" id="yoga-mute-toggle"> mute bell</label>`. The bell is muted by default (`yogaBell.muted = true`) to avoid startling first-time users.
|
||||
|
||||
**Theme compatibility.** `#yoga-panel` and the sequence strip use only existing custom properties: `var(--bg-panel)`, `var(--border)`, `var(--amber)`, `var(--amber-hot)`, `var(--text)`, `var(--text-mute)`, `var(--green)`, `var(--red)`. No new CSS variables are introduced. The panel therefore inherits both the default dark theme and adam-mode automatically — the same mechanism described in ADR-169 §2.1.
|
||||
|
||||
### 2.7 Camera / MediaPipe assumptions and graceful degradation
|
||||
|
||||
**Expected input:** front-facing camera, full body from head to ankles in frame, neutral indoor lighting. The demo's existing camera pipeline already requests `{ video: { facingMode: 'user', width: 640, height: 480 } }`. No change to the MediaPipe setup.
|
||||
|
||||
**Graceful degradation when body is partially out of frame.** MediaPipe assigns a `visibility` score in [0, 1] to each landmark. When a landmark's visibility drops below 0.35, yoga-mode treats that joint as missing:
|
||||
|
||||
```js
|
||||
function effectiveWeight(jointName, angles) {
|
||||
const vis = jointVisibility(jointName); // min visibility of the 3 landmarks
|
||||
if (vis < 0.35) return 0.0; // joint masked — not counted
|
||||
if (vis < 0.65) return angles.weight * (vis / 0.65); // partial weight
|
||||
return angles.weight;
|
||||
}
|
||||
```
|
||||
|
||||
When two or more of the high-weight joints (knees, hips, torso_lean) are masked simultaneously, `Σ_j(weight_j)` falls below a minimum viable total, and `alignment_score_p` is set to 0 regardless of the numerator. This prevents spurious high scores from a partially visible body where only one or two low-weight joints (e.g. elbows) are visible and happen to match a pose.
|
||||
|
||||
The `#yoga-panel` surfaces a `torso_not_visible` warning ("Move back — full body not in frame") in `var(--red)` whenever `liveVis[23] < 0.35 || liveVis[24] < 0.35` (left or right hip not visible). The hips are the reference joint for torso_lean and for hip-angle computation; their absence makes the entire classifier unreliable.
|
||||
|
||||
### 2.8 Cross-demo applicability
|
||||
|
||||
**yoga-mode ships in demo 05 only for the first iteration.** Demos 03 and 04 do not have a MediaPipe pipeline; there are no `liveKp` landmarks to score. Adding yoga-mode to them would require pulling in the entire MediaPipe Pose Heavy CDN script — changing those demos' character and load time.
|
||||
|
||||
**New demo: `06-yoga-mode.html`.** A new file `examples/three.js/demos/06-yoga-mode.html` is introduced as a slimmed-down variant of demo 05 where yoga-mode is the primary focus rather than an optional overlay. Differences from demo 05:
|
||||
|
||||
- The CSI panel (`#csi`) and the tomography sweep are hidden by default (`display: none`).
|
||||
- The `#yoga-panel` is expanded to a larger centre-screen layout with a bigger score ring (r=44) and larger pose name text (24px).
|
||||
- The sequence strip is rendered larger (100px slot width).
|
||||
- The `#helpers` panel shows only the yoga-related toggles (yoga-mode, adam-mode, mute bell).
|
||||
- The titlecard text reads "RuView · Yoga Mode".
|
||||
|
||||
This file is created from a copy of demo 05 with the CSI and tomography sections stripped. It shares the `YogaMode` object and pose templates verbatim — no logic is duplicated.
|
||||
|
||||
The decision to introduce a sixth demo file rather than making demo 05's yoga features more prominent is: demo 05 is a complete multi-feature demo (CSI + MediaPipe + IK retarget); demo 06 is a single-purpose instructional demo. Evaluators who want to show the yoga system without the RF sensing noise get demo 06.
|
||||
|
||||
### 2.9 Persistence
|
||||
|
||||
User settings are persisted in `localStorage` under the `ruview.yoga.*` namespace:
|
||||
|
||||
| Key | Type | Value shape | Default |
|
||||
|---|---|---|---|
|
||||
| `ruview.yoga.enabled` | boolean string | `"true"` or `"false"` | `"false"` |
|
||||
| `ruview.yoga.muted` | boolean string | `"true"` or `"false"` | `"true"` |
|
||||
| `ruview.yoga.tolerance_scale` | float string | `"0.5"` to `"2.0"` | `"1.0"` |
|
||||
| `ruview.yoga.sequence` | JSON string | `["tadasana","urdhva_hastasana",…]` | full 8-pose sequence |
|
||||
|
||||
`tolerance_scale` is a global multiplier applied to every `tolerance_deg` value in every template. A scale of 0.5 makes the classifier strict (tight bands); a scale of 2.0 makes it forgiving (wide bands). The HUD exposes this as a simple "Difficulty" slider: Easy (2.0×), Normal (1.0×), Strict (0.5×). The default is Normal.
|
||||
|
||||
`ruview.yoga.sequence` allows an operator to load a custom subset or reordering of the 8 poses, or to load additional poses added via `YogaMode.addPose()`. The array contains pose `id` strings. On load, yoga-mode resolves each ID against the registered template map; unknown IDs are skipped with a console warning.
|
||||
|
||||
All `localStorage` accesses are wrapped in try/catch to handle privacy-restricted origins.
|
||||
|
||||
### 2.10 JS API surface
|
||||
|
||||
yoga-mode exposes a clean internal module object. Because the demo is a single-file HTML with no ES module bundler, the pattern is a plain object literal assigned to a local `const`:
|
||||
|
||||
```js
|
||||
const YogaMode = {
|
||||
// ---- Lifecycle ----
|
||||
init(opts = {}) {}, // wire up UI, register pose templates, restore localStorage
|
||||
enable() {}, // set data-yoga="on", show #yoga-panel, start classifying
|
||||
disable() {}, // remove data-yoga="on", hide #yoga-panel, reset state
|
||||
|
||||
// ---- Classification callbacks ----
|
||||
onPoseChanged(cb) {}, // cb(poseId: string | null) — fires on confirmed transition
|
||||
onPoseScored(cb) {}, // cb(scores: {[poseId]: number}) — fires every frame
|
||||
onPoseCompleted(cb) {}, // cb(poseId: string, holdMs: number) — fires on hold completion
|
||||
|
||||
// ---- Template management ----
|
||||
addPose(template) {}, // validate and register a custom pose template
|
||||
removePose(id) {}, // remove a template by id (built-ins can be removed)
|
||||
poses() {}, // returns Array<PoseTemplate> — current registered set
|
||||
|
||||
// ---- State accessors ----
|
||||
currentPose() {}, // returns current confirmed pose id or null
|
||||
currentScore() {}, // returns alignment score [0,1] of current pose or 0
|
||||
angles() {}, // returns the latest computed joint angles object
|
||||
|
||||
// ---- Sequence control ----
|
||||
resetSequence() {}, // clears all completion state, restarts from stage 1
|
||||
setSequence(ids) {}, // replace active sequence with a custom id array
|
||||
|
||||
// Internal state — not part of the public API:
|
||||
_state: { enabled, candidate, current, streak, holdStart, completedSet }
|
||||
};
|
||||
```
|
||||
|
||||
`onPoseChanged`, `onPoseScored`, and `onPoseCompleted` follow the same pattern as the demo's existing event hooks: they register a single callback (last-writer wins, not an array). This is sufficient for a single-file demo where there is at most one consumer per event. A future multi-listener pattern would need a `listeners` array; that is out of scope.
|
||||
|
||||
`addPose(template)` validates the template schema before registering it. A template missing `joint_targets` or with an `id` that contains non-alphanumeric characters is rejected with a `console.error` and returns `false`. Valid templates return `true`.
|
||||
|
||||
### 2.11 Pose templates — Sun Salutation A joint targets
|
||||
|
||||
The full 8-pose template set. Angle targets are derived from Perez-Testor et al. (2019) Vicon measurements and Iyengar alignment geometry. Tolerances are set to twice the reported inter-practitioner SD (~10°) rounded to the nearest 5°, then scaled by the user's `tolerance_scale`.
|
||||
|
||||
**Stage 1 — Tāḍāsana (Mountain Pose)**
|
||||
|
||||
All joints extended. Body in anatomical position. Baseline for comparison.
|
||||
|
||||
```js
|
||||
{ id: "tadasana", name_en: "Mountain Pose", name_sa: "Tāḍāsana", stage: 1,
|
||||
min_hold_s: 3,
|
||||
joint_targets: {
|
||||
left_knee: { angle_deg: 180, tolerance_deg: 10, weight: 1.0 },
|
||||
right_knee: { angle_deg: 180, tolerance_deg: 10, weight: 1.0 },
|
||||
left_hip: { angle_deg: 180, tolerance_deg: 12, weight: 0.8 },
|
||||
right_hip: { angle_deg: 180, tolerance_deg: 12, weight: 0.8 },
|
||||
torso_lean: { angle_deg: 0, tolerance_deg: 10, weight: 1.2 },
|
||||
left_elbow: { angle_deg: 180, tolerance_deg: 20, weight: 0.4 },
|
||||
right_elbow: { angle_deg: 180, tolerance_deg: 20, weight: 0.4 },
|
||||
},
|
||||
instruction: "Stand tall. Feet hip-width, weight even. Arms at sides. Lengthen through the crown.",
|
||||
}
|
||||
```
|
||||
|
||||
**Stage 2 — Ūrdhva Hastāsana (Upward Salute)**
|
||||
|
||||
Arms sweep overhead. Shoulders maximally abducted. Distinguishing feature: both elbows extended and arms overhead (shoulder angle approaches 180° abduction). Perez-Testor reports shoulder elevation of 183° at peak overhead position.
|
||||
|
||||
```js
|
||||
{ id: "urdhva_hastasana", name_en: "Upward Salute", name_sa: "Ūrdhva Hastāsana", stage: 2,
|
||||
min_hold_s: 2,
|
||||
joint_targets: {
|
||||
left_shoulder: { angle_deg: 165, tolerance_deg: 20, weight: 1.2 },
|
||||
right_shoulder: { angle_deg: 165, tolerance_deg: 20, weight: 1.2 },
|
||||
left_elbow: { angle_deg: 180, tolerance_deg: 15, weight: 0.8 },
|
||||
right_elbow: { angle_deg: 180, tolerance_deg: 15, weight: 0.8 },
|
||||
left_knee: { angle_deg: 180, tolerance_deg: 12, weight: 0.8 },
|
||||
right_knee: { angle_deg: 180, tolerance_deg: 12, weight: 0.8 },
|
||||
torso_lean: { angle_deg: 0, tolerance_deg: 15, weight: 0.7 },
|
||||
},
|
||||
instruction: "Inhale. Sweep arms overhead. Palms face each other. Gaze forward or slightly up.",
|
||||
}
|
||||
```
|
||||
|
||||
**Stage 3 — Uttānāsana (Standing Forward Fold)**
|
||||
|
||||
Deep hip flexion. Torso approaches vertical-inverted. Perez-Testor reports hip flexion of 134°. The angle at the hip joint as computed by our triplet (shoulder→hip→knee) goes to ~30° as the torso folds toward the legs. Knees remain extended.
|
||||
|
||||
```js
|
||||
{ id: "uttanasana", name_en: "Standing Forward Fold", name_sa: "Uttānāsana", stage: 3,
|
||||
min_hold_s: 3,
|
||||
joint_targets: {
|
||||
left_hip: { angle_deg: 40, tolerance_deg: 25, weight: 1.2 },
|
||||
right_hip: { angle_deg: 40, tolerance_deg: 25, weight: 1.2 },
|
||||
left_knee: { angle_deg: 175, tolerance_deg: 15, weight: 1.0 },
|
||||
right_knee: { angle_deg: 175, tolerance_deg: 15, weight: 1.0 },
|
||||
torso_lean: { angle_deg: 85, tolerance_deg: 20, weight: 1.0 },
|
||||
},
|
||||
instruction: "Exhale. Fold forward from the hips. Let the crown of the head drop toward the floor.",
|
||||
}
|
||||
```
|
||||
|
||||
**Stage 4 — Ardha Uttānāsana (Half Lift / Flat-Back)**
|
||||
|
||||
Torso lifts to horizontal. Hip angle opens to ~90°. Spine neutral. This is the most distinctive pose for classification: it is the only one where the torso is neither upright nor fully folded — the `torso_lean` angle is ~90° and the hips are also ~90°. Perez-Testor reports the half-lift as an intermediate transition posture; the distinguishing cue is the simultaneous hip angle and spine neutral (not flexed).
|
||||
|
||||
```js
|
||||
{ id: "ardha_uttanasana", name_en: "Half Lift", name_sa: "Ardha Uttānāsana", stage: 4,
|
||||
min_hold_s: 2,
|
||||
joint_targets: {
|
||||
left_hip: { angle_deg: 90, tolerance_deg: 20, weight: 1.2 },
|
||||
right_hip: { angle_deg: 90, tolerance_deg: 20, weight: 1.2 },
|
||||
left_knee: { angle_deg: 175, tolerance_deg: 12, weight: 0.8 },
|
||||
right_knee: { angle_deg: 175, tolerance_deg: 12, weight: 0.8 },
|
||||
torso_lean: { angle_deg: 90, tolerance_deg: 15, weight: 1.2 },
|
||||
left_elbow: { angle_deg: 180, tolerance_deg: 20, weight: 0.5 },
|
||||
right_elbow: { angle_deg: 180, tolerance_deg: 20, weight: 0.5 },
|
||||
},
|
||||
instruction: "Inhale. Lift the chest. Flat back. Fingertips on the shins or floor. Gaze forward.",
|
||||
}
|
||||
```
|
||||
|
||||
**Stage 5 — Catvāri / Chaturanga Daṇḍāsana (Four-Limbed Staff)**
|
||||
|
||||
Plank lowered. Elbows at 90°. Body horizontal. This is the hardest pose to classify from a front-facing camera alone: the body is horizontal and the depth axis is ambiguous. The key discriminator is `elbow_angle ≈ 90°` combined with `hip ≈ 180°` (no flexion) and `torso_lean ≈ 90°`. Note: from a front-facing camera, a person in Chaturanga facing the camera appears foreshortened. yoga-mode accepts this limitation and primarily tracks Chaturanga as the transition between Ardha Uttanasana and Upward Dog in the sequence, with lower weight on spatial cues and higher weight on elbow angle. Iyengar geometry specifies elbows at 90° against the body.
|
||||
|
||||
```js
|
||||
{ id: "chaturanga", name_en: "Four-Limbed Staff", name_sa: "Catvāri / Chaturanga Daṇḍāsana", stage: 5,
|
||||
min_hold_s: 2,
|
||||
joint_targets: {
|
||||
left_elbow: { angle_deg: 90, tolerance_deg: 20, weight: 1.5 },
|
||||
right_elbow: { angle_deg: 90, tolerance_deg: 20, weight: 1.5 },
|
||||
left_hip: { angle_deg: 175, tolerance_deg: 15, weight: 0.8 },
|
||||
right_hip: { angle_deg: 175, tolerance_deg: 15, weight: 0.8 },
|
||||
left_knee: { angle_deg: 175, tolerance_deg: 15, weight: 0.6 },
|
||||
right_knee: { angle_deg: 175, tolerance_deg: 15, weight: 0.6 },
|
||||
torso_lean: { angle_deg: 90, tolerance_deg: 20, weight: 0.7 },
|
||||
},
|
||||
instruction: "Lower down. Elbows at 90°, hugged to the ribs. Body in one straight line.",
|
||||
}
|
||||
```
|
||||
|
||||
**Stage 6 — Ūrdhva Mukha Śvānāsana (Upward-Facing Dog)**
|
||||
|
||||
Hips extend, spine extends (backbend), shoulders over wrists, knees off floor. Distinguishing feature: hips are near 160–180° (extended), which is the opposite of Uttanasana's deep flexion. The `torso_lean` reverses from ~90° horizontal to approaching 0° or slightly past vertical (slight backbend). Perez-Testor's spine extension of 44° is the reference for the backbend component; the hip angle opens to near-full extension.
|
||||
|
||||
```js
|
||||
{ id: "urdhva_mukha_svanasana", name_en: "Upward-Facing Dog", name_sa: "Ūrdhva Mukha Śvānāsana", stage: 6,
|
||||
min_hold_s: 2,
|
||||
joint_targets: {
|
||||
left_hip: { angle_deg: 165, tolerance_deg: 20, weight: 1.2 },
|
||||
right_hip: { angle_deg: 165, tolerance_deg: 20, weight: 1.2 },
|
||||
left_elbow: { angle_deg: 170, tolerance_deg: 20, weight: 0.8 },
|
||||
right_elbow: { angle_deg: 170, tolerance_deg: 20, weight: 0.8 },
|
||||
left_knee: { angle_deg: 170, tolerance_deg: 20, weight: 0.6 },
|
||||
right_knee: { angle_deg: 170, tolerance_deg: 20, weight: 0.6 },
|
||||
torso_lean: { angle_deg: 15, tolerance_deg: 20, weight: 0.8 },
|
||||
},
|
||||
instruction: "Press the tops of the feet down. Lift the chest. Shoulders away from the ears. Gaze forward.",
|
||||
}
|
||||
```
|
||||
|
||||
**Stage 7 — Adho Mukha Śvānāsana (Downward-Facing Dog)**
|
||||
|
||||
Hips high. Inverted V. The most geometrically distinct pose in the sequence: high hips, extended knees, arms overhead-ish (shoulder angle ~150° relative to torso), torso_lean ~90° but in the opposite direction to Chaturanga (body weight shifted back over the heels). The hip angle as measured by our shoulder→hip→knee triplet is ~80–110° (the pelvis is high, creating a roughly right-angle fold at the hip). Perez-Testor reports the hip-angle transition from Chaturanga to Downward Dog as the largest single-frame angle change in the sequence (~120° excursion), making it the easiest pose to classify correctly.
|
||||
|
||||
```js
|
||||
{ id: "adho_mukha_svanasana", name_en: "Downward-Facing Dog", name_sa: "Adho Mukha Śvānāsana", stage: 7,
|
||||
min_hold_s: 5,
|
||||
joint_targets: {
|
||||
left_hip: { angle_deg: 90, tolerance_deg: 25, weight: 1.2 },
|
||||
right_hip: { angle_deg: 90, tolerance_deg: 25, weight: 1.2 },
|
||||
left_knee: { angle_deg: 180, tolerance_deg: 15, weight: 1.0 },
|
||||
right_knee: { angle_deg: 180, tolerance_deg: 15, weight: 1.0 },
|
||||
left_shoulder: { angle_deg: 150, tolerance_deg: 25, weight: 0.8 },
|
||||
right_shoulder: { angle_deg: 150, tolerance_deg: 25, weight: 0.8 },
|
||||
torso_lean: { angle_deg: 90, tolerance_deg: 20, weight: 0.7 },
|
||||
},
|
||||
instruction: "Hips up and back. Heels reaching toward the floor. Arms and ears in one line. Breathe.",
|
||||
}
|
||||
```
|
||||
|
||||
**Stage 8 — Uttānāsana (Standing Forward Fold, return)**
|
||||
|
||||
Identical to stage 3 in geometry. Classified as stage 8 for sequence-tracking purposes only — same template joint targets, different `id` and `stage` value.
|
||||
|
||||
```js
|
||||
{ id: "uttanasana_return", name_en: "Standing Forward Fold (return)", name_sa: "Uttānāsana", stage: 8,
|
||||
min_hold_s: 2,
|
||||
joint_targets: { /* same as stage 3 */ },
|
||||
instruction: "Step or jump to the front. Exhale. Release the head. Return to stillness.",
|
||||
}
|
||||
```
|
||||
|
||||
Distinguishing stages 3 and 8 is handled by the sequence-tracking layer, not by the classifier. If yoga-mode is in stage 7 (Downward Dog) and detects a forward-fold shape, it advances to stage 8 rather than regressing to stage 3. If yoga-mode is in stages 1–2 and detects a forward-fold shape, it advances to stage 3. The sequence tracks forward direction only; there is no backward regression in the first implementation.
|
||||
|
||||
### 2.12 Test plan
|
||||
|
||||
**Manual — live camera:**
|
||||
Stand in front of the workstation USB camera (ruvzen, confirmed front-facing in CLAUDE.local.md). Enable yoga-mode from `#helpers`. Cycle through all 8 poses in order. For each pose: verify the HUD shows the correct Sanskrit and English name within 2 frames (~67 ms) of entering the pose, the alignment score exceeds 60%, and the sequence strip advances. Verify no pose is misclassified when standing in a casual at-rest position (score should be below 40% floor for all 8 poses).
|
||||
|
||||
**Synthetic — test mode triggered by `?test=1` URL parameter:**
|
||||
When `location.search` includes `test=1`, yoga-mode enters a headless test mode: instead of reading from `liveKp`, it reads from a pre-recorded `YOGA_TEST_FIXTURES` object — one synthetic landmark array per pose, generated at authoring time by capturing the real `liveKp` values during a manual demo session.
|
||||
|
||||
```js
|
||||
if (new URLSearchParams(location.search).has('test')) {
|
||||
for (const fixture of YOGA_TEST_FIXTURES) {
|
||||
ingestPoseLandmarks(fixture.landmarks);
|
||||
classifyPose();
|
||||
const result = YogaMode.currentPose();
|
||||
console.assert(result === fixture.expected_id,
|
||||
`FAIL: ${fixture.expected_id} got ${result}`);
|
||||
}
|
||||
console.log('YogaMode tests complete');
|
||||
}
|
||||
```
|
||||
|
||||
The fixture set is 8 entries (one per pose). Each entry is a hard-coded `landmarks` array of 33 objects with `{x, y, z, visibility}` values. These fixtures are inlined in the `<script>` block, gated behind `if (urlParams.has('test'))` so they are never executed in normal operation.
|
||||
|
||||
**Negative test:** A ninth fixture entry with the user standing in a neutral at-rest position (arms at sides but knees slightly bent, casual posture — not a yoga pose). Assert `YogaMode.currentPose() === null` (no pose above the 0.40 floor).
|
||||
|
||||
**Regression guard for joint-angle computation:** A tenth fixture that hard-codes known landmark positions forming a right angle at the left knee (three points forming a precise 90° angle). Assert `YogaMode.angles().left_knee` is within ±0.5° of 90.
|
||||
|
||||
### 2.13 Rejected alternatives
|
||||
|
||||
| Alternative | Rejected because |
|
||||
|---|---|
|
||||
| Train a custom MLP on a labelled yoga dataset | No labelled dataset in this codebase. Training requires hundreds of examples per class, a train/test pipeline, and a serialized model file — all incompatible with a single-file demo. Joint-angle matching achieves equivalent discrimination for 8 geometrically distinct poses with zero training data. |
|
||||
| Use a paid SaaS pose-classification API (e.g. a commercial yoga scoring cloud service) | Introduces an external network dependency, a per-request cost, and a privacy concern (camera frames leaving the browser). Pure client-side is a hard requirement. |
|
||||
| Ship audio/video instructional content (video of an instructor demonstrating each pose) | Massively increases the demo's asset footprint. A single instructor video per pose at 15 fps, 10 seconds, compressed, is ~500 KB × 8 = 4 MB minimum. The inline base64 bell (~12 KB) is the correct granularity of embedded media for this demo. |
|
||||
| Ship a backend yoga-tracking session record (store per-session completion data to a server) | No backend endpoint exists or is planned for the demos. Client-only; persistence via `localStorage`. |
|
||||
| Integrate with the Rust `pose_tracker.rs` now | Convention mismatch (BlazePose-33 vs COCO-17) documented in §1.3 but the cost of bridging it outweighs the benefit for a demo. The bridge is deferred: yoga-mode in JS is valuable without it. Rust integration becomes tractable once a WebSocket protocol for streaming joint angles (not raw CSI) from the sensing server is defined — a separate ADR. |
|
||||
| Use MediaPipe Tasks `PoseLandmarker` with a built-in `PoseClassifier` task | The Tasks API requires loading a `.task` bundle (~4 MB) from CDN at runtime. Demo 05 already uses the older `@mediapipe/pose@0.5` CDN script; switching APIs would require rewriting the entire landmark ingest pipeline. The classifier task is a black box undebuggable in DevTools. Threshold matching is fully transparent. |
|
||||
| Put yoga-mode on `data-theme` alongside adam-mode | yoga-mode is not a theme — it is a feature toggle. Mixing it with the theme attribute would prevent simultaneous adam-mode + yoga-mode activation and would conflate presentation with functionality. Separate `data-yoga="on"` attribute is the correct model. |
|
||||
|
||||
---
|
||||
|
||||
## 3. Consequences
|
||||
|
||||
### 3.1 Positive
|
||||
|
||||
- The retargeting pipeline in demo 05 gains a per-pose regression test harness (`?test=1`) at no additional tooling cost.
|
||||
- yoga-mode operates on the existing `liveKp` stream — zero additional CPU cost beyond a few arctangent calls per frame (~50 µs at 30 Hz).
|
||||
- The pose-scoring formula is fully deterministic and inspectable: `console.log(YogaMode.angles())` in DevTools shows every joint angle on every frame.
|
||||
- Demo 06 provides a clean instructional-first presentation that separates yoga-mode from the RF sensing visualisations, making the feature accessible to a fitness-context audience.
|
||||
- The `YogaMode.addPose()` API allows operators to extend the template library without touching core logic — enabling future pose sets (Warrior series, Yin postures) as a follow-on.
|
||||
- The `tolerance_scale` persistence allows the same demo codebase to serve both beginners (2× tolerance) and experienced practitioners (0.5× tolerance) without code changes.
|
||||
|
||||
### 3.2 Negative
|
||||
|
||||
- Two HTML files to maintain (`05` and `06`) where previously there was one. Mitigated by the fact that yoga-mode logic is identical between them — demo 06 is a layout variant, not a code fork.
|
||||
- Chaturanga Dandasana classification is inherently degraded from a front-facing camera (the body is horizontal; the depth axis is ambiguous). The classifier can detect the pose if the user faces the camera sideways (profile view), but the existing camera setup on ruvzen is front-facing. This is a known limitation, documented in the instruction text ("face the camera from the side for best Chaturanga detection").
|
||||
- The inline base64 bell WAV adds ~12 KB to the HTML file size. Negligible at the scale of the demo but noted.
|
||||
- `localStorage` namespace `ruview.yoga.*` adds four keys per origin. No conflict with `ruview.theme` from adam-mode.
|
||||
|
||||
### 3.3 Risks
|
||||
|
||||
| Risk | Likelihood | Mitigation |
|
||||
|---|---|---|
|
||||
| MediaPipe visibility scores are unreliable for floor-level landmarks (ankles, feet) during Dog poses | Medium | `effectiveWeight()` already masks low-visibility joints; Dog-pose templates weight knees (visible) more than ankles (may be occluded). |
|
||||
| The `?test=1` fixture landmarks become stale if the coordinate-space transform in `ingestPoseLandmarks()` changes | Low | Fixtures store raw `liveKp` world-space values, not normalized MediaPipe coords. If `ingestPoseLandmarks()` changes its output schema, the fixtures will produce obviously wrong joint angles in the assertion step — the failure is loud, not silent. |
|
||||
| Sequence-strip animation (CSS pulsing glow on the active stage) triggers repaint on every frame at 30 Hz | Low | The pulse is a CSS `animation` on `opacity` — composited by the GPU, no layout reflow. Negligible cost. |
|
||||
| User's camera position cuts off the hips (e.g. laptop on a desk) — `torso_not_visible` fires immediately | High for laptop use | The warning instructs the user to step back. This is the correct behaviour. Future: add a "camera too close" heuristic based on the ratio of shoulder distance to image width. |
|
||||
| Stage 8 (Uttanasana return) is classified identically to stage 3 by the angle classifier alone — the sequence layer must correctly disambiguate them | Medium | The sequence-tracking layer uses monotonic forward-only progression. Stage 3 can only fire when the current sequence position is 2 (after Urdhva Hastasana); stage 8 can only fire when the current sequence position is 7 (after Downward Dog). The classifier produces the angle score; the sequence layer decides which stage to credit. If the user skips a pose, the sequence layer waits — it does not leap to stage 8 from stage 2 even if a forward-fold shape is detected. |
|
||||
|
||||
---
|
||||
|
||||
## 4. Implementation plan
|
||||
|
||||
Moderate scope — two HTML files, no build step, no new external dependencies.
|
||||
|
||||
1. **Define the `YOGA_POSES` array** — 8 template objects as specified in §2.11, inline in the `<script>` block of demo 05.
|
||||
2. **Implement `computeJointAngles()`** — read from the existing `liveKp` array, fill a `yogaAngles` object using the 9 joint triplets in §2.2.
|
||||
3. **Implement `scorePose(template)`** — the weighted-sum formula from §2.4, respecting `effectiveWeight()` for visibility masking.
|
||||
4. **Implement `classifyPose()`** — argmax with K=6 debounce as in §2.5; call from the existing `requestAnimationFrame` loop after `applyRetargeting()`.
|
||||
5. **Add `#yoga-panel` markup and CSS** — bottom-centre panel, score ring, hold-time bar, instruction text, visibility warning. All styles via existing custom properties.
|
||||
6. **Add the sequence strip** — `#yoga-strip` top-centre, 8 flex slots, 3-state styling (dimmed/active/completed).
|
||||
7. **Wire the `#helpers` toggle** — `yoga-mode-toggle` checkbox and `yoga-mute-toggle` checkbox; `localStorage` persistence.
|
||||
8. **Add `YogaMode` object** — wrapping steps 1–7 with the API surface from §2.10.
|
||||
9. **Add `YOGA_TEST_FIXTURES` and the `?test=1` harness** — 10 fixture entries (8 positive, 1 negative, 1 angle-computation).
|
||||
10. **Create `06-yoga-mode.html`** — copy of demo 05 with CSI/tomography sections hidden, larger yoga panel layout.
|
||||
11. **Manual validation** — stand in front of ruvzen camera, cycle all 8 poses, verify classification and sequence advancement.
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- All 8 poses classified correctly in the `?test=1` synthetic harness (assertions pass with no console errors).
|
||||
- The negative fixture (casual stand) produces `currentPose() === null`.
|
||||
- The angle-computation fixture (`left_knee` at a known 90°) asserts within ±0.5°.
|
||||
- Manual: each of the 8 Sun Salutation A poses classified within 2 frames when held correctly.
|
||||
- Alignment score exceeds 60% when the user matches the pose by self-assessment.
|
||||
- Sequence strip advances in order; completed poses show green checkmark.
|
||||
- Bell fires on completion (when unmuted).
|
||||
- adam-mode + yoga-mode simultaneously active: both panels visible, correct theme.
|
||||
- `localStorage` persists enabled-state and tolerance-scale across page reloads.
|
||||
|
||||
---
|
||||
|
||||
## 5. Related ADRs
|
||||
|
||||
| ADR | Relationship |
|
||||
|---|---|
|
||||
| [ADR-169](ADR-169-adam-mode-light-theme.md) | Sibling demo-side feature. yoga-mode toggle lives in the same `#helpers` panel. Both are orthogonal and must compose. |
|
||||
| [ADR-019](ADR-019-sensing-only-ui-mode.md) | Sensing-only UI — yoga-mode is the opposite: camera-first, sensing secondary. |
|
||||
| [ADR-035](ADR-035-live-sensing-ui-accuracy.md) | Live sensing UI accuracy norms. yoga-mode scores the user's body against templates, not CSI accuracy — but the same principle of not misrepresenting measurement quality applies. |
|
||||
| [ADR-014](ADR-014-sota-signal-processing.md) | The Rust-side `gesture.rs` uses DTW for gesture recognition. yoga-mode explicitly rejects DTW for static pose classification (§2.2). The two systems are complementary: DTW for motion gestures, angle-threshold for static poses. |
|
||||
| [ADR-029](ADR-029-ruvsense-multistatic-sensing-mode.md) | The Rust `pose_tracker.rs` (COCO-17) that yoga-mode defers integrating with. The COCO↔BlazePose mapping in §1.3 is the foundation for the future bridge. |
|
||||
|
||||
---
|
||||
|
||||
## 6. References
|
||||
|
||||
### Production code
|
||||
- `examples/three.js/demos/05-skinned-realtime.html` — primary implementation target; `liveKp`, `liveVis`, `ingestPoseLandmarks()`, `#helpers`, `#pose-panel`, `RETARGETS`, `visForRetarget()` are all anchors for yoga-mode integration
|
||||
- `examples/three.js/demos/04-skinned-fbx.html` — sibling demo; lighting reference
|
||||
- `v2/crates/wifi-densepose-signal/src/ruvsense/pose_tracker.rs` — Rust COCO-17 tracker; convention mapping in §1.3 of this ADR targets this module
|
||||
|
||||
### External references
|
||||
|
||||
1. **Perez-Testor, S. et al. (2019).** "Kinematics of Suryanamaskar Using Three-Dimensional Motion Capture." *PMC6521759*. 10 trained practitioners, 12-camera Vicon, 100 Hz, sagittal-plane joint angles for each of the 12 standard Surya Namaskar positions. Primary source for angle targets and tolerance bounds in §2.11.
|
||||
|
||||
2. **Chidamber, S. and Harikumar, K. (2023).** "A novel approach for yoga pose estimation based on in-depth analysis of human body joint detection accuracy." *PMC10280249*. Validates joint-angle threshold matching as the dominant reliable real-time method for small-to-medium yoga pose sets; reports average inter-joint angle error of 10.017° across six common daily poses — the empirical basis for the ±10–25° tolerance bands in the templates.
|
||||
|
||||
3. **Lugaresi, C. et al. (2020 / MediaPipe team).** "On-device, Real-time Body Pose Tracking with MediaPipe BlazePose." Google Research Blog and arXiv:2006.10204. Defines the 33-landmark BlazePose topology used throughout §1.3 and §2.2. Confirms the landmark visibility score semantics used in §2.7.
|
||||
|
||||
4. **Google ML Kit team.** "Pose classification options." developers.google.com/ml-kit/vision/pose-detection/classifying-poses. Documents the `PoseClassifier` EfficientNet approach that this ADR rejects in §2.13; the 60% alignment threshold in §2.4 is consistent with the sample thresholds in this guide.
|
||||
|
||||
5. **Iyengar, B.K.S. (2001).** *Light on Yoga* (Schocken Books, revised edition). Chaturanga Dandasana description pp. 102–104: "elbows at right angles along the body" — the 90° elbow target for stage 5. Tadasana pp. 61–63: anatomical position as baseline. The Iyengar descriptions supply angle targets where Perez-Testor's Vicon study does not explicitly report a joint.
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
# ADR-149: Drone Swarm Benchmarking & Evaluation Methodology — Metrics, Leaderboards, and Statistical Rigor
|
||||
# ADR-171: Drone Swarm Benchmarking & Evaluation Methodology — Metrics, Leaderboards, and Statistical Rigor
|
||||
|
||||
| Field | Value |
|
||||
|------------|-----------------------------------------------------------------------------------------|
|
||||
+2
-2
@@ -97,8 +97,8 @@ Statuses: **Proposed** (under discussion), **Accepted** (approved and/or impleme
|
||||
| [ADR-036](ADR-036-rvf-training-pipeline-ui.md) | Training Pipeline UI Integration | Proposed |
|
||||
| [ADR-043](ADR-043-sensing-server-ui-api-completion.md) | Sensing Server UI API Completion (14 endpoints) | Accepted |
|
||||
| [ADR-115](ADR-115-home-assistant-integration.md) | Home Assistant integration via MQTT auto-discovery + Matter bridge (HA-DISCO + HA-FABRIC + HA-MIND) | Accepted (MQTT track) / Proposed (Matter SDK P8b) |
|
||||
| [ADR-147](ADR-147-adam-mode-light-theme.md) | adam-mode — light theme toggle for the three.js realtime demo | Proposed |
|
||||
| [ADR-148](ADR-148-yoga-mode-pose-system.md) | yoga-mode — yoga pose detection, classification, and scoring for the three.js realtime demo | Proposed |
|
||||
| [ADR-169](ADR-169-adam-mode-light-theme.md) | adam-mode — light theme toggle for the three.js realtime demo | Proposed |
|
||||
| [ADR-170](ADR-170-yoga-mode-pose-system.md) | yoga-mode — yoga pose detection, classification, and scoring for the three.js realtime demo | Proposed |
|
||||
|
||||
### Architecture and infrastructure
|
||||
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
# ADR Corpus Census
|
||||
|
||||
Full per-ADR census underpinning ADR-164. **162 ADR entries across 156 distinct files** (the 5 duplicate-number collisions / 6 displaced files have been RESOLVED — displaced files renumbered to ADR-166…171 per ADR-164 G1; the ADR-134 identity split is tracked separately under G3). Source of truth for the gap-analysis lenses. Where the census is uncertain it is marked *needs verification*.
|
||||
|
||||
| ADR | Title | Status | impl_state | Flags |
|
||||
|-----|-------|--------|-----------|-------|
|
||||
| ADR-001 | WiFi-Mat Disaster Detection Architecture | Accepted | implemented | data/hardware-gated (rubble-penetration unproven without field hardware) |
|
||||
| ADR-002 | RuVector RVF Integration Strategy | Superseded by ADR-016 + ADR-017 | superseded | umbrella ADR; child ADRs 003/007/008/009/010 still Proposed |
|
||||
| ADR-003 | RVF Cognitive Containers for CSI Data | Proposed | proposed-only | proposed-but-looks-abandoned (parent 002 superseded, never advanced) |
|
||||
| ADR-004 | HNSW Vector Search for Signal Fingerprinting | Partially realized by ADR-024; extended by ADR-027 | partial | realized indirectly via downstream ADRs, not directly |
|
||||
| ADR-005 | SONA Self-Learning Pose Estimation | Partially realized in ADR-023; extended by ADR-027 | partial | realized indirectly via ADR-023 (MicroLoRA/EWC++) |
|
||||
| ADR-006 | GNN-Enhanced CSI Pattern Recognition | Partially realized in ADR-023; extended by ADR-027 | partial | realized indirectly via ADR-023 (2-layer GCN), scope narrowed |
|
||||
| ADR-007 | Post-Quantum Cryptography for Secure Sensing | Proposed | proposed-only | proposed-but-looks-abandoned (parent 002 superseded) |
|
||||
| ADR-008 | Distributed Consensus for Multi-AP | Proposed | proposed-only | proposed-but-looks-abandoned (parent 002 superseded) |
|
||||
| ADR-009 | RVF WASM Runtime for Edge Deployment | Proposed | proposed-only | contradicts shipped wifi-densepose-wasm crate it proposes to replace |
|
||||
| ADR-010 | Witness Chains for Audit-Trail Integrity | Proposed | proposed-only | witness-bundle (ADR-028) fills this role instead |
|
||||
| ADR-011 | Python Proof-of-Reality / Mock Elimination | Proposed (URGENT) | partial | proof pipeline (verify.py/ADR-028) live despite Proposed status; credibility-gated |
|
||||
| ADR-012 | ESP32 CSI Sensor Mesh | Accepted — Partially Implemented | partial | hardware-gated; mesh partial, single-node firmware working per ADR-018 |
|
||||
| ADR-013 | Feature-Level Sensing on Commodity Gear | Accepted — Implemented (36/36 tests) | implemented | — |
|
||||
| ADR-014 | SOTA Signal Processing | Accepted | implemented | — |
|
||||
| ADR-015 | Public Dataset Training Strategy | Accepted | implemented | data-gated (MM-Fi/Wi-Pose availability/licensing) |
|
||||
| ADR-016 | RuVector Training-Pipeline Integration | Accepted | implemented | supersedes ADR-002 (but file never mentions 002 — unsupported claim) |
|
||||
| ADR-017 | RuVector Signal + MAT Integration | Accepted | implemented | CLAUDE.md still lists as Proposed; supersedes 002 only via "Correction" prose |
|
||||
| ADR-018 | ESP32 Dev Implementation | Proposed | partial | status stale — ADR-012 cites it as working firmware/aggregator |
|
||||
| ADR-019 | Sensing-Only UI Mode with Gaussian Splat Viz | Accepted | implemented | status in table format not ## header |
|
||||
| ADR-020 | Migrate AI/Model Inference to Rust (RuVector + ONNX) | Accepted | partial | table-format status; overlaps ADR-019 backend-decoupling scope |
|
||||
| ADR-021 | Vital Sign Detection via rvdna Pipeline | Partially Implemented | partial | wifi-densepose-vitals crate exists |
|
||||
| ADR-022 | Enhanced Windows WiFi Fidelity via Multi-BSSID | Partially Implemented | partial | wifi-densepose-wifiscan crate exists |
|
||||
| ADR-023 | Trained DensePose Model w/ RuVector Signal Intelligence | Proposed | proposed-only | data/hardware-gated; scaffold w/ random weights |
|
||||
| ADR-024 | Project AETHER — Contrastive CSI Embedding | Proposed | proposed-only | CLAUDE.md lists Accepted; pose_tracker.rs uses AETHER re-ID — contradiction |
|
||||
| ADR-025 | macOS CoreWLAN WiFi Sensing (ORCA) | Proposed | proposed-only | hardware-gated (Mac Mini M2 Pro); RSSI-only |
|
||||
| ADR-026 | Survivor Track Lifecycle Management (MAT) | Accepted | implemented | explicit Supersedes: None |
|
||||
| ADR-027 | Project MERIDIAN — Cross-Env Domain Generalization | Proposed | proposed-only | CLAUDE.md lists Accepted — contradiction; data-gated |
|
||||
| ADR-028 | ESP32 Capability Audit & Witness Record | Accepted | implemented | audit/witness record; pins commit 96b01008 |
|
||||
| ADR-029 | RuvSense — Sensing-First RF Multistatic Mode | Proposed | stale-or-contradicted | repo has ruvsense/ (16 modules); ADR-032 hardens it |
|
||||
| ADR-030 | RuvSense Persistent Field Model | Proposed | stale-or-contradicted | field_model/longitudinal/cross_room modules exist; ADR-032 secures |
|
||||
| ADR-031 | RuView — Cross-Viewpoint Fusion | Proposed | stale-or-contradicted | ruvector/src/viewpoint/ exists; near-duplicate of ADR-029 |
|
||||
| ADR-032 | Multistatic Mesh Security Hardening | Accepted | implemented | hardens Proposed 029/030/031 — status-graph inversion |
|
||||
| ADR-033 | CRV Signal Line Sensing (Coordinate Remote Viewing) | Proposed | proposed-only | speculative/metaphor-driven; abandonment risk |
|
||||
| ADR-034 | Expo React Native Mobile App (FieldView) | Accepted | unknown | no mobile-app crate/dir in CLAUDE.md — unverified |
|
||||
| ADR-035 | Live Sensing UI Accuracy & Data Source Transparency | Accepted | implemented | bug-fix; heuristic pose superseded in spirit by 023/036 |
|
||||
| ADR-036 | RVF Model Training Pipeline & UI Integration | Proposed | proposed-only | overlaps ADR-023 scope |
|
||||
| ADR-037 | Multi-Person Pose from Single ESP32 CSI Stream | Proposed | proposed-only | explicit Supersedes: None; HW limitation noted |
|
||||
| ADR-038 | Sublinear GOAP for Roadmap Optimization | Proposed | proposed-only | meta/process ADR; own corpus census may be stale |
|
||||
| ADR-039 | ESP32-S3 Edge Intelligence Pipeline | Accepted (hardware-validated) | implemented | hardware-validated |
|
||||
| ADR-040 | WASM Programmable Sensing (Tier 3) | Accepted | implemented | depends on ADR-039; WASM3 optional |
|
||||
| ADR-041 | WASM Module Collection — Sensing Registry | Accepted (Phase 1) | partial | ~57 modules catalog/proposed; exotic modules speculative |
|
||||
| ADR-042 | Coherent Human Channel Imaging (CHCI) | Proposed | proposed-only | hardware-gated (custom PCB/TCXO); superseded-in-intent by ADR-153 |
|
||||
| ADR-043 | Sensing Server UI API Completion | Accepted | implemented | internal route count contradiction (14 vs 17) |
|
||||
| ADR-044 | Geospatial Satellite Integration | Accepted | unknown | no Date/Deciders; wifi-densepose-geo crate not in CLAUDE.md table |
|
||||
| ADR-045 | AMOLED Display Support for ESP32-S3 | Proposed | proposed-only | hardware-gated (LilyGO T-Display-S3); ADR-048 depends on it |
|
||||
| ADR-046 | Android TV Box / Armbian Deployment Target | Proposed | proposed-only | proposed-but-looks-abandoned; Phase 2 speculative |
|
||||
| ADR-047 | RuView Observatory — Three.js Visualization | Accepted (Implemented) | implemented | — |
|
||||
| ADR-048 | Adaptive CSI Activity Classifier | Accepted | implemented | depends on Proposed ADR-045 |
|
||||
| ADR-049 | Cross-Platform WiFi Detection & Graceful Degradation | Proposed | proposed-only | targets Python v1 legacy; abandonment risk |
|
||||
| ADR-050 | Provisioning Tool Enhancements | Proposed | partial | keeps 050 (collision resolved); partially fulfilled by ADR-060 |
|
||||
| ADR-166 | Quality Engineering Response — Security Hardening | Accepted | partial | renumbered from ADR-050 (collision resolved); unverified claims (54K fps); findings #6-8 unconfirmed |
|
||||
| ADR-167 | DDD Bounded Contexts (appendix to ADR-052) | (none — appendix, no Status) | unknown | renumbered from ADR-052 (collision resolved); missing-status; cross-ref errors (cites 044 for provisioning) |
|
||||
| ADR-052 | Tauri Desktop Frontend — Hardware Mgmt & Viz | Proposed | partial | keeps 052 (collision resolved); superseded_by ADR-054; status drift |
|
||||
| ADR-053 | UI Design System — Dark Professional | Accepted | implemented | depends on Proposed ADR-052 |
|
||||
| ADR-054 | RuView Desktop Full Implementation | Accepted — in progress | partial | command matrix mostly Stub; espflash version drift vs 052 |
|
||||
| ADR-055 | Integrated Sensing Server in Desktop App | Accepted | implemented | — |
|
||||
| ADR-056 | RuView Desktop Complete Capabilities Reference | Accepted | partial | reference doc; "complete" overstates impl state |
|
||||
| ADR-057 | Firmware CSI Build Guard & sdkconfig.defaults | Accepted | implemented | minor C6 CSI matrix tension vs CLAUDE.md |
|
||||
| ADR-058 | Dual-Modal WASM Browser Pose (Video + CSI) | Proposed | partial | data-gated; ships placeholder weights |
|
||||
| ADR-059 | Live ESP32 CSI Pipeline Integration | Accepted | implemented | hardware-gated (physical ESP32-S3 + UDP:5005) |
|
||||
| ADR-060 | Provision Channel Override & MAC Filtering | Accepted | implemented | fulfills part of Proposed ADR-050(prov) without superseding |
|
||||
| ADR-061 | QEMU ESP32-S3 Emulation for Firmware Testing | Accepted | implemented | RF-PHY paths untestable in QEMU |
|
||||
| ADR-062 | QEMU ESP32-S3 Swarm Configurator | Accepted | implemented | — |
|
||||
| ADR-063 | 60 GHz mmWave Sensor Fusion with WiFi CSI | Proposed | proposed-only | hardware-gated (ESP32-C6+MR60BHA2); superseded-in-scope by 064 |
|
||||
| ADR-064 | Multimodal Ambient Intelligence (CSI+mmWave+env) | Proposed | proposed-only | hardware-gated; mixes build-now + speculative tiers |
|
||||
| ADR-065 | Hotel Guest Happiness Scoring | Proposed | proposed-only | hardware-gated (Cognitum Seed Pi Zero 2 W) |
|
||||
| ADR-066 | ESP32 CSI Swarm with Cognitum Seed Coordinator | Proposed | proposed-only | hardware-gated; overlaps 068/069 |
|
||||
| ADR-067 | RuVector v2.0.4→v2.0.5 Upgrade | Proposed | proposed-only | CLAUDE.md still v2.0.4 — not adopted |
|
||||
| ADR-068 | Per-Node State Pipeline for Multi-Node Sensing | Accepted | implemented | — |
|
||||
| ADR-069 | ESP32 CSI → Cognitum Seed RVF Ingest Pipeline | Accepted | implemented | hardware-gated (live Cognitum Seed fw v0.8.1) |
|
||||
| ADR-070 | Self-Supervised Pretraining from Live CSI + Seed | Accepted | partial | hardware-gated (live 2-node + Seed capture) |
|
||||
| ADR-071 | ruvllm Training Pipeline for CSI Models | Proposed | proposed-only | overlaps 072/079 + libtorch pipeline |
|
||||
| ADR-072 | WiFlow Pose Estimation Architecture | Proposed | partial | data-gated; referenced as implemented in CLAUDE.md (WiFlow-STD) — stale header |
|
||||
| ADR-073 | Multi-Frequency Mesh Scanning | Proposed | proposed-only | hardware-gated (2-node multi-AP) |
|
||||
| ADR-074 | Spiking Neural Network for CSI Sensing | Proposed | proposed-only | proposed-but-looks-abandoned (no in-repo SNN signal) |
|
||||
| ADR-075 | Min-Cut Person Separation from Subcarrier Corr | Proposed | proposed-only | fixes #348; 077/078 depend on it though Proposed |
|
||||
| ADR-076 | CSI Spectrogram Embeddings via CNN + Graph Transformer | Proposed | proposed-only | — |
|
||||
| ADR-077 | Novel RF Sensing Applications | Accepted | partial | depends on Proposed 075/076; data-gated |
|
||||
| ADR-078 | Multi-Frequency Mesh Sensing Applications | Proposed | proposed-only | hardware-gated; depends on Proposed 073 |
|
||||
| ADR-079 | Camera Ground-Truth Training Pipeline | Accepted | partial | P7-P9 Pending; internal PCK contradiction (2.5% vs 35.3% vs 0%); #640 = 0% |
|
||||
| ADR-080 | QE Analysis Remediation Plan | Proposed | proposed-only | unfixed security HIGH findings (XFF bypass, stack traces, JWT-in-URL) |
|
||||
| ADR-081 | Adaptive CSI Mesh Firmware Kernel | Accepted — L1-5 host-tested | partial | mesh RX + Ed25519 signing deferred to Phase 3.5 |
|
||||
| ADR-082 | Pose Tracker Confirmed-Track Output Filter | Accepted — implemented | implemented | fixes #420 |
|
||||
| ADR-083 | Per-Cluster Pi Compute Hop | Proposed — pending field evidence | proposed-only | hardware-gated (status explicitly pending field evidence) |
|
||||
| ADR-084 | RaBitQ Similarity Sensor (4 pipeline points) | Accepted — merged PR #435 | implemented | acceptance on synthetic data; <1pp regression deferred to soak |
|
||||
| ADR-085 | RaBitQ Similarity Sensor — Pipeline Expansion (7 sites) | Proposed | proposed-only | proposed-but-looks-abandoned (refines 084, never advanced) |
|
||||
| ADR-086 | Edge Novelty Gate — RaBitQ on Sensor MCU | Proposed | proposed-only | hardware-gated (no_std port + real-deployment suppression rates) |
|
||||
| ADR-089 | nvsim — NV-Diamond Magnetometer Simulator | Accepted — Passes 1-5 merged | partial | Pass 6 (proof bundle + bench) pending |
|
||||
| ADR-090 | nvsim — Full Hamiltonian/Lindblad Solver | Proposed — conditional | proposed-only | explicitly deferred decision-to-defer |
|
||||
| ADR-091 | Stand-off Radar — 77 GHz / sub-THz Research | Proposed — research only | proposed-only | hardware-gated (COTS sub-THz); ITAR/dual-use |
|
||||
| ADR-092 | nvsim Dashboard — Vite + Dual-Transport | Implemented (2026-04-27) | implemented | 4/12 gates need external infra; PR #436 open |
|
||||
| ADR-093 | nvsim Dashboard Gap Analysis | Implemented (2026-04-27) | implemented | P2.7/P2.8 polish deferred |
|
||||
| ADR-094 | Live 3D Point Cloud Viewer — GH Pages | Proposed (2026-04-29) | proposed-only | governs viewer deploy only, not crate data contract |
|
||||
| ADR-095 | rvCSI — Edge RF Sensing Runtime Platform | Proposed | implemented | header stale — ADR-097 confirms built, published 0.3.1 |
|
||||
| ADR-096 | rvCSI — Crate Topology, napi-c Shim, napi-rs | Proposed | implemented | header stale — 9 crates published 0.3.1 |
|
||||
| ADR-097 | Adopt rvCSI as RuView's primary CSI runtime | Proposed | proposed-only | RuView vendors but does not yet consume — adoption open |
|
||||
| ADR-098 | Evaluate ruvnet/midstream | Rejected (with carve-outs) | proposed-only | rejection; carve-outs revived by ADR-099 |
|
||||
| ADR-099 | Adopt midstream — introspection + low-latency tap | Proposed | proposed-only | tension with ADR-098 (which rejected midstream) |
|
||||
| ADR-100 | Cognitum Cog Packaging Specification | Accepted | implemented | first cog shipped 2026-05-19 (ADR-101) |
|
||||
| ADR-101 | Pose Estimation Cog (WiFi-DensePose side) | Accepted — v0.0.1 shipped | implemented | hardware-gated; signed binaries on GCS |
|
||||
| ADR-102 | Edge Module Registry Integration | Accepted | implemented | serves 105-cog catalog |
|
||||
| ADR-103 | Learned Multi-Person Counter (cog-person-count) | Proposed | proposed-only | data/hardware-gated; claim gutted by ADR-159 |
|
||||
| ADR-104 | RuView MCP Server + CLI Distribution | Accepted | partial | depends on Proposed ADR-103 for count tool |
|
||||
| ADR-105 | Federated learning for RuView CSI personalization | Proposed | proposed-only | head of 105-108 chain, none implemented |
|
||||
| ADR-106 | Differential privacy + biometric isolation | Proposed | proposed-only | extends Proposed 105 |
|
||||
| ADR-107 | Cross-installation federation w/ secure aggregation | Proposed | proposed-only | classical DH later superseded by 108 |
|
||||
| ADR-108 | Kyber PQ key exchange for federation | Proposed | proposed-only | extends Proposed 107 (parent unimplemented) |
|
||||
| ADR-109 | Dilithium PQ signatures for cog distribution | Proposed | proposed-only | extends ADR-100; sister of 108 |
|
||||
| ADR-110 | ESP32-C6 firmware extension (Wi-Fi 6 CSI, 802.15.4, TWT, LP) | Accepted — P1-P10 complete v0.7.0 | implemented | HE-CSI needs ESP-IDF ≥5.5 (v5.4 downconverts to HT) |
|
||||
| ADR-113 | Multistatic anchor placement strategy | Proposed | proposed-only | amends ADR-029; simulation-derived not HW-validated |
|
||||
| ADR-114 | cog-quantum-vitals | Proposed | proposed-only | hardware-gated (nvsim today, real NV-diamond in prod); R13 NEGATIVE |
|
||||
| ADR-115 | Home Assistant via MQTT auto-discovery + Matter bridge | Accepted (MQTT) / Proposed (Matter) | partial | mixed status; Matter deferred to v0.7.1 |
|
||||
| ADR-116 | HA + Matter as a Cognitum Seed cog (cog-ha-matter) | Proposed — P2 scaffold compiles | partial | provisional; Matter deferred to v0.8 |
|
||||
| ADR-117 | pip wifi-densepose via PyO3 + maturin | Proposed | proposed-only | current PyPI v1.1.0 stale; tracking issue TBD |
|
||||
| ADR-118 | BFLD — Beamforming Feedback Layer for Detection | Proposed | proposed-only | umbrella; sub-ADRs 119-123 |
|
||||
| ADR-119 | BFLD Frame Format and Wire Protocol | Proposed | proposed-only | child of Proposed 118 |
|
||||
| ADR-120 | BFLD Privacy Class and Hash Rotation | Proposed | proposed-only | child of Proposed 118 |
|
||||
| ADR-121 | BFLD Identity Risk Scoring and Coherence Gate | Proposed | proposed-only | abandonment risk; data-gated (KIT BFId dataset) |
|
||||
| ADR-122 | BFLD RuView Surface — HA/Matter/MQTT | Proposed | proposed-only | abandonment risk; depends on Soul Signature + cog-ha-matter |
|
||||
| ADR-123 | BFLD Capture Path — Pi5/Nexmon, ESP32 feasibility | Proposed | proposed-only | hardware-gated (ESP32 cannot sniff CBFR) |
|
||||
| ADR-124 | rvagent — MCP + ruvector npm lib (SENSE-BRIDGE) | Proposed | proposed-only | abandonment risk; not published; open questions |
|
||||
| ADR-125 | RuView ↔ Apple Home native HAP bridge | Proposed | proposed-only | abandonment risk; hardware-gated (same-L2 pairing) |
|
||||
| ADR-126 | HOMECORE — Rust+WASM+TS port of Home Assistant | Proposed | proposed-only | multi-quarter; series map cites missing 131/132 + mis-numbered 134 |
|
||||
| ADR-127 | HOMECORE-CORE — state machine, registries, event bus | Proposed | proposed-only | future-dated Q3 2026 |
|
||||
| ADR-128 | HOMECORE-PLUGINS — WASM integration plugin system | Proposed | proposed-only | future-dated; depends on 127 ABI freeze |
|
||||
| ADR-129 | HOMECORE-AUTO — automation engine + template eval | Proposed | proposed-only | future-dated; broken cross-ref to ADR-134 |
|
||||
| ADR-130 | HOMECORE-API — wire-compatible REST + WS | Proposed | proposed-only | future-dated; wire-compat needs HA companion-app suite |
|
||||
| ADR-133 | HOMECORE-ASSIST — voice/intent + Ruflo bridge | Proposed | partial | missing tracking issue; P1 partial build, P2 deferred |
|
||||
| ADR-134 | First-Class Channel Impulse Response (CIR) Support | Proposed | proposed-only | DUPLICATE IDENTITY (126/129 cite 134 as HOMECORE-MIGRATE); hardware-gated |
|
||||
| ADR-135 | Empty-Room Baseline Calibration | Proposed | proposed-only | hardware-gated (COM9/COM12 + 802.15.4 sync) |
|
||||
| ADR-136 | RuView Rust Streaming Engine — Architecture/Contracts | Proposed | partial | status-contradiction: §8 says Built (commit 11f89727f, 9 tests) |
|
||||
| ADR-137 | Fusion Engine Quality Scoring | Proposed | partial | status-contradiction: Built (commit 4fa3847ac, 6 tests) |
|
||||
| ADR-138 | WiFi-7 MLO LinkGroup + ArrayCoordinator gating | Proposed | partial | status-contradiction: Built (commit fc7674bde, 8 tests) |
|
||||
| ADR-139 | WorldGraph — Environmental Digital Twin | Proposed | partial | status-contradiction: Built (commit 521a012d8, 7 tests) |
|
||||
| ADR-140 | Semantic State Record + Ruflo Agent Bridge | Proposed | partial | status-contradiction: Built (commit 169a355bd, 4 tests); Rest kind not built |
|
||||
| ADR-141 | BFLD Privacy Control Plane | Proposed | partial | header stale vs Implementation note (commit 7d88eb84c, 6 tests) |
|
||||
| ADR-142 | Evolution Tracker + Temporal VoxelMap | Proposed | partial | header stale vs note (commit 1f8e180d6, 6 tests) |
|
||||
| ADR-143 | RF SLAM v2 — Reflector Discovery + Anchor Learning | Proposed | partial | header stale (commit 2d4f3dea5); v2 dormant behind 7-day validation |
|
||||
| ADR-144 | UWB Range-Constraint Fusion | Proposed | partial | header stale (commit b10bc2e9a); no UWB radio in fleet |
|
||||
| ADR-145 | Ablation Evaluation Harness | Proposed | partial | referenced as existing by 149/150/151; F4/UWB variant HW-gated |
|
||||
| ADR-146 | RF Encoder Multi-Task Heads + Uncertainty | Proposed | proposed-only | no Impl note (unlike 141-144); depends on tch/libtorch |
|
||||
| ADR-169 | adam-mode — light theme toggle | Proposed | proposed-only | renumbered from ADR-147 (collision resolved); referenced by ADR-170 yoga |
|
||||
| ADR-147 | Occupancy World Model (OccWorld/RoboOccWorld) | Accepted | partial | keeps 147 (collision resolved); self-revised from Cosmos; Phase B gated |
|
||||
| ADR-168 | Benchmark Proof — OccWorld on RTX 5080 | (none) | unknown | renumbered from ADR-147 (collision resolved); MISSING STATUS; baseline-without-fine-tuning (random weights) |
|
||||
| ADR-148 | Drone Swarm Control System | In Progress | partial | keeps 148 (collision resolved); re-routes 147 Cosmos item to 149 |
|
||||
| ADR-170 | yoga-mode — pose detection/scoring demo | Proposed | proposed-only | renumbered from ADR-148 (collision resolved); no tracking issue |
|
||||
| ADR-149 | AetherArena — Spatial-Intelligence Benchmark (HF) | Accepted | partial | keeps 149 (collision resolved); external repo out-of-tree; Wi-Pose dropped |
|
||||
| ADR-171 | Drone Swarm Benchmarking Methodology | Accepted (peer-reviewed) | partial | renumbered from ADR-149 (collision resolved); critiques 148's own numbers |
|
||||
| ADR-150 | RuView RF Foundation Encoder | Proposed | partial | status Proposed but cites measured 81.63% in-domain vs ~11.6% cross-subject |
|
||||
| ADR-151 | Per-Room Calibration & Specialized Model Training | Accepted — Stages 1-5 impl | partial | HF-backbone distillation pending |
|
||||
| ADR-152 | WiFi-Pose SOTA 2026 Intake | Proposed | partial | header stale; §2.1-2.3/2.6 impl, WiFlow-STD ~96% PCK; 1/25 claim REFUTED |
|
||||
| ADR-153 | IEEE 802.11bf-2025 Forward-Compat Protocol Model | accepted | implemented | amends ADR-152 §2.4; OTA/silicon binding deferred |
|
||||
| ADR-154 | Signal/DSP Beyond-SOTA Sweep — M0 | Proposed | partial | header likely stale; discloses dead CIR coherence gate; ~45 deferred |
|
||||
| ADR-155 | NN/Training Beyond-SOTA Sweep — M1 | Proposed | partial | header likely stale; retracts synthetic-val/fake-gradient/self-cert proof |
|
||||
| ADR-156 | RuVector/Cross-Viewpoint Fusion Sweep — M2 | Proposed | partial | header likely stale; one staged finding is numeric no-op |
|
||||
| ADR-157 | Hardware/Sensing-Acquisition Sweep — M3 | Proposed | partial | header likely stale; headline negative result (layer already hardened) |
|
||||
| ADR-158 | MAT/World-Model Cluster Sweep — Anti-AI-Slop | accepted | implemented | life-safety; fixes triage inflation; some paths DATA-GATED |
|
||||
| ADR-159 | Cognitum Appliance Cluster Sweep — Anti-AI-Slop | accepted | implemented | person-count training_class1_accuracy = 0.343; description renamed |
|
||||
| ADR-160 | Edge Skill Library (wasm-edge) — Honest Labeling | accepted | implemented | medical/affect/weapon NOT validated — relabelled |
|
||||
| ADR-161 | HOMECORE Server — WS Auth Bypass, Reply-Theater | accepted | implemented | CRITICAL WS auth bypass fix; amends 130/129/128 |
|
||||
| ADR-162 | HOMECORE Plugin Security + Bounded RunModes | accepted | implemented | security-critical; enforces ADR-161 deferrals |
|
||||
| ADR-163 | Edge-Latency Measurement — CLAIMED→MEASURED | accepted | implemented | ESP32/Xtensa figure remains UNMEASURED (hardware-gated) |
|
||||
@@ -0,0 +1,316 @@
|
||||
# ADR Corpus Gap Analysis — Five-Lens Findings
|
||||
|
||||
Research notes backing ADR-164. Each lens output is reproduced verbatim. Census: 162 ADR entries across 156 distinct files (6 duplicate-number collisions verified on disk).
|
||||
|
||||
---
|
||||
|
||||
## Lens 1: status-distribution
|
||||
|
||||
Confirmed: ADR-168-benchmark-proof.md (was ADR-147-benchmark-proof.md) and ADR-134-csi-to-cir have no `Status` line in their headers (the 167-ddd hits are Rust code in the body, not a header; the ADR-167 appendix, was ADR-052-ddd, lacks a real Status header per its first lines). Findings are evidence-grounded. Final analysis below.
|
||||
|
||||
### ADR Corpus — Status & Implementation Distribution
|
||||
|
||||
Census: **162 ADR entries** across **156 distinct files** (6 duplicate-number collisions verified on disk).
|
||||
|
||||
#### Status tally
|
||||
|
||||
| Status bucket | Count |
|
||||
|---|---|
|
||||
| Accepted (incl. "Accepted — partial/in-progress/Phase 1" variants) | ~56 |
|
||||
| Proposed (incl. "Proposed — conditional/research-only") | ~88 |
|
||||
| Superseded | 1 (ADR-002) |
|
||||
| Rejected | 1 (ADR-098) |
|
||||
| Missing / no Status header | 3 (ADR-168-benchmark-proof [was 147], ADR-167-ddd appendix [was 052], ADR-134-CIR) |
|
||||
| Mixed/dual status in one ADR | 3 (ADR-115, ADR-149-AetherArena vs swarm, ADR-133) |
|
||||
|
||||
#### impl_state tally
|
||||
|
||||
| impl_state | Count |
|
||||
|---|---|
|
||||
| implemented | ~36 |
|
||||
| partial | ~50 |
|
||||
| proposed-only | ~64 |
|
||||
| stale-or-contradicted | 3 (ADR-029, 030, 031) |
|
||||
| unknown | 5 (ADR-034, 044, 167-ddd [was 052], 168-proof [was 147], …) |
|
||||
| superseded | 1 (ADR-002) |
|
||||
|
||||
**Headline:** ~114 of 162 ADRs (70%) are decisions that never fully landed (proposed-only + partial + stale + unknown). The dominant failure mode is **stale Status headers** — Accepted/implemented work still labeled "Proposed."
|
||||
|
||||
#### SEVERITY: CRITICAL — Status header missing or structurally absent (cannot triage)
|
||||
|
||||
- **ADR-168-benchmark-proof.md** (renumbered from ADR-147 to resolve the 147 collision) — *No `Status` header at all* (grep confirmed). Not a true ADR; it's a benchmark artifact (OccWorld @ ~213ms on RTX 5080, random weights) that was misfiled under the ADR-147 number. **Action: relocate to `docs/proof/` or `benchmarks/`, remove ADR number.**
|
||||
- **ADR-134-csi-to-cir-time-domain-multipath.md** — *No `Status` header* (grep confirmed) in the header region. Body says Proposed but the field is not in canonical position. Compounded by a **number collision**: ADR-126/129 reference "ADR-134" as HOMECORE-MIGRATE, but the on-disk file is CIR. **Action: add canonical `## Status` line; resolve the 134 identity split.**
|
||||
- **ADR-167-ddd-bounded-contexts.md** (renumbered from ADR-052 to resolve the 052 collision; still an appendix to parent ADR-052) — Appendix doc with no Status/Date header (grep found only Rust code, no header field). **Action: mark explicitly "Appendix to ADR-052 (no independent status)".**
|
||||
|
||||
#### SEVERITY: CRITICAL — Duplicate ADR numbers (6 collisions, all verified on disk)
|
||||
|
||||
| Number | Colliding files | Action | Resolution |
|
||||
|---|---|---|---|
|
||||
| **147** | adam-mode-light-theme · nvidia-cosmos/OccWorld · benchmark-proof | Renumber 2 of 3 | **RESOLVED** — 147 keeps nvidia-cosmos/OccWorld; benchmark-proof → **ADR-168**, adam-mode → **ADR-169** |
|
||||
| **148** | drone-swarm-control-system · yoga-mode-pose-system | Renumber 1 | **RESOLVED** — 148 keeps drone-swarm; yoga-mode → **ADR-170** |
|
||||
| **149** | AetherArena-leaderboard · swarm-benchmarking | Renumber 1 | **RESOLVED** — 149 keeps AetherArena; swarm-benchmarking → **ADR-171** |
|
||||
| **050** | provisioning-tool-enhancements · quality-engineering-security-hardening | Renumber 1 | **RESOLVED** — 050 keeps provisioning (5 refs vs 1); quality-engineering → **ADR-166** |
|
||||
| **052** | tauri-desktop-frontend · ddd-bounded-contexts (appendix) | Demote appendix | **RESOLVED** — 052 keeps tauri; ddd appendix renumbered → **ADR-167** (still linked to parent 052) |
|
||||
| **134** | csi-to-cir (on disk) · HOMECORE-MIGRATE (referenced, no file) | Resolve identity | Identity split (not a filename collision); resolved separately via G3 → ADR-165 |
|
||||
|
||||
These broke the ADR index and `/adr` tooling — two ADRs answering to one number is a corpus-integrity defect, not cosmetics. The five filename collisions are now resolved (six displaced files renumbered 166–171); see ADR-164 Gap Register G1.
|
||||
|
||||
#### SEVERITY: HIGH — Status header stale vs. shipped reality (Proposed header on landed code)
|
||||
|
||||
These are the most dangerous: an auditor reading the header concludes "not built" when code + tests exist. Ranked by blast radius:
|
||||
|
||||
1. **ADR-136 → ADR-145** (streaming-engine series, 10 ADRs) — every header says `Proposed` but each `§ Implementation Status` reports **"Built" with pinned commits + passing tests** (136: 11f89727f; 137: 4fa3847ac; 138: fc7674bde; 139: 521a012d8; 140: 169a355bd; 141: 7d88eb84c; 142: 1f8e180d6; 143: 2d4f3dea5; 144: b10bc2e9a; 145 referenced as landed by 149/150/151). **Bulk action: flip headers to "Accepted — partial (integration glue pending)".**
|
||||
2. **ADR-029 / 030 / 031** (RuvSense/field-model/cross-viewpoint) — `Proposed` but repo has `signal/src/ruvsense/` (16 modules) and `ruvector/src/viewpoint/`, and **Accepted ADR-032 hardens them** — an Accepted ADR depending on Proposed parents (status-graph inversion).
|
||||
3. **ADR-095 / 096** (rvCSI) — `Proposed` but ADR-097 confirms built, extracted to own repo, published 0.3.1 to crates.io/npm.
|
||||
4. **ADR-152** — `Proposed` but CLAUDE.md + recent commits report §2.1–2.3/2.6 implemented, WiFlow-STD MEASURED-EQUIVALENT ~96% PCK.
|
||||
5. **ADR-154/155/156/157** (beyond-SOTA sweeps) — `Proposed` but each describes fixes **already landed with revert-verified regression tests**.
|
||||
6. **ADR-024 (AETHER) / 027 (MERIDIAN) / 072 (WiFlow)** — `Proposed` but CLAUDE.md lists them Accepted and code references them as implemented.
|
||||
7. **ADR-017** — header Accepted but CLAUDE.md still calls it "Proposed" (inverse drift).
|
||||
8. **ADR-018** — `Proposed` but ADR-012 cites it as the working firmware/aggregator impl.
|
||||
|
||||
#### SEVERITY: HIGH — Status ahead of its dependencies (Accepted depends on Proposed)
|
||||
|
||||
- **ADR-032** Accepted → depends on Proposed 029/030/031.
|
||||
- **ADR-053** Accepted → depends on Proposed ADR-052.
|
||||
- **ADR-048** Accepted → depends on Proposed ADR-045.
|
||||
- **ADR-077** Accepted → depends on Proposed ADR-075/076.
|
||||
|
||||
#### SEVERITY: MEDIUM — Proposed-but-looks-abandoned (decisions that will likely never land)
|
||||
|
||||
Cluster heads where the whole chain is Proposed with zero implementation evidence:
|
||||
- **ADR-003/007/008/009/010** — RuVector child ADRs orphaned after parent ADR-002 was superseded by 016/017.
|
||||
- **ADR-105/106/107/108** — entire federation chain, none implemented.
|
||||
- **ADR-118/119/120/121/122/123** — entire BFLD chain, all ACs unchecked, tracking issues TBD.
|
||||
- **ADR-124/125/126/127/128/129/130/133** — HOMECORE/bridge chain, multi-quarter future-dated, all TBD.
|
||||
- **ADR-033** (remote-viewing), **ADR-042** (CHCI, superseded-in-intent by 153), **ADR-046** (Android TV), **ADR-049** (Python v1 legacy), **ADR-067** (RuVector v2.0.5 upgrade not adopted), **ADR-074** (SNN), **ADR-085** (RaBitQ expansion), **ADR-011** (Proposed-URGENT despite proof pipeline live).
|
||||
|
||||
#### SEVERITY: MEDIUM — Accepted but no implementation evidence (unverified "done")
|
||||
|
||||
- **ADR-034** (FieldView mobile app) — Accepted, no crate/dir in CLAUDE.md.
|
||||
- **ADR-044** (wifi-densepose-geo) — bare Accepted, no Date/Deciders, crate not in CLAUDE.md table.
|
||||
|
||||
#### Ranked actionable backlog (do in this order)
|
||||
|
||||
1. **Resolve 6 duplicate ADR numbers + 3 missing-header files** (CRITICAL — breaks the index/tooling). **Number collisions RESOLVED:** renumbered 147×2 (benchmark-proof→168, adam-mode→169), 148 (yoga→170), 149 (swarm-benchmarking→171), 050 (quality-engineering→166), 052 ddd appendix→167. Remaining: resolve the 134 identity split (done via G3→165); add Status headers to 168-proof, 134, 167-ddd (owner-gated).
|
||||
2. **Bulk-flip the 10 streaming-engine headers (ADR-136–145)** from Proposed → "Accepted — partial" — they have commit-pinned, test-backed Implementation Status notes. Highest ROI: one batch fixes the largest stale-status cluster.
|
||||
3. **Fix the status-graph inversions** (032/053/048/077 depend on Proposed parents; promote parents 029/030/031/045/052/075/076 to match their built reality, or downgrade the dependents).
|
||||
4. **Reconcile CLAUDE.md vs ADR headers** for 017, 024, 027, 072, 152 (doc says one thing, header another).
|
||||
5. **Triage the abandoned chains** (003/007–010, 105–108, 118–123, HOMECORE 124–133) — either mark `Rejected`/`Deferred` or open tracking issues; leaving ~50 Proposed-forever ADRs pollutes every gap analysis.
|
||||
6. **Verify or downgrade unsubstantiated Accepteds** (034, 044).
|
||||
|
||||
**Bottom line:** the corpus's #1 defect is not abandoned work — it's **bookkeeping drift**. ~24 ADRs are mislabeled Proposed while code+tests exist, 6 numbers collide, and 3 files lack Status headers entirely. The status field cannot currently be trusted as a source of truth for what is built.
|
||||
|
||||
---
|
||||
|
||||
## Lens 2: supersession-chains
|
||||
|
||||
I have all evidence needed. ADR-016 doesn't mention ADR-002 at all (only 014/015); ADR-017 discusses ADR-002 as a "correction" but never uses the word "supersede." This confirms the broken reciprocity. Final analysis below.
|
||||
|
||||
### Supersession & Lifecycle Integrity — ADR Corpus
|
||||
|
||||
Total declared supersession edges in census: **3** (ADR-002→016, ADR-002→017, ADR-052-tauri→054). All three were file-verified. Findings below; severity graded **CRITICAL / HIGH / MEDIUM / LOW**.
|
||||
|
||||
#### Reconstructed chains
|
||||
- **ADR-002** (RuVector RVF Integration Strategy) → superseded-by **ADR-016 + ADR-017** (dual realization). Self-declared `supersedes` on 016/017.
|
||||
- **ADR-052-tauri** (Tauri Desktop Frontend) → superseded-by **ADR-054** (declared in 052's `superseded_by`).
|
||||
- No other formal `supersedes`/`superseded_by` links exist. No cycles detected (the only multi-node graph, ADR-002→{016,017}, is a DAG; ADR-052→054 is a single edge). **No cycles — clean.**
|
||||
|
||||
#### Broken / asymmetric links
|
||||
|
||||
**1. ADR-002 → ADR-016 / ADR-017: one-directional, never reciprocated. (HIGH)**
|
||||
ADR-002 header declares "Superseded by [ADR-016] and [ADR-017]" (`docs/adr/ADR-002-ruvector-rvf-integration-strategy.md:4`). But neither successor claims it:
|
||||
- **ADR-016** (`ADR-016-ruvector-integration.md`) never mentions ADR-002 anywhere — its `## References` lists only ADR-014/015. It does not assert supersession; the census `supersedes:["ADR-002"]` for ADR-016 is **unsupported by the file**.
|
||||
- **ADR-017** (`ADR-017-ruvector-signal-mat-integration.md`) discusses ADR-002 only as a `## Correction to ADR-002 Dependency Strategy` (line 532) — corrects "fictional crate names" — but **never uses the word "supersede."** Census `supersedes:["ADR-002"]` is again file-unsupported.
|
||||
- Net: ADR-002 points up at two ADRs that don't point back. The supersession is asserted by the superseded ADR alone — backwards from convention, and unverifiable from the successors.
|
||||
|
||||
**2. ADR-002 partial-supersession leaves 5 orphaned children stranded. (HIGH)**
|
||||
ADR-002 is an umbrella whose children ADR-003, 007, 008, 009, 010 are still `Proposed`. ADR-016/017 only realize the *training/signal/MAT* integration points (mincut, attention, solver, etc.). The RVF-container (003), PQ-crypto (007), Raft consensus (008), WASM edge runtime (009), and witness-chains (010) decisions are **neither implemented nor formally superseded** — ADR-017:555 explicitly acknowledges 008/009 "described in ADR-002" are not carried forward. Marking the parent fully "Superseded" silently buries 5 live-but-abandoned child decisions. ADR-010's role is additionally filled de facto by ADR-028's witness-bundle without any supersession link.
|
||||
|
||||
**3. ADR-052-tauri → ADR-054: declared by predecessor, not acknowledged by successor. (HIGH)**
|
||||
Census records ADR-052-tauri `superseded_by:["ADR-054"]`. **ADR-054 (`ADR-054-desktop-full-implementation.md`) contains zero references to ADR-052** (grep for `ADR-052|replac|supersed` returns nothing). ADR-054 is titled "RuView Desktop **Full Implementation**" and is "in progress" — functionally it's the implementation plan *for* 052, not a replacement. The supersession edge is unconfirmed by the successor and arguably mis-modeled (an in-progress impl doesn't supersede its own design ADR).
|
||||
|
||||
#### Orphaned superseded ADRs still marked accepted/active
|
||||
**4. No classic orphan (superseded ADR still `Accepted`), but two soft variants: (MEDIUM)**
|
||||
- **ADR-052-tauri** is `Proposed` *and* `superseded_by ADR-054`, yet downstream ADR-053/055/056 (all `Accepted`) build on it and treat the desktop app as shipped (v0.3.0). A Proposed-and-superseded ADR anchoring three Accepted descendants is a lifecycle inconsistency: the live decision-of-record is ambiguous (052? 054? 056?).
|
||||
- **ADR-002** is correctly `Superseded`, so not an orphan — but ADR-038's roadmap census still counts it among 37 active ADRs, so stale references persist downstream.
|
||||
|
||||
#### De-facto supersessions never recorded (missing links) — MEDIUM
|
||||
These pairs behave as supersession in the corpus but carry **no** `supersedes`/`superseded_by` fields, so the chain graph understates reality:
|
||||
- **ADR-098 ⇄ ADR-099** (`MEDIUM`): ADR-098 **Rejected** midstream; ADR-099 revives its carve-outs ("Adopt midstream…"). A rejection partially reversed by a later Proposed ADR — neither links the other via supersession fields (only prose tension).
|
||||
- **ADR-063 → ADR-064**, **ADR-035 → ADR-023/036**, **ADR-042 → ADR-153**, **ADR-050-provisioning → ADR-060**, **ADR-117 retracts PyPI v1.1.0**, PQ chain **ADR-100 → ADR-109 / ADR-107→108**: each is "superseded in scope/intent" per flags but has empty supersession fields. Not broken links (none falsely asserted), but **missing** ones — the lifecycle graph is incomplete.
|
||||
|
||||
#### Cycles
|
||||
**None.** ADR-002→{016,017} and ADR-052→054 are acyclic; no ADR is reachable from its own successor.
|
||||
|
||||
#### Severity summary
|
||||
| # | Issue | Severity |
|
||||
|---|-------|----------|
|
||||
| 1 | ADR-002 supersession not reciprocated by ADR-016/017 (016 never mentions 002; 017 only "corrects") | HIGH |
|
||||
| 2 | ADR-002 children 003/007/008/009/010 stranded under a "fully superseded" parent | HIGH |
|
||||
| 3 | ADR-052→054 edge unacknowledged by ADR-054 (no reference); likely mis-modeled (impl, not replacement) | HIGH |
|
||||
| 4 | ADR-052-tauri Proposed+superseded yet anchors Accepted 053/055/056 | MEDIUM |
|
||||
| 5 | De-facto supersessions (098/099, 063/064, 042/153, 050/060, 035/023, 100/109) unrecorded | MEDIUM |
|
||||
| — | Cycles | none (clean) |
|
||||
|
||||
Highest-priority fix: add reciprocal `supersedes: ADR-002` language to ADR-016/017 (or downgrade the claim), and split ADR-002's status to "partially superseded" so children 003/007/008/009/010 aren't silently closed.
|
||||
|
||||
---
|
||||
|
||||
## Lens 3: contradictions
|
||||
|
||||
All findings confirmed against source. Here is the contradictions lens.
|
||||
|
||||
### Contradictions & Retractions Lens — ADR Corpus
|
||||
|
||||
The sweep (ADR-154–163) is itself a structured retraction layer: each "Beyond-SOTA / anti-AI-slop" ADR exists *because* an earlier accepted claim was found false. Findings graded **CRITICAL** (life-safety, security, or a published accuracy number that was meaningless) / **HIGH** (a capability/number retracted or directly contradicted) / **MEDIUM** (status or scope conflict) / **LOW** (cosmetic/doc drift).
|
||||
|
||||
#### A. Accepted/published claims later RETRACTED or REFUTED
|
||||
|
||||
**[CRITICAL] ADR-155 retracts every prior NN accuracy/TTA/proof claim.** ADR-155 §2.2 discloses `bin/train.rs` validated a *real* MM-Fi training run against a **synthetic** val set, and windows leak at stride-1 (~99% overlap) — *"any PCK it printed was meaningless on two counts."* §2.3: `rapid_adapt.rs` `contrastive_step`/`entropy_step` wrote a **fake gradient** (`grad += v * 0.01`) unrelated to the objective — every "TTA improves the metric" result was unsupported. §2.4: the deterministic proof **self-certified** (`generate_expected_hash` blessed whatever the pipeline emitted; PASS counted any loss decrease incl. 1e-9 float noise; missing hash defaulted to PASS). This retroactively voids accuracy claims made anywhere in the corpus that depended on the training/proof path prior to commit landing ADR-155.
|
||||
|
||||
**[CRITICAL] ADR-154 retracts the ADR-134 CIR coherence gate as live.** ADR-152/CLAUDE.md present CIR (ADR-134) as a contributing signal in the multistatic coherence gate. ADR-154 §2 proves it was **DEAD in production for every canonical frame**: the HT20 CIR estimator returns `SubcarrierMismatch` on all 56-tone canonical frames (`cir_gate_ht20_is_dead_on_canonical56`: 0 Ok / 8 mismatch), so `coherence = 0.7·freq + 0.3·dominant_tap_ratio` silently degraded to freq-only (`cir_gate_dead_ht20_equals_gate_off`, |Δ|<1e-9). Any ADR claiming CIR-enhanced coherence/ToF before this fix overstated reality.
|
||||
|
||||
**[CRITICAL] ADR-079 internal accuracy contradiction (self-flagged in census, confirmed).** Context states proxy PCK@20 = **2.5%** (lines 11, 25) and "10-20x improvement: 2.5% → 35%+". The baseline table (line 497) reports proxy PCK@20 = **35.3%** — i.e. the *baseline already equals the stated target* — while per-joint upper body (nose/shoulders/wrists) is **0%** (line 503). The headline 10–20x improvement number is therefore self-refuting against its own baseline table. CLAUDE.local.md adds the local-Windows attempt (#640) measured **0% PCK**. An Accepted ADR with three mutually inconsistent values for its own central metric.
|
||||
|
||||
**[HIGH] ADR-152 self-refutes one verified research claim (F4).** ADR-152 grades 25 claims 3-vote; §F4 records the "Espressif `esp_wifi_sensing` is **drop-in compatible with RuView nodes**" claim **REFUTED 0-3** (WiFi-6 parts use a different CSI acquisition config struct). ADR-110 ("ESP32-C6 Wi-Fi 6 CSI") and the CLAUDE.md hardware table treat C6/Wi-Fi-6 CSI as a smooth extension; ADR-152 also notes HE-CSI needs ESP-IDF ≥5.5 (v5.4 silently downconverts to HT). The "WiFlow-STD MEASURED-EQUIVALENT ~96% PCK@20" line in CLAUDE.md is *not* yet supported: §2.2/§F1 mark external pose numbers (incl. the 97.25% WiFlow-STD figure) **CLAIMED**, and §F1 explicitly forbids citing 97.25% as comparable until measurements (a)–(c) are run. CLAUDE.md asserting "MEASURED-EQUIVALENT" contradicts the ADR's own gating.
|
||||
|
||||
**[HIGH] ADR-150 retracts the implied cross-subject capability of the encoder line.** AETHER/MERIDIAN ADRs (024/027) and the foundation-encoder framing imply subject-invariant embeddings work. ADR-150 measures **81.63% in-domain vs ~11.6% leakage-free cross-subject** torso-PCK, and reports DANN **failed** (27.26%→27.54%, empirically ~0 gain) and bigger capacity *hurt* (transformer 24.8% < conv 27.3%). §1.1/§4 conclude the cross-subject acceptance gate "is **unlikely to be met without new multi-subject** data" — a direct retraction of the "more capacity / adversarial alignment solves cross-environment loss" premise underlying ADR-027.
|
||||
|
||||
**[HIGH] ADR-159 refutes the "never identified anyone" accusation but simultaneously retracts cog-person-count's marketing.** ADR-159 ships real SHA-pinned Candle models, but discloses person-count `training_class1_accuracy = 0.343` (presence-only, classes 0/1), and **renames** the Cargo description from "learned multi-person counter" → "presence detector + (data-gated) person count," clamping/`low_confidence`-flagging multi-occupant counts. This retracts ADR-103's "learned multi-person counter (SOTA WiFi CSI counting)" claim and ADR-104's count tool, which depended on it.
|
||||
|
||||
**[HIGH] ADR-161 retracts HOMECORE server security + functionality claims.** ADR-130 (HOMECORE-API, wire-compatible, Ed25519-JWT) implied a secured server. ADR-161 fixes a **CRITICAL WebSocket auth bypass** (any non-empty token accepted), "reply-theater" (WS responses computed then discarded), and documented-but-no-op automation — then ADR-162 enforces the ADR-161 deferrals (plugin Ed25519 sig verification, capability isolation, bounded RunModes that were "parsed-but-unenforced/unbounded-parallel"), retracting ADR-128/129's implied plugin-signing and automation guarantees.
|
||||
|
||||
**[MEDIUM] ADR-163 converts CLAIMED latency budgets to MEASURED — retracting prior budget citations.** ADR-160/159 cited wasm-edge/cog latency *budgets*. ADR-163 adds host benches and explicitly states the **ESP32/Xtensa-on-hardware figure remains UNMEASURED** — so any doc citing the device latency budget as achieved is unsupported.
|
||||
|
||||
**[MEDIUM] ADR-098 → ADR-099 partial reversal.** ADR-098 **Rejected** midstream as a system component; ADR-099 (Proposed) **adopts** midstream's temporal-compare (DTW) + temporal-attractor-studio as a parallel tap. Framed as "complementary," but it revives the exact carve-outs ADR-098 declined to integrate — a live decision conflict pending resolution.
|
||||
|
||||
**[MEDIUM] ADR-147 (OccWorld) self-retracts Cosmos.** The accepted ADR-147 title/decision was revised from "NVIDIA Cosmos WFM Integration" to OccWorld after a hardware finding (Cosmos needs 32.5 GB VRAM); Cosmos is retracted as primary. The companion ADR-168-benchmark-proof (renumbered from ADR-147) reports 213 ms/inference on **random weights, no checkpoint** — a baseline-without-fine-tuning number that must not be cited as a quality/target metric.
|
||||
|
||||
#### B. Pairs making CONFLICTING decisions on the same topic
|
||||
|
||||
**[HIGH] RVF-WASM edge runtime — ADR-009 vs shipped `wifi-densepose-wasm`.** ADR-009 (Proposed) decides to **replace** the existing wifi-densepose-wasm approach with an `.rvf.edge` container runtime. The crate it proposes to replace is shipped and in the CLAUDE.md crate table (and is the dependency base for ADR-058/059 browser pose). ADR-009 is an unrealized decision directly contradicting shipped architecture.
|
||||
|
||||
**[HIGH] Witness/audit mechanism — ADR-010 vs ADR-028.** ADR-010 (Proposed) decides RuVector witness *chains* as "the primary tamper-evident audit mechanism." ADR-028 (Accepted, implemented) established a different **witness-bundle** mechanism (verify.py / SHA-256 / VERIFY.sh) that fills this role. Two competing "primary audit" decisions; ADR-010 is stranded.
|
||||
|
||||
**[HIGH] Multistatic "sensing-first RF mode" — ADR-029 vs ADR-031 near-duplicate scope.** Both decide a "sensing-first RF mode for multistatic fidelity": ADR-029 (RuvSense, signal/src/ruvsense/) and ADR-031 (RuView cross-viewpoint fusion, ruvector/src/viewpoint/). Overlapping problem statements (occlusion/depth/multi-person via multistatic attention+geometry), separate crate homes, both still nominally "Proposed" while both are implemented. Unreconciled dual ownership of the multistatic-fusion decision.
|
||||
|
||||
**[MEDIUM] Person-counting decision conflict — ADR-037 vs ADR-075 vs ADR-103.** Three different decisions to replace the same fixed-threshold counter: ADR-037 (4-phase neural decomposition), ADR-075 (spectral min-cut over subcarrier-correlation graph, fixes #348), ADR-103 (learned Cog `cog-person-count`). ADR-075's bug (#348) overlaps ADR-069's driver. None supersedes the others; ADR-159 then guts ADR-103's claim (above).
|
||||
|
||||
**[MEDIUM] PQ-crypto signing — ADR-007 vs ADR-109.** ADR-007 (Proposed) decides Ed25519 + ML-DSA-65 hybrid for sensing-data signing; ADR-109 (Proposed) decides Ed25519 + **Dilithium-3** hybrid for cog signing (Dilithium = ML-DSA family but a different parameter pick/scope). Two PQ-signature decisions over adjacent surfaces with non-identical algorithm choices, neither reconciled.
|
||||
|
||||
**[MEDIUM] Federation key-exchange self-supersession — ADR-107 vs ADR-108.** ADR-107 adopts classical Diffie-Hellman in secure-aggregation Layer 4; ADR-108 replaces it with Kyber-768 because the DH choice is "quantum-vulnerable." ADR-108 supersedes a core element of ADR-107 while ADR-107 is still only Proposed — a decision corrected before it was ever accepted.
|
||||
|
||||
**[MEDIUM] Provisioning path forked three ways — ADR-050(prov) vs ADR-060 vs ADR-052/054.** ADR-050 (provisioning-tool-enhancements, Proposed) scopes channel+MAC-filter flags; ADR-060 (Accepted) actually implements them; ADR-052/054 move provisioning into a Rust-native Tauri desktop path. Three live decisions for "how RuView provisions nodes," with ADR-060 partially fulfilling ADR-050 without superseding it.
|
||||
|
||||
#### C. Status-graph contradictions (Accepted depending on / contradicting Proposed)
|
||||
|
||||
**[MEDIUM] Accepted ADRs hardening/depending on Proposed ones.** ADR-032 (Accepted, security hardening) hardens ADR-029/030/031 which remain "Proposed" — an accepted decision presupposing un-accepted ones exist. Same pattern: ADR-048 (Accepted) depends on ADR-045 (Proposed); ADR-053 (Accepted) depends on ADR-052 (Proposed); ADR-077 (Accepted) depends on ADR-075/076 (Proposed); ADR-104 (Accepted) depends on ADR-103 (Proposed). These are status contradictions, not capability retractions, but they signal the same "header lags reality" hygiene problem the sweep is correcting.
|
||||
|
||||
**[LOW] Header-stale-vs-implementation (pervasive).** ADR-029/030/031, 072, 095/096, 136–145, 150, 152, 154–157 all carry `Status: Proposed` while their own appended Implementation-Status notes (or downstream ADRs / CLAUDE.md) report them built+tested with commits. ADR-024/027 say Proposed; CLAUDE.md lists them Accepted; pose_tracker.rs already uses AETHER re-ID. Cosmetic but corpus-wide; it is the mechanism by which retracted/overstated claims survive (a green "built" note under a "Proposed" header is exactly where ADR-155's self-certifying proof hid).
|
||||
|
||||
#### Cited source files (absolute)
|
||||
- C:\Users\ruv\Projects\wifi-densepose\docs\adr\ADR-079-camera-ground-truth-training.md (lines 11/25/497/503 — 2.5% vs 35.3% vs 0%)
|
||||
- C:\Users\ruv\Projects\wifi-densepose\docs\adr\ADR-150-rf-foundation-encoder.md (81.63% vs 11.6%; DANN ~0)
|
||||
- C:\Users\ruv\Projects\wifi-densepose\docs\adr\ADR-152-wifi-pose-sota-2026-intake.md (F4 REFUTED 0-3; 97.25% CLAIMED-not-MEASURED)
|
||||
- C:\Users\ruv\Projects\wifi-densepose\docs\adr\ADR-154-signal-dsp-beyond-sota.md (§2 dead CIR gate)
|
||||
- C:\Users\ruv\Projects\wifi-densepose\docs\adr\ADR-155-nn-training-beyond-sota.md (§2.2–2.4 synthetic-val / fake gradient / self-certifying proof)
|
||||
- C:\Users\ruv\Projects\wifi-densepose\docs\adr\ADR-159-cognitum-appliance-beyond-sota.md (person-count 0.343; description renamed)
|
||||
|
||||
**Top-severity summary:** the four CRITICAL items (ADR-155 fake-gradient+synthetic-val+self-certifying proof; ADR-154 dead CIR gate; ADR-079 self-inconsistent PCK; ADR-161 WS auth bypass) are the corpus's load-bearing "AI-slop" admissions — each is an *accepted-or-shipped* surface whose stated accuracy/security/function was provably false until the sweep landed. Every accuracy number predating ADR-155 in any other ADR should be treated as CLAIMED, not MEASURED, until re-derived through the post-155 leak-free split.
|
||||
|
||||
---
|
||||
|
||||
## Lens 4: coverage-gaps
|
||||
|
||||
Confirmed — ADR-094 governs the pointcloud *viewer deployment* (proposed-only), not the crate's sensing-data-production contract. I have all evidence needed.
|
||||
|
||||
### Coverage Gaps — Crates/Capabilities vs Governing ADRs
|
||||
|
||||
Severity: **CRITICAL** (shipped code with no/broken governing ADR), **HIGH** (architect would expect an ADR, none exists), **MEDIUM** (governed only by a remediation/deploy ADR, no creation/architecture ADR), **LOW** (minor).
|
||||
|
||||
#### A. Shipped crates whose cited ADR does not exist (CRITICAL)
|
||||
|
||||
Two crates are built and in-tree but reference ADR numbers that point to *different* on-disk ADRs or to files that never existed (confirmed: no `ADR-131*.md` or `ADR-132*.md` exists; `ADR-134` on disk is CIR, not HOMECORE-MIGRATE):
|
||||
|
||||
- **`v2/crates/homecore-recorder`** — Cargo.toml header: *"SQLite state history + semantic search (ADR-132)"*. **No ADR-132 exists.** The HOMECORE series map (ADR-126 §4) lists ADR-132 HOMECORE-RECORDER as planned, but it was never written. A shipped persistence/history crate has zero governing decision record. **CRITICAL** — this is the recorder, the durable-state surface, ungoverned.
|
||||
- **`v2/crates/homecore-migrate`** — Cargo.toml header: *"Implements ADR-134 (HOMECORE-MIGRATE)"*. **On-disk ADR-134 is "First-Class CIR Support"** (census + glob confirm). ADR-129/126 also cite ADR-134 as HOMECORE-MIGRATE. The crate implements a migration tool from Python HA reading `.storage/*.json` — a data-integrity-sensitive importer — governed by a phantom ADR identity. **CRITICAL** (compounds the documented ADR-134 duplicate-number collision).
|
||||
|
||||
These are not stale-header issues like the ADR-136..146 cluster (where the ADR exists and is just marked Proposed); here the cited governing ADR **is absent or is a different decision**.
|
||||
|
||||
#### B. Shipped crates with NO governing ADR at all (HIGH)
|
||||
|
||||
- **`v2/crates/wifi-densepose-engine`** — *"streaming-engine integration layer — composes the ADR-135..146 building blocks into one trust-traceable pipeline cycle."* It composes ~12 ADRs' outputs into the live pipeline-cycle aggregate, but **no ADR governs the composition/orchestration contract itself** (ordering, back-pressure, the "one pipeline cycle" boundary). ADR-136 defines frame contracts/stages but not the integrator crate. An architect would expect an ADR for the seam that wires 135–146 onto the live 20 Hz path — exactly the "integration glue not yet on live path" caveat repeated across ADR-136..146. **HIGH.**
|
||||
|
||||
#### C. Capabilities governed only by a remediation/deploy ADR — no creation/architecture ADR (MEDIUM)
|
||||
|
||||
- **`v2/crates/wifi-densepose-wasm-edge` (~70 edge skills)** — The only ADRs touching it are **ADR-160** (honest *relabeling*/soundness cleanup) and **ADR-163** (latency *measurement*). Both are anti-slop remediation ADRs that presuppose ~70 skills already shipped. There is **no creation/architecture ADR** defining the skill taxonomy, ABI, event-ID allocation, or budget tiers for this crate. (Contrast ADR-041, which *does* catalog the 60-module registry — but for the ESP32/WASM3 on-device path of ADR-040, a different artifact.) A whole ~70-module crate's design rationale lives nowhere. **MEDIUM-HIGH.**
|
||||
- **`v2/crates/wifi-densepose-occworld-candle`** — *"OccWorld TransVQVAE inference ported to Candle (Rust-native, no Python IPC)."* ADR-147 (OccWorld) decided a **Python-subprocess** thin client and explicitly deferred a Rust backend swap to "Phase B / RoboOccWorld." A native Candle reimplementation is a material architecture change (new dep surface, no IPC, weight-loading path) that **no ADR records the decision to build now**. **MEDIUM.**
|
||||
- **`v2/crates/wifi-densepose-pointcloud`** — ADR-094 governs only the *GitHub-Pages viewer deployment* (Proposed). The crate as a **point-cloud data-production/format contract** (what it emits, schema, real-data-stream toggle wiring) has no governing decision beyond the demo-deploy doc. **MEDIUM.**
|
||||
- **`v2/crates/homecore-hap`** — header cites ADR-125 P1 scaffold; ADR-125 (Apple Home HAP bridge) exists and covers it. **Governed — no gap.** (Listed to scope out the false positive.)
|
||||
- **`v2/crates/wifi-densepose-geo`** — governed by ADR-044 (geospatial). Governed, but ADR-044 is a bare "Accepted" with no implementation evidence and is cross-referenced incorrectly by ADR-052 (cites ADR-044 for provisioning). **LOW** (governed but the ADR itself is thin).
|
||||
|
||||
#### D. Decision areas an architect would expect an ADR for, but none exists (HIGH)
|
||||
|
||||
1. **Persistence/storage strategy for HOMECORE state history** — `homecore-recorder` ships SQLite with an "HA-compat schema," but no ADR decides SQLite-vs-alternatives, retention, or the semantic-search index. Recorder is the durability backbone; an unrecorded storage choice is a classic missing-ADR. **HIGH** (ties to gap A).
|
||||
2. **Python-HA → HOMECORE migration/import contract** — `homecore-migrate` reads foreign `.storage` JSON (untrusted input, schema-drift risk) with no governing ADR (the cited one is CIR). Migration correctness and trust boundary are exactly what an ADR should pin. **HIGH** (ties to gap A).
|
||||
3. **The streaming-engine *integrator* contract** (`wifi-densepose-engine`) — see B. **HIGH.**
|
||||
4. **Cross-crate workspace dependency/publishing ADR** — CLAUDE.md lists a hand-maintained 12-step publishing order and a 15-crate table, but the workspace now has **38 crates** (glob count) including ungoverned ones (engine, worldmodel, worldgraph, occworld-candle, geo, wasm-edge, homecore-*, cog-*, ruview-swarm, pointcloud, nvsim-server, desktop). No ADR governs crate-graph topology / publish boundaries at this scale — the publishing list in CLAUDE.md is already stale against reality. **MEDIUM-HIGH.**
|
||||
5. **No ADR ties the streaming-engine (`engine`) to the cog/appliance deploy surface** — ADR-101/102/159 govern cogs; ADR-136..146 govern the engine; nothing decides how the trust-traceable engine output becomes a deployed cog. The seam between the two largest subsystems is ungoverned. **MEDIUM.**
|
||||
|
||||
#### E. Scoped-out false positives (verified governed)
|
||||
|
||||
- `wifi-densepose-worldmodel` → ADR-147 (OccWorld bridge). Governed.
|
||||
- `wifi-densepose-worldgraph` → ADR-139. Governed.
|
||||
- `cog-ha-matter` → ADR-116; `cog-person-count` → ADR-103; `cog-pose-estimation` → ADR-101. Governed.
|
||||
- `ruview-swarm` → ADR-148. `nvsim`/`nvsim-server` → ADR-089/092. `wifi-densepose-bfld` → ADR-118–123/141. `wifi-densepose-calibration` → ADR-151. All governed.
|
||||
- `wifi-densepose-desktop` → ADR-052/054 (contested status, but an ADR exists). Not a coverage gap (it's a status-drift issue, out of this lens's scope).
|
||||
|
||||
#### Top-priority remediation (concrete)
|
||||
1. Write **ADR-132 HOMECORE-RECORDER** (or renumber) so the shipped `homecore-recorder` SQLite/history crate has a real governing decision — **CRITICAL**.
|
||||
2. Resolve the **ADR-134 identity collision** and create a real **HOMECORE-MIGRATE** ADR for `homecore-migrate` (untrusted-import trust boundary) — **CRITICAL**.
|
||||
3. Write a **streaming-engine integrator ADR** for `wifi-densepose-engine` (the 135–146 composition contract / live-path seam) — **HIGH**.
|
||||
4. Write a **creation/architecture ADR for `wifi-densepose-wasm-edge`** (taxonomy/ABI/event-IDs) — currently only remediation ADRs (160/163) exist — **MEDIUM-HIGH**.
|
||||
5. Add an ADR for the **`occworld-candle` Rust-native backend swap** (ADR-147 only blessed the Python-subprocess path) — **MEDIUM**.
|
||||
|
||||
Evidence files: `v2/crates/homecore-recorder/Cargo.toml`, `v2/crates/homecore-migrate/Cargo.toml`, `v2/crates/wifi-densepose-engine/Cargo.toml`, `v2/crates/wifi-densepose-occworld-candle/Cargo.toml`, `docs/adr/ADR-134-csi-to-cir-time-domain-multipath.md` (CIR, not MIGRATE), `docs/adr/ADR-160-edge-skill-library-honest-labeling.md`, `docs/adr/ADR-094-pointcloud-github-pages-deployment.md`; absence confirmed via glob: no `ADR-131*`, no `ADR-132*`.
|
||||
|
||||
---
|
||||
|
||||
## Lens 5: data-hardware-gated
|
||||
|
||||
Evidence confirmed. I have enough to write the section.
|
||||
|
||||
### Open / Gated Decisions — The Real Backlog (data/hardware-gated, in-progress, or unresolved)
|
||||
|
||||
Severity = how load-bearing the unresolved decision is to a shipped claim. Sweep coverage = whether ADR-154–163 touched it.
|
||||
|
||||
#### CRITICAL — life-safety or shipped-claim surface, still gated
|
||||
|
||||
**ADR-079 — Camera Ground-Truth Training Pipeline.** *Accepted, but core decision unvalidated.* P7–P9 (real paired-data collection, training, cross-room LoRA) are **Pending** (file lines 476–478). Blocker: a real synchronized camera+ESP32 paired-capture session and GPU training run — neither done. The ADR's own baseline table is self-contradictory: text says proxy PCK@20=2.5% (lines 11, 25) yet line 497 reports 35.3% (the *target*) with line 503 confessing **upper-body joints at 0%** — the proxy has no real spatial signal. CLAUDE.local.md records the local-Windows attempt (#640) at 0% PCK. The fleet (ruvultra RTX 5080, cognitum-seed-1) is the unblock, but the decision is accepted-on-paper, not proven. **Sweep: NOT addressed** — 154–163 never touch the camera-teacher path. Real open backlog item.
|
||||
|
||||
**ADR-158 — MAT/World-Model sweep (life-safety).** *Accepted/implemented for the correctness fixes, but capability remains DATA-GATED.* The sweep honestly fixed the dangerous bugs (unified the two divergent triage engines so survivor count can't inflate from repeat detection — lines 46–56, 184–186), but explicitly grades the actual capabilities as unproven: **RF-through-rubble survivor detection = DATA-GATED** (needs instrumented rubble trials, line 37); **learned multi-person counter = DATA-GATED** on labelled multi-occupant CSI (lines 41, 173); PicoScenes/Intel-5300/Atheros live capture DATA-GATED on NIC/driver hardware (lines 177–179). **Sweep: addressed the slop, honestly deferred the capability.** This is the model the rest should follow — code is real, accuracy claim is withheld pending absent hardware. Severity CRITICAL because it is the life-safety surface; the residual gate is acceptable and labeled.
|
||||
|
||||
#### HIGH — shipped/benchmarked claim with an explicit residual gate
|
||||
|
||||
**ADR-152 — WiFi-Pose SOTA 2026 Intake.** Status header stale (says Proposed; commits + line 58 report §2.1–2.3/2.6 implemented and WiFlow-STD **MEASURED-EQUIVALENT 96.09% PCK@20** on RTX 5080). Residual gates are real and disclosed: (1) **1 of 25 verified claims REFUTED 0-3** — "ESP WiFi-6 drop-in compatible with RuView nodes" is false (WiFi-6 parts use a different CSI acquisition struct, lines 31, 123); (2) external pose numbers (PerceptAlign −60% cross-domain; UNSW MAE pose transfer) remain **CLAIMED until reproduced on our hardware** (lines 21, 27, 119–122); (3) measurement (b)/(c) open — line 111 confirms pretrained init gives optimization transfer but **no feature transfer**, and no run beat a mean-pose baseline on single-subject data, so **no CSI→pose capability is citable** until multi-subject/multi-position data exists. Blocker: heterogeneous multi-subject CSI dataset (data-gated, per ADR-150 §F3). **Sweep: this ADR *is* the prove-everything discipline applied to research intake** — gates labeled, not buried.
|
||||
|
||||
**ADR-072 / ADR-150 — WiFlow pose + RF foundation encoder.** ADR-072 >80% PCK@20 target unverifiable without camera labels (resolved-path via ADR-079, itself gated above). ADR-150 cites measured 81.63% in-domain vs **~11.6% leakage-free cross-subject** — the cross-subject collapse is real and the stated lever (ADR-152 F3) is *more heterogeneous data*, not capacity. Blocker: multi-subject/room dataset + libtorch GPU training. **Sweep: NOT directly addressed** (155 fixed PCK/OKS metric-integrity plumbing, which makes these numbers *trustworthy* but doesn't close the data gap).
|
||||
|
||||
#### HIGH — security/privacy decisions still Proposed-only (no sweep touched the gate itself)
|
||||
|
||||
**ADR-080 — QE Remediation.** Tracks unfixed security HIGH findings (X-Forwarded-For bypass, leaked stack traces, JWT-in-URL CWE-598), gate FAILED, status Proposed, no done-marking. The HOMECORE sweep (ADR-161/162) fixed *HOMECORE*'s WS-auth bypass and plugin signing — a **different** server boundary. **Sweep: did NOT cover ADR-080's sensing-server findings.** Genuine open security backlog.
|
||||
|
||||
**ADR-105→109, ADR-118–125 (BFLD/federation/fabric chains).** Entire federation chain (105–109) and BFLD surface (118–125) are Proposed-only, all ACs unchecked, several "tracking issue TBD." Blockers: KIT BFId dataset (ADR-121 calibration), Pi5/Nexmon CBFR capture hardware (ADR-123 — ESP32 *structurally cannot* sniff CBFR), Soul-Signature + cog-ha-matter dependencies (ADR-122/125). **Sweep: NOT addressed** — 154–163 stop at HOMECORE/MAT/cog/edge; the privacy control *plane* (ADR-141, built) exists but the BFLD *capture/scoring* chain it would gate does not. Backlog, honestly gated by absent hardware.
|
||||
|
||||
#### MEDIUM — hardware-gated, honestly deferred BY the sweep (lowest risk)
|
||||
|
||||
**ADR-163 — Edge-latency measurement.** *Accepted/implemented* for host benches, but the **ESP32/Xtensa on-hardware `process_frame` figure is explicitly UNMEASURED / PENDING (hardware)** (lines 31–32, 79–83, 92–93). Blocker: `wasm32-unknown-unknown` built + flashed to ESP32-S3 and timed on-device; host x86_64 median is "an upper bound on algorithm work, not the ESP32 number." This is the **gold-standard deferral**: the gate is stated everywhere, no claim overreaches. **Sweep: this *is* a sweep ADR honestly deferring its own residual.**
|
||||
|
||||
**ADR-160 — wasm-edge skill labeling.** Medical/affect/weapon capabilities explicitly **NOT validated** — relabelled/disclaimed/feature-gated rather than implemented, reference-standard-gated. **Sweep: addressed by relabeling, capability honestly deferred.**
|
||||
|
||||
**ADR-110 — ESP32-C6 firmware.** Implemented, but HE-CSI requires ESP-IDF ≥5.5 (v5.4 silently downconverts to HT) — capability hardware/toolchain-gated per WITNESS §B1. Not a sweep target; gate is a noted hardware constraint, not slop.
|
||||
|
||||
**Other purely hardware/data-gated Proposed decisions (no sweep involvement, no overreach):** ADR-023 (paired data+GPU), ADR-027/MERIDIAN (multi-env data), ADR-042 CHCI (custom PCB/TCXO — largely superseded by 153), ADR-063/064 (ESP32-C6+MR60BHA2 mmWave), ADR-065/066 (live Cognitum Seed deploy), ADR-070 (live 2-node+Seed capture), ADR-073/078 (multi-AP mesh deployment), ADR-083 (pending field evidence), ADR-086 (real-deployment suppression rates), ADR-091 (COTS sub-THz + ITAR-clear use case), ADR-103 (labelled count data), ADR-113 (Fresnel-sim, not hardware-validated), ADR-114 (real NV-diamond device), ADR-134/135 (COM9/COM12 hardware-test feature), ADR-143 v2 (7-day fleet validation campaign, dead-code until then), ADR-144 (no UWB radio in fleet).
|
||||
|
||||
#### Cross-cutting finding
|
||||
The sweep (ADR-154–163) is **narrowly scoped**: it hardened MAT (158), Cognitum cogs (159), wasm-edge (160), HOMECORE server+plugins (161/162), and latency debt (163) — converting CLAIMED→MEASURED or DATA-GATED with honest labels. It **did not** touch the two largest *capability* gaps: the **camera-teacher training validation (ADR-079/072/150)** and the **federation/BFLD privacy chains (105–109, 118–125)** — both remain data/hardware-gated and Proposed-only. The single hard contradiction worth flagging to a human: **ADR-079's baseline table reports the target (35.3%) as if achieved while the prose and #640 evidence say 2.5%/0%** — that is the one place a reader could mistake an aspiration for a measurement.
|
||||
@@ -181,7 +181,7 @@ A facade hides its failures. We document ours in detail:
|
||||
a 20 KB int4 edge model, with the quantization trade-offs shown.
|
||||
- **Retractions** — the "100% presence" figure was withdrawn in-place rather than quietly
|
||||
edited away.
|
||||
- **[ADR-147 benchmark proof](adr/ADR-147-benchmark-proof.md)** and
|
||||
- **[ADR-168 benchmark proof](adr/ADR-168-benchmark-proof.md)** and
|
||||
**[WITNESS-LOG-028](WITNESS-LOG-028.md)** — how the numbers are produced and a 33-row
|
||||
per-claim attestation matrix.
|
||||
|
||||
|
||||
@@ -33,11 +33,11 @@ Role mapping is normative per ADR-136 §2.1; maturity is this review's judgment
|
||||
| **signal** | `wifi-densepose-signal` (incl. `ruvsense/`) | 6-stage pipeline (`ruvsense/mod.rs:9-23`), `cir.rs`, `calibration.rs`, `hampel.rs`, `fresnel.rs`, `phase_sanitizer.rs` | 473 | **Production** (unit level); live multistatic wiring **beta** | §3 below; ADR-014 Accepted, ADR-029 Proposed |
|
||||
| **fusion** | `ruvsense/multistatic.rs`, `ruvsense/fusion_quality.rs`, `wifi-densepose-ruvector/src/viewpoint/` | `MultistaticFuser`, `QualityScore`, `CrossViewpointAttention`, GDI/Cramér-Rao (`viewpoint/geometry.rs`) | 20 (multistatic.rs), 3 (fusion_quality.rs), 136 (ruvector crate) | **Beta** — tested building blocks, composed only in `wifi-densepose-engine` tests | `viewpoint/mod.rs:1-30`; engine `lib.rs:317-319` |
|
||||
| **world** | `homecore`, `wifi-densepose-worldgraph`, `wifi-densepose-geo`, `wifi-densepose-worldmodel` | `StateMachine`, `EventBus`, `WorldGraph` (rooms/sensors/person-tracks/semantic states), ENU geo registration | 9+11, 7, 16+1, 12+1 | **Beta** — homecore is explicit "P1 scaffold"; persistence/service dispatch deferred to P2 | `homecore/src/lib.rs:7, 24-31`; ADR-127 Proposed |
|
||||
| **models** | `cog-pose-estimation`, `cog-person-count`, `wifi-densepose-nn`, `wifi-densepose-train`, `wifi-densepose-occworld-candle` | ONNX/Candle inference, training pipeline, OccWorld bridge | 7, 15, 30+1, 312, 12 | **Experimental** — no trained RF foundation encoder exists; ADR-147 benchmarked OccWorld with **random weights** | `ADR-147-benchmark-proof.md` ("random weights — pre-domain-fine-tuning baseline"); ADR-146/150 Proposed |
|
||||
| **models** | `cog-pose-estimation`, `cog-person-count`, `wifi-densepose-nn`, `wifi-densepose-train`, `wifi-densepose-occworld-candle` | ONNX/Candle inference, training pipeline, OccWorld bridge | 7, 15, 30+1, 312, 12 | **Experimental** — no trained RF foundation encoder exists; ADR-147 benchmarked OccWorld with **random weights** | `ADR-168-benchmark-proof.md` ("random weights — pre-domain-fine-tuning baseline"); ADR-146/150 Proposed |
|
||||
| **privacy** | `wifi-densepose-bfld` | `privacy_gate.rs`, `privacy_mode.rs` (mode registry + hash-chained attestation), `identity_risk.rs`, `signature_hasher.rs`, `embedding_ring.rs` | 369 | **Beta** — strongest-tested layer, but lib header still says "Status: P1 in progress" (`lib.rs:12`, stale vs 20 implemented modules) | ADR-118–123, 141 all Proposed |
|
||||
| **store** | `homecore-recorder` | trajectory/event recording | 8+12 | **Experimental** | ADR-136 §2.1 |
|
||||
| **api** | `homecore-api`, `homecore-server`, `cog-ha-matter`, `homecore-hap` | REST/WS, HA discovery, Matter, HomeKit | 7+11, 0, 63+1, 15+2 | **Experimental→Beta** (`homecore-server` has zero tests) | ADR-130/125/115 Proposed |
|
||||
| **eval** | `wifi-densepose-train/src/ablation.rs`, `ruview-swarm/src/evals/` | ablation harness (ADR-145), swarm eval suite (ADR-149) | included in 312 / 115 | **Experimental** — ADR-145 self-labels "skeleton/scaffolding, mostly not yet on the live 20 Hz path" | `ablation.rs` exists; ADR-149 (swarm benchmarking) Accepted |
|
||||
| **eval** | `wifi-densepose-train/src/ablation.rs`, `ruview-swarm/src/evals/` | ablation harness (ADR-145), swarm eval suite (ADR-171) | included in 312 / 115 | **Experimental** — ADR-145 self-labels "skeleton/scaffolding, mostly not yet on the live 20 Hz path" | `ablation.rs` exists; ADR-171 (swarm benchmarking, renumbered from ADR-149) Accepted |
|
||||
| **observe** | `homecore-automation`, `homecore-assist` | automation engine, assistant/Ruflo bridge | 20+14, 3+20 | **Experimental** | ADR-129/133 Proposed |
|
||||
| **(integration root)** | `wifi-densepose-engine` | `StreamingEngine`, `TrustedOutput`, privacy demotion, witness | 11 | **Beta** — the only crate that proves cross-role composition; not on a live I/O path | `engine/src/lib.rs:1-29, 457-751` |
|
||||
| **(swarm)** | `ruview-swarm` | Raft/gossip topology, RRT-APF planning, Candle PPO MARL, CSI sensing payload, failsafe, Ruflo | 115+19 | **Experimental/simulation** — M3 needs real ESP32-S3 hardware | ADR-148:940-953 ("Overall ~98%", M3 85%) |
|
||||
@@ -148,7 +148,7 @@ This is genuinely strong design. But all inputs are synthetic `MultiBandCsiFrame
|
||||
| R5 | **Float nondeterminism in fusion** across thread counts could silently break the witness/replay contract once wired | Medium | High | ADR-136 §3.3 risk table (project's own assessment) |
|
||||
| R6 | **Privacy bypass via unwired paths**: BFLD invariants are enforced per-module, but until the engine is the *only* route from ingest to API, a sensing-server endpoint can emit ungated state (sensing-server already has 30+ modules incl. pose/vitals APIs predating the control plane) | Medium | Critical | `sensing-server/src/` module list vs engine isolation |
|
||||
| R7 | **Hardware dependence + scale**: multistatic TDMA/channel-hopping timing validated on small ESP32 sets; ADR-148 M3 explicitly blocked on real hardware; clock-quality model in engine uses a hardcoded `ClockQualityScore` (`engine/src/lib.rs:384`) | Medium | High | ADR-148:946; hardcoded 50 µs stdev |
|
||||
| R8 | **ADR/doc/status drift**: 150 ADRs with near-universal "Proposed" status, stale in-source status headers (`bfld/src/lib.rs:12`), CLAUDE.md "16 ruvsense modules" vs 22 on disk, duplicate ADR numbers (two ADR-050s, two ADR-147s, two ADR-149s, ADR-052 ×2) — institutional-memory value degrades | High | Medium | `ls docs/adr/`; this review §3 |
|
||||
| R8 | **ADR/doc/status drift**: 150 ADRs with near-universal "Proposed" status, stale in-source status headers (`bfld/src/lib.rs:12`), CLAUDE.md "16 ruvsense modules" vs 22 on disk, duplicate ADR numbers (two ADR-050s, two ADR-147s, two ADR-149s, ADR-052 ×2 — **now RESOLVED: displaced files renumbered to ADR-166…171 per ADR-164 G1**) — institutional-memory value degrades | High | Medium | `ls docs/adr/`; this review §3 |
|
||||
| R9 | **Workspace breadth vs maintenance capacity**: 38 workspace crates + 4 vendored subtrees + Python archive + firmware; several crates have 0 tests (`homecore-server`, `nvsim-server`, `wifi-densepose-wasm`, `homecore-plugin-example`); bus factor appears to be ~1 | High | Medium | crate test-count table §2 |
|
||||
| R10 | **Eval debt**: no end-to-end accuracy benchmark on real CSI with ground truth exists in-repo (ADR-145 harness is scaffolding; ADR-079 camera ground truth not exercised here) — "beyond SOTA" claims are currently unfalsifiable | High | High | ADR-145 status note; absence of ground-truth datasets in tree |
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ published from the layer it lives at.
|
||||
|-------|----------------|---------|-----------|-------------|
|
||||
| **L0** Unit/integration tests | Code correctness | `cargo test --workspace --no-default-features` + pytest | per commit | exact |
|
||||
| **L1** Deterministic proof + witness bundle | Pipeline is real, unchanged, reproducible | `archive/v1/data/proof/verify.py`, `scripts/generate-witness-bundle.sh` | per merge / release | exact (SHA-256) |
|
||||
| **L2** Criterion micro-benchmarks | Compute latency only — never quality (ADR-149 §2) | 15 bench targets across `v2/crates/*/benches/` | nightly / pre-release | statistical |
|
||||
| **L2** Criterion micro-benchmarks | Compute latency only — never quality (ADR-171 §2) | 15 bench targets across `v2/crates/*/benches/` | nightly / pre-release | statistical |
|
||||
| **L3** Dataset-level accuracy eval | Pose/presence/vitals quality vs published SOTA | MM-Fi / Wi-Pose (ADR-015), `ruview_metrics.rs` tiers, ADR-145 ablation harness | per model release | seeded |
|
||||
| **L4** Hardware-in-loop | Real CSI on real ESP32, no mocks | COM9 (S3) / COM12 (C6) protocol, witness firmware hashes | per firmware release | A/B controlled |
|
||||
| **L5** Field trials / live capture | End-to-end behavior in a real room | live-session captures (e.g. `benchmark_baseline.json`) | campaign | statistical |
|
||||
@@ -69,7 +69,7 @@ from the check inventory.
|
||||
|
||||
### 1.3 L2 — Criterion micro-benchmark inventory (all 15 targets)
|
||||
|
||||
All bench sources read directly. Per ADR-149 §2 these are **latency regression gates
|
||||
All bench sources read directly. Per ADR-171 §2 these are **latency regression gates
|
||||
only, never quality evidence**.
|
||||
|
||||
| Bench target | Crate | Benchmark functions / groups | What it measures | Recorded value or in-source target (citation) |
|
||||
@@ -86,7 +86,7 @@ only, never quality evidence**.
|
||||
| `detection_bench.rs` | wifi-densepose-mat | `breathing_detection`, `heartbeat_detection`, `movement_classification`, `detection_pipeline`, localization (triangulation/depth), alert generation | MAT survivor-detection algorithms at varying signal lengths / noise | no recorded baseline |
|
||||
| `transport_bench.rs` | wifi-densepose-hardware | `beacon_serialize_16byte/28byte_auth/quic_framed`, `auth_beacon_verify`, `replay_window`, `framed_message` encode/decode, `secure_tdm_cycle` (manual vs QUIC) | TDM beacon crypto + transport | no recorded baseline |
|
||||
| `mqtt_throughput.rs` | wifi-densepose-sensing-server | `discovery::build_*`, `state::*`, `rate_limiter::allow_*`, `privacy::decide_*`, `semantic::bus_tick_all_10_primitives` | ADR-115 MQTT hot path | Targets (header): discovery **<5 µs**, state encode **<2 µs**, rate limit **<100 ns**, privacy **<50 ns**, bus tick **<10 µs** |
|
||||
| `swarm_bench.rs` | ruview-swarm | `marl_actor_inference`, `rrt_apf_100iter`, `multiview_fusion_3drones`, `demo_coverage_estimate`, `ppo_update_64transitions` | ADR-148 swarm control-loop compute | Measured: **3.3 µs / 43 µs / 54–58.5 ns / 100 ps / 248 µs** (ADR-149 §4.3; `CHANGELOG.md` Performance section) |
|
||||
| `swarm_bench.rs` | ruview-swarm | `marl_actor_inference`, `rrt_apf_100iter`, `multiview_fusion_3drones`, `demo_coverage_estimate`, `ppo_update_64transitions` | ADR-148 swarm control-loop compute | Measured: **3.3 µs / 43 µs / 54–58.5 ns / 100 ps / 248 µs** (ADR-171 §4.3; `CHANGELOG.md` Performance section) |
|
||||
| `pipeline_throughput.rs` | nvsim | `pipeline_run` (sample-count sweep), `witness::run` vs `run_with_witness` | NV-diamond sim throughput + witness overhead | Acceptance: **≥1 kHz** simulated samples/s on Cortex-A53-class CPU — bench header |
|
||||
| `state_machine.rs` | homecore | `set` first/warm/no-op, `get` hit/miss, `all_snapshot`, `all_by_domain_light_20_of_100`, `broadcast_fan_out` | HOMECORE state-machine hot paths | no recorded baseline |
|
||||
|
||||
@@ -109,7 +109,7 @@ file itself); its producer must be identified and committed (§5.3). Summary val
|
||||
| `person_count_changes` | 10 |
|
||||
|
||||
Criterion latencies that *have* been recorded live in ADR documents instead
|
||||
(ADR-147-benchmark-proof.md, ADR-149 §4.3, CHANGELOG Performance) — §5 below defines
|
||||
(ADR-168-benchmark-proof.md, ADR-171 §4.3, CHANGELOG Performance) — §5 below defines
|
||||
how to consolidate them into a real machine-readable criterion baseline.
|
||||
|
||||
### 1.4 L3 — Dataset-level accuracy evaluation
|
||||
@@ -150,7 +150,7 @@ how to consolidate them into a real machine-readable criterion baseline.
|
||||
### 1.6 L5 — Field trials
|
||||
|
||||
Live multi-node sessions captured as JSONL/JSON with summary statistics —
|
||||
`benchmark_baseline.json` (§1.3) is the existing exemplar. ADR-149 §6 adds the seeded
|
||||
`benchmark_baseline.json` (§1.3) is the existing exemplar. ADR-171 §6 adds the seeded
|
||||
`evals/` episode harness (Stage 1 kinematic full-matrix, Stage 2 Gazebo/PX4 SITL on the
|
||||
3 median seeds) for the swarm domain.
|
||||
|
||||
@@ -168,42 +168,42 @@ statistical procedure of §3 followed. Current axes with measured status:
|
||||
| Edge efficiency frontier | torso-PCK@20 at deployed precision + params + batch-1 latency | same | MultiFormer 72.25% at full size | Pareto-dominance: smaller **and** above 72.25% at the deployed precision | int8 73.5 KB **74.70%**; int4-QAT 36.7 KB **74.46%**; shipped int4 verified **74.08%**, 0.135 ms 1-thread x86 (same file) |
|
||||
| Cross-subject generalization | torso-PCK@20, official MM-Fi cross-subject split (256,608 train / 64,152 test) | leakage-free split | own zero-shot baseline 63.99% | ADR-150 §4 gate: **+≥6 pts cross-subject without losing >2 pts random-split** | Best zero-shot **64.92%** (mixup+TTA+3-seed); gate judged unreachable without new capture (ADR-150 §3.2) |
|
||||
| Few-shot calibration (deployment) | PCK@20 after K labeled in-room samples; adapter size | MM-Fi cross-subject & cross-environment splits | zero-shot (64% / 10.6%) | SOTA-level (≳72%) from ≤200 samples with ≤~11 KB per-room adapter | cross-subject ~**72%** @100–200 samples (3 seeds); cross-env **10.6→73.1%** @200, 60.1% @5 (ADR-150 §3.5–3.6) |
|
||||
| Swarm SAR localization | CEP50/CEP95 (m), GDOP-stratified | seeded episode distribution (ADR-149 §6), not single geometry | Wi2SAR **5 m** (arxiv 2604.09115, paper-to-paper) | CEP50 < 5 m, IQM over ≥10 seeds, 95% CI excluding 5 m | 1.732 m single synthetic geometry — graded **Low–Medium**, not yet claimable (ADR-149 §7) |
|
||||
| Swarm coverage | coverage-rate@240 s; time-to-95% | episode rollouts | Wi2SAR 160k m²/13.5 min | rollout (not analytic) mean+CI beating baseline | 223 s is an analytic estimate — graded **Low** (ADR-149 §7) |
|
||||
| Control-loop latency | criterion wall-clock | local hardware, named | 10 ms / 100 Hz budget | all stages ≪ budget | 3.3 µs MARL / 43 µs RRT-APF / 54 ns fusion / 248 µs PPO (ADR-149 §4.3) |
|
||||
| World-model trajectory | MDE (m) at 5-frame horizon | RuView CSI-derived occupancy | pre-fine-tune random-weight baseline 9.49 m MDE | **≤1.0 m (2.0 vox)** at 5-frame horizon (ADR-147 §5 target, cited in benchmark-proof §4) | 9.49 m / FDE 16.23 m random weights; 208.45 ms median latency on real CSI (ADR-147-benchmark-proof §4, §7) |
|
||||
| Swarm SAR localization | CEP50/CEP95 (m), GDOP-stratified | seeded episode distribution (ADR-171 §6), not single geometry | Wi2SAR **5 m** (arxiv 2604.09115, paper-to-paper) | CEP50 < 5 m, IQM over ≥10 seeds, 95% CI excluding 5 m | 1.732 m single synthetic geometry — graded **Low–Medium**, not yet claimable (ADR-171 §7) |
|
||||
| Swarm coverage | coverage-rate@240 s; time-to-95% | episode rollouts | Wi2SAR 160k m²/13.5 min | rollout (not analytic) mean+CI beating baseline | 223 s is an analytic estimate — graded **Low** (ADR-171 §7) |
|
||||
| Control-loop latency | criterion wall-clock | local hardware, named | 10 ms / 100 Hz budget | all stages ≪ budget | 3.3 µs MARL / 43 µs RRT-APF / 54 ns fusion / 248 µs PPO (ADR-171 §4.3) |
|
||||
| World-model trajectory | MDE (m) at 5-frame horizon | RuView CSI-derived occupancy | pre-fine-tune random-weight baseline 9.49 m MDE | **≤1.0 m (2.0 vox)** at 5-frame horizon (ADR-147 §5 target, cited in benchmark-proof §4) | 9.49 m / FDE 16.23 m random weights; 208.45 ms median latency on real CSI (ADR-168-benchmark-proof §4, §7) |
|
||||
| Privacy leakage | MIA `leakage_score = 2·(AUC−0.5)` | fixed replay, fixed-seed shadow classifier | chance (0) | ≤ **0.05** (attacker AUC ≤ 0.525) | gate defined, harness built (ADR-145 §2.3) |
|
||||
| Vitals (hardware) | BPM error vs wearable ground truth | live A/B board protocol | control board behavior | within physiological agreement of ground truth, stable spread | 88–91 BPM vs 87 GT, spread 59→0 (CHANGELOG #987) |
|
||||
|
||||
### Claim-language discipline (from ADR-149 §7 grading)
|
||||
### Claim-language discipline (from ADR-171 §7 grading)
|
||||
|
||||
| Evidence | Permitted language |
|
||||
|---|---|
|
||||
| Single run / single geometry / analytic estimate | "directional", never "beats SOTA" |
|
||||
| Seeded multi-run with CIs vs paper baseline | "exceeds the published X result paper-to-paper" |
|
||||
| Same metric, same split, same protocol, CI excludes baseline | "beyond SOTA on <dataset>/<split>" |
|
||||
| No public leaderboard exists (swarm CSI-SAR) | never claim "leaderboard standing" (ADR-149 §3) |
|
||||
| No public leaderboard exists (swarm CSI-SAR) | never claim "leaderboard standing" (ADR-171 §3) |
|
||||
|
||||
---
|
||||
|
||||
## 3. Statistical Procedure for Honest Claims
|
||||
|
||||
Adopted from ADR-149 §5 (Agarwal 2021 / Gorsane 2022 standard) and the practices
|
||||
Adopted from ADR-171 §5 (Agarwal 2021 / Gorsane 2022 standard) and the practices
|
||||
already used in ADR-150/efficiency-frontier measurements:
|
||||
|
||||
1. **Seeds.** ≥10 independent seeds for RL/episodic claims (ADR-149 §5); ≥3 seeds
|
||||
1. **Seeds.** ≥10 independent seeds for RL/episodic claims (ADR-171 §5); ≥3 seeds
|
||||
minimum for supervised dataset evals (ADR-150 §3.5 used 3 seeds; report all).
|
||||
Training seeds, eval seeds, and split files are versioned and committed.
|
||||
2. **Aggregate.** IQM (not mean/median) for episodic metrics + performance profiles;
|
||||
for dataset accuracy report mean across seeds with each seed's value listed.
|
||||
3. **Confidence intervals.** 95% stratified bootstrap, 1,000 resamples (ADR-149 §5;
|
||||
3. **Confidence intervals.** 95% stratified bootstrap, 1,000 resamples (ADR-171 §5;
|
||||
reference impl: `rliable`).
|
||||
4. **Paired comparisons.** When comparing model A vs B (e.g. `csi_plus_cir` vs
|
||||
`csi_only`, or ours vs a reproduced baseline), evaluate both on the **identical
|
||||
frozen test frames** and use a paired bootstrap over per-sample correctness
|
||||
(PCK hit/miss is per-joint binary — pair at the joint-sample level). For
|
||||
paper-to-paper comparisons where the baseline cannot be re-run, state so
|
||||
explicitly ("paper-to-paper", ADR-149 §2) and require the CI lower bound to clear
|
||||
explicitly ("paper-to-paper", ADR-171 §2) and require the CI lower bound to clear
|
||||
the published point value.
|
||||
5. **Pre-registration.** The threshold lives in an ADR **before** the run
|
||||
(precedent: ADR-150 §4 gate written before §3.2 measurements; the measurements
|
||||
@@ -212,9 +212,9 @@ already used in ADR-150/efficiency-frontier measurements:
|
||||
capacity-hurts, and KD-didn't-help results in the record — required practice.
|
||||
7. **Eval episodes (swarm):** 50 fixed, versioned episodes per policy
|
||||
(10 victim layouts × 5 CSI-noise levels), ≥3 baselines (random walk,
|
||||
boustrophedon+triangulation, IPPO) (ADR-149 §5).
|
||||
boustrophedon+triangulation, IPPO) (ADR-171 §5).
|
||||
8. **GDOP stratification** for any localization claim, so geometry artifacts cannot
|
||||
produce the headline (ADR-149 §6.3).
|
||||
produce the headline (ADR-171 §6.3).
|
||||
|
||||
---
|
||||
|
||||
@@ -230,7 +230,7 @@ already used in ADR-150/efficiency-frontier measurements:
|
||||
|
||||
### 4.2 Criterion baseline file (replaces the current gap)
|
||||
|
||||
Today criterion numbers live in prose (ADR-147-benchmark-proof, ADR-149 §4.3,
|
||||
Today criterion numbers live in prose (ADR-168-benchmark-proof, ADR-171 §4.3,
|
||||
CHANGELOG). Formalize:
|
||||
|
||||
1. `cargo bench --workspace -- --save-baseline main` on a **named, fixed runner**
|
||||
@@ -293,7 +293,7 @@ Anyone outside the project must be able to re-run every claimed result:
|
||||
(`calibration_proof_runner.rs` pattern, ADR-145 §2.6) for libm portability.
|
||||
3. **Seeds are constants, committed:** `PROOF_SEED=42`, `MODEL_SEED=0`
|
||||
(`proof.rs`, ADR-015 Phase 5); dataset splits committed as `.npy`
|
||||
(`split_random.npy`); swarm configs as versioned YAML with all seeds (ADR-149 §5).
|
||||
(`split_random.npy`); swarm configs as versioned YAML with all seeds (ADR-171 §5).
|
||||
4. **Artifacts carry hashes.** Published model artifacts include SHA-256 (HuggingFace
|
||||
`pose_micro_int4.npz`, sha256 `c03eeb…` — efficiency-frontier doc); witness bundle
|
||||
has a `MANIFEST.sha256` over every file; provenance fields
|
||||
@@ -318,9 +318,9 @@ Anyone outside the project must be able to re-run every claimed result:
|
||||
| 1 | **Subject leakage / split optimism.** In-domain `random_split` has temporal/subject-adjacency effects; the same model family scores 83.6% random-split but ~11.6% torso-PCK on the leakage-free cross-subject split | efficiency-frontier "Controlled claim" footnote; ADR-150 §1, §3.2 | Always report the split name; publish random-split and cross-subject numbers side by side; cross-subject claims only on the official split |
|
||||
| 2 | **Per-environment overfitting.** Zero-shot cross-environment collapses to 10.6%; subject-scaling saturates ~63.7% past 16–20 subjects because the residual is room/device shift | ADR-150 §3.3, §3.6 | Cross-room degradation + 17-joint heatmap in every ablation (ADR-145 §2.5); claim deployment accuracy only with the calibration protocol stated (K samples, adapter size) |
|
||||
| 3 | **Mock-mode contamination.** Mock firmware missed a real Kconfig threshold bug; the nn crate ships a `mock_inference` criterion group that must never be quoted as pipeline performance | `CLAUDE.md` firmware rule 7; `inference_bench.rs` `bench_mock_inference` | L4 mandatory before firmware release ("Always test with real WiFi CSI, not mock mode"); label mock benches in reports; ADR-147 §7 re-ran the benchmark on real CSI explicitly "no mocks" |
|
||||
| 4 | **Single-run point estimates.** 1.732 m localization from one synthetic geometry; 223 s coverage from an analytic formula | ADR-149 §1, §7 | §3 seed/CI protocol; evidence-grade table before publication |
|
||||
| 5 | **Random-weight / untrained baselines read as results.** OccWorld MDE 9.49 m is a pre-fine-tuning random-weight reading | ADR-147-benchmark-proof §4 | Label baseline-vs-target explicitly; never aggregate untrained-model numbers into capability claims |
|
||||
| 6 | **Latency conflated with quality.** Criterion µs numbers prove no compute bottleneck, nothing about accuracy | ADR-149 §2, §4.3 | L2 is gate-only; quality claims live in L3+ |
|
||||
| 4 | **Single-run point estimates.** 1.732 m localization from one synthetic geometry; 223 s coverage from an analytic formula | ADR-171 §1, §7 | §3 seed/CI protocol; evidence-grade table before publication |
|
||||
| 5 | **Random-weight / untrained baselines read as results.** OccWorld MDE 9.49 m is a pre-fine-tuning random-weight reading | ADR-168-benchmark-proof §4 | Label baseline-vs-target explicitly; never aggregate untrained-model numbers into capability claims |
|
||||
| 6 | **Latency conflated with quality.** Criterion µs numbers prove no compute bottleneck, nothing about accuracy | ADR-171 §2, §4.3 | L2 is gate-only; quality claims live in L3+ |
|
||||
| 7 | **Floating-point nondeterminism breaking proofs.** SciPy FFT SIMD reordering + multithreaded BLAS produced different hashes across CI microarchitectures | CHANGELOG #560; `calibration_proof_runner.rs` lines 1–13 (cited in ADR-145 §2.3) | Quantize before hashing; pin thread env vars; exclude wall-clock from hashes |
|
||||
| 8 | **Hash churn without procedure.** Three distinct historical values of the proof hash exist (`8c0680d7…` ADR-028, `667eb054…` CHANGELOG #560, `f8e76f21…` current file) | cited files | Every regeneration via `--generate-hash` + re-verify + CHANGELOG entry + witness bundle refresh |
|
||||
| 9 | **Aggregation bugs masking accuracy.** Person count clamped to 1 by EMA mapping; eigenvalue path leaking counts up to 10; both invisible to unit tests for months | CHANGELOG #803, #894 | L5 summary gates on `person_count_changes`/count distributions; convergence tests replaying the live loop |
|
||||
@@ -336,7 +336,7 @@ Anyone outside the project must be able to re-run every claimed result:
|
||||
| Machine-readable criterion baseline (`v2/benchmarks/criterion-baseline.json`) + CI comparison job | L2 | §4.2 (numbers currently only in ADR prose) |
|
||||
| Provenance + producer script for `benchmark_baseline.json`; soft-gate job | L5 | §1.3, §4.3 (zero code references today) |
|
||||
| `ruview-cli --ablation mode=auto` wiring + `expected_ablation_<slug>.sha256` (currently placeholders → exit 2) | L3 | ADR-145 implementation status |
|
||||
| Seeded swarm `evals/` harness + `evals/RESULTS.md` internal leaderboard | L3/L5 | ADR-149 §6, §8 open issues |
|
||||
| Seeded swarm `evals/` harness + `evals/RESULTS.md` internal leaderboard | L3/L5 | ADR-171 §6, §8 open issues |
|
||||
| Fix `VERIFY.sh` hardcoded verdict count; reconcile `CLAUDE.md` "7/7" | L1 | §1.2 |
|
||||
| Curated paired room-A/room-B labeled replay set (frozen, SHA-pinned, never trained on) | L3 | ADR-145 §3.2 |
|
||||
| ARM/edge on-device latency validation for the int4 model (x86-only today) | L4 | efficiency-frontier doc ("Pi fleet pending") |
|
||||
@@ -372,8 +372,8 @@ failing test, not a slogan.
|
||||
---
|
||||
|
||||
*All values cited from: `benchmark_baseline.json`, `v2/crates/*/benches/*.rs` (15
|
||||
files), `docs/adr/ADR-147-benchmark-proof.md`,
|
||||
`docs/adr/ADR-149-swarm-benchmarking-evaluation-methodology.md`,
|
||||
files), `docs/adr/ADR-168-benchmark-proof.md`,
|
||||
`docs/adr/ADR-171-swarm-benchmarking-evaluation-methodology.md`,
|
||||
`docs/adr/ADR-145-ablation-eval-harness-privacy-leakage.md`,
|
||||
`docs/adr/ADR-028-esp32-capability-audit.md`,
|
||||
`docs/adr/ADR-015-public-dataset-training-strategy.md`,
|
||||
|
||||
@@ -15,7 +15,7 @@ validation pass run against the working tree.
|
||||
| [00-system-review.md](00-system-review.md) | Capability audit of the current engine | Signal layer is the deepest asset (`ruvsense/` ≈14.4k lines, 310 in-module tests); the model tier is the emptiest (no trained checkpoint in-tree); the live 20 Hz path is the main integration gap |
|
||||
| [01-sota-landscape-2026.md](01-sota-landscape-2026.md) | Published SOTA per capability axis (web-verified) | Defines the beyond-SOTA bar: 12-row capability → published SOTA → RuView-today → target table; IEEE 802.11bf-2025 is ratified and moves the moat up-stack |
|
||||
| [02-beyond-sota-architecture.md](02-beyond-sota-architecture.md) | Target architecture | 8 pillars (RF foundation encoder + UQ heads, differentiable RF forward model, RF-SLAM×WorldGraph loop, camera→RF distillation, swarm apertures, continual adaptation, deterministic WASM edge, NV fusion) — all landing inside existing crates, no rewrite (per ADR-136 §2.1) |
|
||||
| [03-benchmark-validation-methodology.md](03-benchmark-validation-methodology.md) | Test/validation/benchmark methodology | 6-layer validation pyramid; 15 criterion bench targets inventoried; `benchmark_baseline.json` is a live-capture anchor, not a criterion baseline; statistical protocol from ADR-149 (≥10 seeds, IQM, bootstrap CIs) |
|
||||
| [03-benchmark-validation-methodology.md](03-benchmark-validation-methodology.md) | Test/validation/benchmark methodology | 6-layer validation pyramid; 15 criterion bench targets inventoried; `benchmark_baseline.json` is a live-capture anchor, not a criterion baseline; statistical protocol from ADR-171 (≥10 seeds, IQM, bootstrap CIs) |
|
||||
| [04-optimization-roadmap.md](04-optimization-roadmap.md) | Performance review + 90-day plan | ISTA CIR solver is the dominant latency hazard (~1.1 GFLOP/frame at HE40); exact zero-risk wins identified; WorldGraph grows unboundedly (no eviction) — a real bug-class |
|
||||
|
||||
## Validation results (this session, 2026-06-09)
|
||||
@@ -83,7 +83,7 @@ Correctness post-optimization: `wifi-densepose-signal` 456 tests green;
|
||||
|
||||
1. **"Beyond SOTA" is currently unfalsifiable** without a real-CSI
|
||||
ground-truth benchmark — standing one up (per doc 03's acceptance table
|
||||
and ADR-149's statistical protocol) is the highest-leverage next step.
|
||||
and ADR-171's statistical protocol) is the highest-leverage next step.
|
||||
2. **The path is evolution, not rewrite**: all eight architecture pillars in
|
||||
doc 02 land inside existing crates on the ADR-136 `Stage<I,O>`/`FrameMeta`
|
||||
contract spine.
|
||||
|
||||
+3
-3
@@ -1113,7 +1113,7 @@ The Observatory is an immersive Three.js visualization that renders WiFi sensing
|
||||
|
||||
A pretrained CSI encoder + presence-detection head is published on Hugging Face at [`ruvnet/wifi-densepose-pretrained`](https://huggingface.co/ruvnet/wifi-densepose-pretrained). It was trained on 60,630 frames / 610,615 contrastive triplets (12.2M steps, final loss 0.065) and reports **82.3% held-out temporal-triplet accuracy** (the older "100% presence" figure was measured on a single-class recording and has been retracted) and ~164k embeddings/sec on an Apple M4 Pro.
|
||||
|
||||
> **Results & proof.** The SOTA 17-keypoint pose model is published separately at [`ruvnet/wifi-densepose-mmfi-pose`](https://huggingface.co/ruvnet/wifi-densepose-mmfi-pose) — **82.69% torso-PCK@20** on MM-Fi (83.59% ensemble + TTA), beating MultiFormer (72.25%) and CSI2Pose (68.41%). Browse the auditable [AetherArena leaderboard Space](https://huggingface.co/spaces/ruvnet/aether-arena), the full [MM-Fi study](benchmarks/mmfi-wifi-sensing-study.md), and the [efficiency frontier](benchmarks/wifi-pose-efficiency-frontier.md). Reproduce the deterministic pipeline proof with `python archive/v1/data/proof/verify.py` (must print `VERDICT: PASS`; see [ADR-147 benchmark proof](adr/ADR-147-benchmark-proof.md) and [WITNESS-LOG-028](WITNESS-LOG-028.md)).
|
||||
> **Results & proof.** The SOTA 17-keypoint pose model is published separately at [`ruvnet/wifi-densepose-mmfi-pose`](https://huggingface.co/ruvnet/wifi-densepose-mmfi-pose) — **82.69% torso-PCK@20** on MM-Fi (83.59% ensemble + TTA), beating MultiFormer (72.25%) and CSI2Pose (68.41%). Browse the auditable [AetherArena leaderboard Space](https://huggingface.co/spaces/ruvnet/aether-arena), the full [MM-Fi study](benchmarks/mmfi-wifi-sensing-study.md), and the [efficiency frontier](benchmarks/wifi-pose-efficiency-frontier.md). Reproduce the deterministic pipeline proof with `python archive/v1/data/proof/verify.py` (must print `VERDICT: PASS`; see [ADR-168 benchmark proof](adr/ADR-168-benchmark-proof.md) and [WITNESS-LOG-028](WITNESS-LOG-028.md)).
|
||||
|
||||
What it ships (and what it does not):
|
||||
|
||||
@@ -1289,7 +1289,7 @@ Once trained, the adaptive model runs automatically:
|
||||
RuView integrates [OccWorld](https://github.com/wzzheng/OccWorld) (ECCV 2024) to predict
|
||||
future 3D occupancy from WiFi CSI — extending the Kalman tracker's 5-frame horizon to
|
||||
15 predicted frames (~7 s). See [ADR-147](adr/ADR-147-nvidia-cosmos-world-foundation-model-integration.md)
|
||||
and the [benchmark proof](adr/ADR-147-benchmark-proof.md) for full details.
|
||||
and the [benchmark proof](adr/ADR-168-benchmark-proof.md) for full details.
|
||||
|
||||
**Hardware requirement:** NVIDIA GPU with ≥4 GB VRAM (validated: RTX 5080 at 209 ms / 3.4 GB).
|
||||
|
||||
@@ -1869,7 +1869,7 @@ Pre-trained models are available on HuggingFace:
|
||||
- **SOTA MM-Fi pose model** (82.69% torso-PCK@20) — https://huggingface.co/ruvnet/wifi-densepose-mmfi-pose
|
||||
- **AetherArena leaderboard Space** — https://huggingface.co/spaces/ruvnet/aether-arena
|
||||
|
||||
Download and start sensing immediately — no datasets, no GPU, no training needed. Results are reproducible via `python archive/v1/data/proof/verify.py` (deterministic SHA-256 proof) — see [ADR-147](adr/ADR-147-benchmark-proof.md).
|
||||
Download and start sensing immediately — no datasets, no GPU, no training needed. Results are reproducible via `python archive/v1/data/proof/verify.py` (deterministic SHA-256 proof) — see [ADR-168](adr/ADR-168-benchmark-proof.md).
|
||||
|
||||
### Quick Start with Pre-Trained Models
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# homecore-migrate — Migration tooling from Python Home Assistant.
|
||||
# Implements ADR-134 (HOMECORE-MIGRATE), P1 scaffold:
|
||||
# Implements ADR-165 (HOMECORE-MIGRATE), P1 scaffold:
|
||||
# (was cited as "ADR-134"; renumbered to ADR-165 — on-disk ADR-134 is CIR. See ADR-164/ADR-165.)
|
||||
# - HaStorageDir + HaStorageEnvelope: reads `.storage/*.json` files
|
||||
# - Versioned format parsers under `storage_format::v<N>`
|
||||
# - entity_registry, device_registry, config_entries parsers
|
||||
@@ -14,7 +15,7 @@ version = "0.1.0-alpha.0"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
authors = ["rUv <ruv@ruv.net>", "HOMECORE Contributors"]
|
||||
description = "Migration tooling from Python Home Assistant to HOMECORE (ADR-134 P1 scaffold)"
|
||||
description = "Migration tooling from Python Home Assistant to HOMECORE (ADR-165 P1 scaffold)"
|
||||
repository = "https://github.com/ruvnet/RuView"
|
||||
|
||||
[[bin]]
|
||||
|
||||
@@ -6,7 +6,7 @@ Migration tooling for importing Home Assistant configuration, entities, and secr
|
||||

|
||||

|
||||
[](https://github.com/ruvnet/RuView)
|
||||
[](../../docs/adr/ADR-134-homecore-migration-from-python-ha.md)
|
||||
[](../../docs/adr/ADR-165-homecore-migrate-from-home-assistant.md)
|
||||
|
||||
Parse and inspect Home Assistant's `.storage/` directory, entity registry, device registry, secrets, and automations. Convert existing HA configurations for import into HOMECORE (full conversion in P2).
|
||||
|
||||
@@ -22,7 +22,7 @@ Parse and inspect Home Assistant's `.storage/` directory, entity registry, devic
|
||||
- **Automations parser** — reads `automations.yaml` and counts/lists automations (full conversion in P2)
|
||||
- **CLI binary** — `homecore-migrate inspect` to preview what will be migrated
|
||||
|
||||
The tool enforces version schema compatibility: unknown HA schema versions are rejected (hard error per ADR-134 §6 Q5) rather than silently corrupting data.
|
||||
The tool enforces version schema compatibility: unknown HA schema versions are rejected (hard error per ADR-165 §6 Q5) rather than silently corrupting data.
|
||||
|
||||
## Features
|
||||
|
||||
@@ -136,7 +136,7 @@ homecore-migrate (import from HA)
|
||||
|
||||
## References
|
||||
|
||||
- [ADR-134: HOMECORE Migration from Python Home Assistant](../../docs/adr/ADR-134-homecore-migration-from-python-ha.md)
|
||||
- [ADR-165: HOMECORE Migration from Python Home Assistant](../../docs/adr/ADR-165-homecore-migrate-from-home-assistant.md)
|
||||
- [ADR-126: HOMECORE Home Assistant Port (master)](../../docs/adr/ADR-126-homecore-home-assistant-port.md)
|
||||
- [Home Assistant .storage/ format](https://developers.home-assistant.io/docs/storage/)
|
||||
- [homecore-migrate CLI source](src/main.rs)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Parser for `core.config_entries` (HA storage schema v1, minor_version varies).
|
||||
//!
|
||||
//! Per ADR-134 §6 Q5, `.storage/core.config_entries` format is undocumented
|
||||
//! Per ADR-165 §6 Q5, `.storage/core.config_entries` format is undocumented
|
||||
//! and version-gated. P1 reads the envelope and emits:
|
||||
//! - count of config entries
|
||||
//! - list of integration domains represented
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
//! homecore-migrate — Migration tooling from Python Home Assistant.
|
||||
//!
|
||||
//! Implements [ADR-134](../../docs/adr/ADR-134-homecore-migration-from-python-ha.md)
|
||||
//! (referenced via ADR-126 §4, series map row ADR-134 HOMECORE-MIGRATE).
|
||||
//! Implements [ADR-165](../../docs/adr/ADR-165-homecore-migrate-from-home-assistant.md)
|
||||
//! (HOMECORE-MIGRATE; ADR-126 §4 series map labels the role "ADR-134 HOMECORE-MIGRATE",
|
||||
//! but on-disk ADR-134 is CIR — the migrate decision was renumbered to ADR-165. See ADR-164).
|
||||
//!
|
||||
//! ## P1 scope
|
||||
//!
|
||||
@@ -56,7 +57,7 @@ pub enum MigrateError {
|
||||
|
||||
/// Fired when the outer `{version, minor_version}` envelope version is
|
||||
/// known but the `minor_version` is not supported by any compiled parser.
|
||||
/// Per ADR-134 §6 Q5: hard error on unknown minor_version.
|
||||
/// Per ADR-165 §6 Q5: hard error on unknown minor_version.
|
||||
#[error(
|
||||
"unsupported schema version in {file}: \
|
||||
version={version} minor_version={minor_version}. \
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! adding a new `v<N>.rs` module; the dispatch function in each parser module
|
||||
//! routes to the right implementation.
|
||||
//!
|
||||
//! Per ADR-134 §6 Q5: unknown `minor_version` values produce a hard
|
||||
//! Per ADR-165 §6 Q5: unknown `minor_version` values produce a hard
|
||||
//! `MigrateError::UnsupportedSchemaVersion` — we do NOT silently fall back
|
||||
//! to an older parser, because schema changes can be load-bearing (new fields,
|
||||
//! renamed keys, semantic reinterpretations).
|
||||
|
||||
@@ -79,6 +79,6 @@ harness = false
|
||||
name = "train_marl"
|
||||
required-features = ["train"]
|
||||
|
||||
# ADR-149 Stage-1 evaluation CLI — pure Rust, no special feature needed.
|
||||
# ADR-171 Stage-1 evaluation CLI — pure Rust, no special feature needed.
|
||||
[[bin]]
|
||||
name = "eval_swarm"
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
# ADR-149 evaluation outputs
|
||||
# ADR-171 evaluation outputs
|
||||
RESULTS.md is generated by the `eval_swarm` binary.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# ruview-swarm Evaluation Results (ADR-149 Stage 1, kinematic)
|
||||
# ruview-swarm Evaluation Results (ADR-171 Stage 1, kinematic)
|
||||
|
||||
Statistically-rigorous evaluation harness: seeded multi-run rollouts with IQM + 95% stratified-bootstrap confidence intervals (Agarwal et al., NeurIPS 2021).
|
||||
|
||||
@@ -9,7 +9,7 @@ Statistically-rigorous evaluation harness: seeded multi-run rollouts with IQM +
|
||||
- **CI method**: 95% stratified bootstrap of the IQM, stratified by seed
|
||||
- **GDOP**: 2-D geometric dilution of precision at first detection
|
||||
|
||||
> **Stage 2 pending**: high-fidelity Gazebo/PX4 SITL evaluation (false-alarm rate, real collision rate on the median seeds) is a follow-on — see ADR-149 §6.1. The collision figures below are a kinematic min-separation proxy, not SITL physics.
|
||||
> **Stage 2 pending**: high-fidelity Gazebo/PX4 SITL evaluation (false-alarm rate, real collision rate on the median seeds) is a follow-on — see ADR-171 §6.1. The collision figures below are a kinematic min-separation proxy, not SITL physics.
|
||||
|
||||
## Flight-pattern leaderboard
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
//! ADR-149 Stage-1 evaluation CLI.
|
||||
//! ADR-171 Stage-1 evaluation CLI.
|
||||
//!
|
||||
//! Runs the kinematic eval matrix over every flight pattern (default) and
|
||||
//! writes a ranked `RESULTS.md` leaderboard. Pure Rust — no special feature
|
||||
//! flag required, so it builds and runs in default CI.
|
||||
//!
|
||||
//! Defaults are intentionally small (10 seeds × 10 episodes) so the run is fast.
|
||||
//! The full ADR-149 reporting configuration is 10 seeds × 50 episodes — pass
|
||||
//! The full ADR-171 reporting configuration is 10 seeds × 50 episodes — pass
|
||||
//! `--seeds 10 --episodes 50` for the publication run.
|
||||
//!
|
||||
//! ```text
|
||||
@@ -45,7 +45,7 @@ fn main() {
|
||||
}
|
||||
"--help" | "-h" => {
|
||||
eprintln!(
|
||||
"eval_swarm — ADR-149 Stage-1 kinematic evaluator\n\
|
||||
"eval_swarm — ADR-171 Stage-1 kinematic evaluator\n\
|
||||
Usage: eval_swarm [--seeds N] [--episodes M] [--out PATH]\n\
|
||||
Defaults: --seeds 10 --episodes 10 --out crates/ruview-swarm/evals/RESULTS.md"
|
||||
);
|
||||
@@ -59,7 +59,7 @@ fn main() {
|
||||
}
|
||||
|
||||
eprintln!(
|
||||
"Running ADR-149 Stage-1 eval: {seeds} seeds × {episodes} episodes \
|
||||
"Running ADR-171 Stage-1 eval: {seeds} seeds × {episodes} episodes \
|
||||
over {} flight patterns...",
|
||||
FlightPattern::all().len()
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Per-episode and aggregate SAR + MARL metrics (ADR-149 Stage 1).
|
||||
//! Per-episode and aggregate SAR + MARL metrics (ADR-171 Stage 1).
|
||||
|
||||
use crate::evals::stats::{stratified_bootstrap_ci, ConfidenceInterval};
|
||||
|
||||
@@ -38,7 +38,7 @@ pub struct AggregateMetrics {
|
||||
impl AggregateMetrics {
|
||||
/// Aggregate a seed-stratified matrix of episodes. Each inner `Vec` is one
|
||||
/// seed's episodes; bootstrap resampling is stratified by seed so the CI
|
||||
/// reflects between-seed variance (the dominant source per ADR-149).
|
||||
/// reflects between-seed variance (the dominant source per ADR-171).
|
||||
pub fn from_strata(per_seed: &[Vec<EpisodeMetrics>], boot_seed: u64) -> Self {
|
||||
const N_BOOT: usize = 1000;
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
//! ADR-149 statistically-rigorous evaluation harness (Stage 1, kinematic).
|
||||
//! ADR-171 statistically-rigorous evaluation harness (Stage 1, kinematic).
|
||||
//!
|
||||
//! Produces SAR + MARL metrics over a seeded N-seed × M-episode matrix with
|
||||
//! IQM + 95% stratified-bootstrap CIs, a (sigma, kappa) CSI-noise sweep, and
|
||||
//! GDOP-stratified localization error. Generates evals/RESULTS.md.
|
||||
//!
|
||||
//! Stage 2 (Gazebo/PX4 SITL high-fidelity, false-alarm + collision rate on the
|
||||
//! median seeds) is a follow-on — see ADR-149 §6.1.
|
||||
//! median seeds) is a follow-on — see ADR-171 §6.1.
|
||||
pub mod gdop;
|
||||
pub mod stats;
|
||||
pub mod metrics;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! RESULTS.md leaderboard generator (ADR-149 Stage 1).
|
||||
//! RESULTS.md leaderboard generator (ADR-171 Stage 1).
|
||||
|
||||
use crate::evals::metrics::AggregateMetrics;
|
||||
use crate::evals::stats::ConfidenceInterval;
|
||||
@@ -19,7 +19,7 @@ fn fmt_ci(ci: &ConfidenceInterval) -> String {
|
||||
/// so callers should pre-sort (e.g. by descending coverage point estimate).
|
||||
pub fn render_results_md(rows: &[(String, AggregateMetrics)]) -> String {
|
||||
let mut s = String::new();
|
||||
s.push_str("# ruview-swarm Evaluation Results (ADR-149 Stage 1, kinematic)\n\n");
|
||||
s.push_str("# ruview-swarm Evaluation Results (ADR-171 Stage 1, kinematic)\n\n");
|
||||
s.push_str(
|
||||
"Statistically-rigorous evaluation harness: seeded multi-run rollouts with \
|
||||
IQM + 95% stratified-bootstrap confidence intervals (Agarwal et al., \
|
||||
@@ -46,7 +46,7 @@ pub fn render_results_md(rows: &[(String, AggregateMetrics)]) -> String {
|
||||
s.push_str(
|
||||
"\n> **Stage 2 pending**: high-fidelity Gazebo/PX4 SITL evaluation \
|
||||
(false-alarm rate, real collision rate on the median seeds) is a \
|
||||
follow-on — see ADR-149 §6.1. The collision figures below are a \
|
||||
follow-on — see ADR-171 §6.1. The collision figures below are a \
|
||||
kinematic min-separation proxy, not SITL physics.\n\n",
|
||||
);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Stage-1 kinematic rollout + seed × episode matrix (ADR-149).
|
||||
//! Stage-1 kinematic rollout + seed × episode matrix (ADR-171).
|
||||
//!
|
||||
//! A single `run_episode` deterministically drives `drones` drones across a
|
||||
//! mission area under a chosen [`FlightPattern`], marks coverage on a grid,
|
||||
@@ -28,7 +28,7 @@ pub struct EvalConfig {
|
||||
pub config: SwarmConfig,
|
||||
pub drones: usize,
|
||||
pub steps: usize,
|
||||
pub seeds: usize, // ≥10 per ADR-149
|
||||
pub seeds: usize, // ≥10 per ADR-171
|
||||
pub episodes_per_seed: usize, // e.g. 50
|
||||
pub victims: Vec<Position3D>,
|
||||
pub noise: NoiseLevel,
|
||||
@@ -297,7 +297,7 @@ pub fn run_matrix(cfg: &EvalConfig) -> Vec<Vec<EpisodeMetrics>> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Standard ADR-149 noise sweep grid: cartesian product of σ × κ levels.
|
||||
/// Standard ADR-171 noise sweep grid: cartesian product of σ × κ levels.
|
||||
pub fn default_noise_sweep() -> Vec<NoiseLevel> {
|
||||
let sigmas = [0.02, 0.05, 0.10];
|
||||
let kappas = [16.0, 8.0, 4.0];
|
||||
|
||||
@@ -682,8 +682,9 @@ mod tests {
|
||||
fn contradiction_demotes_privacy() {
|
||||
let (mut e, room) = engine();
|
||||
let cal = CalibrationId(7);
|
||||
// 2 ms spread: within the 5 ms hard guard but above the 1 ms soft guard.
|
||||
let frames = [node_frame(0, 1000, 56), node_frame(1, 3000, 56)];
|
||||
// 25 ms spread: within the 60 ms hard guard but above the 20 ms soft
|
||||
// guard (#1031 raised both to accommodate the real TDM slot offset).
|
||||
let frames = [node_frame(0, 1_000, 56), node_frame(1, 26_000, 56)];
|
||||
let out = e.process_cycle(&frames, cal, room, 20_000).unwrap();
|
||||
|
||||
assert!(out.demoted, "loose alignment must demote");
|
||||
|
||||
@@ -24,7 +24,7 @@ linux-wifi = []
|
||||
[dependencies]
|
||||
# CLI argument parsing (for bin/aggregator)
|
||||
clap = { version = "4.4", features = ["derive"] }
|
||||
# Cryptographic HMAC (ADR-050: replace fake XOR-fold HMAC)
|
||||
# Cryptographic HMAC (ADR-166: replace fake XOR-fold HMAC)
|
||||
hmac = "0.12"
|
||||
sha2 = "0.10"
|
||||
# Byte parsing
|
||||
|
||||
@@ -265,7 +265,7 @@ impl AuthenticatedBeacon {
|
||||
/// Compute the HMAC-SHA256 tag for this beacon, truncated to 8 bytes.
|
||||
///
|
||||
/// Uses the `hmac` + `sha2` crates for cryptographically secure
|
||||
/// message authentication (ADR-050, Sprint 1).
|
||||
/// message authentication (ADR-166, Sprint 1).
|
||||
pub fn compute_tag(payload_and_nonce: &[u8], key: &[u8; 16]) -> [u8; HMAC_TAG_SIZE] {
|
||||
let mut mac = HmacSha256::new_from_slice(key).expect("HMAC-SHA256 accepts any key length");
|
||||
mac.update(payload_and_nonce);
|
||||
@@ -953,7 +953,7 @@ mod tests {
|
||||
assert_eq!(SecLevel::Enforcing as u8, 2);
|
||||
}
|
||||
|
||||
// ---- Security tests (ADR-050) ----
|
||||
// ---- Security tests (ADR-166) ----
|
||||
|
||||
#[test]
|
||||
fn test_hmac_different_keys_produce_different_tags() {
|
||||
|
||||
@@ -254,6 +254,98 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// REGRESSION (ADR-080 #3, CWE-598 — token in URL query string).
|
||||
///
|
||||
/// ADR-080 flagged "JWT in URL" as a HIGH finding (tokens in query strings
|
||||
/// leak into logs, proxies, browser history, `Referer`). The current
|
||||
/// sensing-server only ever reads the token from the `Authorization: Bearer`
|
||||
/// header — there is no `?token=` / `?access_token=` query path in
|
||||
/// `require_bearer` (see [`require_bearer`] above, which only inspects the
|
||||
/// `AUTHORIZATION` header). This test pins that: a request carrying the
|
||||
/// correct token *only* in the query string is still `401`, while the same
|
||||
/// token in the header is `200`. If anyone ever re-introduces a query-string
|
||||
/// token path, this fails.
|
||||
#[tokio::test]
|
||||
async fn query_string_token_is_never_accepted() {
|
||||
let r = wrap(AuthState::from_token("s3cr3t"));
|
||||
// Correct token, but supplied only in the URL — must NOT authenticate.
|
||||
assert_eq!(
|
||||
status(r.clone(), "GET", "/api/v1/info?token=s3cr3t", None).await,
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"?token= in the query string must not authenticate (CWE-598)"
|
||||
);
|
||||
assert_eq!(
|
||||
status(
|
||||
r.clone(),
|
||||
"GET",
|
||||
"/api/v1/info?access_token=s3cr3t",
|
||||
None
|
||||
)
|
||||
.await,
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"?access_token= in the query string must not authenticate (CWE-598)"
|
||||
);
|
||||
// A query token must not "help" a request that also lacks the header,
|
||||
// even combined with an unrelated param.
|
||||
assert_eq!(
|
||||
status(
|
||||
r.clone(),
|
||||
"GET",
|
||||
"/api/v1/info?foo=bar&token=s3cr3t",
|
||||
None
|
||||
)
|
||||
.await,
|
||||
StatusCode::UNAUTHORIZED
|
||||
);
|
||||
// The header path is the only accepted channel — same token, header,
|
||||
// succeeds. (Proves we didn't just break auth entirely.)
|
||||
assert_eq!(
|
||||
status(r, "GET", "/api/v1/info?token=s3cr3t", Some("s3cr3t")).await,
|
||||
StatusCode::OK,
|
||||
"the Authorization: Bearer header is the supported channel"
|
||||
);
|
||||
}
|
||||
|
||||
/// REGRESSION (ADR-080 #1 — X-Forwarded-For spoofing).
|
||||
///
|
||||
/// The bearer middleware authenticates on the token alone and must be
|
||||
/// completely insensitive to a client-supplied `X-Forwarded-For` header:
|
||||
/// an attacker cannot flip an auth decision by spoofing XFF. A wrong token
|
||||
/// stays `401` and a right token stays `200` regardless of XFF. (The
|
||||
/// sensing-server has no IP-based rate-limit / allowlist that XFF could
|
||||
/// bypass; this locks in that auth itself never consults XFF.)
|
||||
#[tokio::test]
|
||||
async fn xff_header_never_affects_auth_decision() {
|
||||
let r = wrap(AuthState::from_token("s3cr3t"));
|
||||
async fn with_xff(router: Router, token: Option<&str>, xff: &str) -> StatusCode {
|
||||
let mut req = Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/v1/info")
|
||||
.header("X-Forwarded-For", xff)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
if let Some(t) = token {
|
||||
req.headers_mut()
|
||||
.insert(AUTHORIZATION, format!("Bearer {t}").parse().unwrap());
|
||||
}
|
||||
router.oneshot(req).await.unwrap().status()
|
||||
}
|
||||
// Spoofed XFF + no/ wrong token ⇒ still rejected.
|
||||
assert_eq!(
|
||||
with_xff(r.clone(), None, "127.0.0.1").await,
|
||||
StatusCode::UNAUTHORIZED
|
||||
);
|
||||
assert_eq!(
|
||||
with_xff(r.clone(), Some("nope"), "10.0.0.1, 127.0.0.1").await,
|
||||
StatusCode::UNAUTHORIZED
|
||||
);
|
||||
// Spoofed XFF + correct token ⇒ still accepted (XFF is irrelevant).
|
||||
assert_eq!(
|
||||
with_xff(r, Some("s3cr3t"), "evil-proxy").await,
|
||||
StatusCode::OK
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn enabled_never_gates_paths_outside_api_v1() {
|
||||
let r = wrap(AuthState::from_token("s3cr3t"));
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
//! Generic, leak-free error responses for the sensing-server HTTP API.
|
||||
//!
|
||||
//! ## ADR-080 finding #2 — leaked internal errors in responses
|
||||
//!
|
||||
//! Several handlers historically serialized the *internal* error `Display`
|
||||
//! (`format!("{e}")`, `err.to_string()`, a panicked `JoinError`) straight into
|
||||
//! the JSON response body. That leaks server internals to any client: OS error
|
||||
//! strings can carry filesystem paths, a `JoinError` carries the panic message
|
||||
//! (`task … panicked`), and an upstream-fetch error can carry an internal URL.
|
||||
//! ADR-080 flagged this HIGH (CWE-209: Generation of Error Message Containing
|
||||
//! Sensitive Information). The HOMECORE/M7 sweep (ADR-161) covered
|
||||
//! `homecore-server`, **not** this crate, so the finding stayed open.
|
||||
//!
|
||||
//! ## Contract
|
||||
//!
|
||||
//! [`internal_error`] logs the full detail **server-side only** (at `error`
|
||||
//! level, tagged with a correlation id) and returns a *generic* body to the
|
||||
//! client:
|
||||
//!
|
||||
//! ```json
|
||||
//! { "error": "internal_error", "correlation_id": "a1b2c3d4e5f60718", "success": false }
|
||||
//! ```
|
||||
//!
|
||||
//! The correlation id lets an operator grep the server log for the matching
|
||||
//! detail line without ever shipping that detail to the client. The body
|
||||
//! deliberately contains no `Display`/`Debug` of the underlying error, no file
|
||||
//! paths, and never the word `panicked`.
|
||||
//!
|
||||
//! Handlers that previously returned `Json<serde_json::Value>` keep doing so via
|
||||
//! [`internal_error_json`]; handlers that return `(StatusCode, Json<…>)` use
|
||||
//! [`internal_error`]. A "service unavailable" flavor ([`upstream_unavailable`])
|
||||
//! exists for the 503 upstream-fetch path so it, too, stops leaking the raw
|
||||
//! upstream error.
|
||||
|
||||
use std::fmt::Display;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use axum::{http::StatusCode, response::Json};
|
||||
use serde_json::json;
|
||||
|
||||
/// Monotonic component of the correlation id, so two errors in the same
|
||||
/// nanosecond still get distinct ids. Wraps harmlessly.
|
||||
static CORRELATION_COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// Generate a short, opaque correlation id (16 lowercase hex chars). Built from
|
||||
/// a nanosecond timestamp XORed with a monotonic counter — unique enough to tie
|
||||
/// a client-visible id back to a single server-side log line without pulling in
|
||||
/// a UUID dependency. It is **not** a security token; it is only an opaque
|
||||
/// log-join key, so a non-cryptographic source is fine.
|
||||
pub fn correlation_id() -> String {
|
||||
let nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos() as u64)
|
||||
.unwrap_or(0);
|
||||
let seq = CORRELATION_COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
// Mix the counter into the high bits so concurrent calls in the same
|
||||
// nanosecond don't collide.
|
||||
let mixed = nanos ^ seq.rotate_left(40);
|
||||
format!("{mixed:016x}")
|
||||
}
|
||||
|
||||
/// Build a generic internal-error response **and log the real detail
|
||||
/// server-side**. The client sees only `{"error":"internal_error",
|
||||
/// "correlation_id":…,"success":false}` with a `500` status; the detail is
|
||||
/// written to the `error`-level log tagged with the same correlation id.
|
||||
///
|
||||
/// `context` is a short, *static* description of where the error happened
|
||||
/// (e.g. `"model delete"`); it is safe to log but is **not** sent to the
|
||||
/// client.
|
||||
pub fn internal_error(context: &str, detail: impl Display) -> (StatusCode, Json<serde_json::Value>) {
|
||||
let cid = correlation_id();
|
||||
// Server-side only — this is where the real detail lives.
|
||||
tracing::error!(
|
||||
correlation_id = %cid,
|
||||
context = context,
|
||||
detail = %detail,
|
||||
"internal error (detail logged server-side only; client received a generic body)"
|
||||
);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({
|
||||
"error": "internal_error",
|
||||
"correlation_id": cid,
|
||||
"success": false,
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
/// Same as [`internal_error`] but returns a bare `Json` body (HTTP `200` at the
|
||||
/// transport layer) for the legacy handlers that are typed
|
||||
/// `-> Json<serde_json::Value>` and signal failure via `"success": false`
|
||||
/// rather than an HTTP status code. The detail is still logged server-side and
|
||||
/// never reaches the client.
|
||||
pub fn internal_error_json(context: &str, detail: impl Display) -> Json<serde_json::Value> {
|
||||
let cid = correlation_id();
|
||||
tracing::error!(
|
||||
correlation_id = %cid,
|
||||
context = context,
|
||||
detail = %detail,
|
||||
"internal error (detail logged server-side only; client received a generic body)"
|
||||
);
|
||||
Json(json!({
|
||||
"error": "internal_error",
|
||||
"correlation_id": cid,
|
||||
"success": false,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Generic `503 Service Unavailable` for an upstream dependency that failed,
|
||||
/// without leaking the raw upstream error (which can carry an internal URL or
|
||||
/// connection detail). Detail is logged server-side with a correlation id.
|
||||
pub fn upstream_unavailable(
|
||||
context: &str,
|
||||
detail: impl Display,
|
||||
) -> (StatusCode, Json<serde_json::Value>) {
|
||||
let cid = correlation_id();
|
||||
tracing::warn!(
|
||||
correlation_id = %cid,
|
||||
context = context,
|
||||
detail = %detail,
|
||||
"upstream unavailable (detail logged server-side only; client received a generic body)"
|
||||
);
|
||||
(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(json!({
|
||||
"error": "upstream_unavailable",
|
||||
"correlation_id": cid,
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A "detail" string carrying the kind of internal information the old
|
||||
/// `format!("{e}")` path would have leaked: a filesystem path, an OS error,
|
||||
/// and the word `panicked`.
|
||||
const LEAKY_DETAIL: &str =
|
||||
"task 42 panicked at 'C:\\Users\\ruv\\secret\\models\\foo.rvf': No such file or directory (os error 2)";
|
||||
|
||||
/// Recursively collect every string value in a JSON document, so a test can
|
||||
/// assert no leaky substring appears *anywhere* in the body (not just in a
|
||||
/// single known field).
|
||||
fn all_strings(v: &serde_json::Value, out: &mut Vec<String>) {
|
||||
match v {
|
||||
serde_json::Value::String(s) => out.push(s.clone()),
|
||||
serde_json::Value::Array(a) => a.iter().for_each(|x| all_strings(x, out)),
|
||||
serde_json::Value::Object(o) => o.values().for_each(|x| all_strings(x, out)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn body_strings(body: &Json<serde_json::Value>) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
all_strings(&body.0, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
/// REGRESSION (ADR-080 #2): the response body must NOT contain the panic
|
||||
/// message, the filesystem path, or the OS error string. The pre-fix code
|
||||
/// returned `format!("{e}")` / `join_err.to_string()` directly, so the body
|
||||
/// *did* contain `panicked`, the path, and `os error 2` — this test fails
|
||||
/// on that old behavior.
|
||||
#[test]
|
||||
fn internal_error_body_does_not_leak_detail() {
|
||||
let (status, body) = internal_error("unit-test", LEAKY_DETAIL);
|
||||
assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
|
||||
for s in body_strings(&body) {
|
||||
assert!(
|
||||
!s.contains("panicked"),
|
||||
"response body leaked the panic message: {s:?}"
|
||||
);
|
||||
assert!(
|
||||
!s.contains("secret"),
|
||||
"response body leaked a filesystem path: {s:?}"
|
||||
);
|
||||
assert!(
|
||||
!s.contains("os error"),
|
||||
"response body leaked an OS error string: {s:?}"
|
||||
);
|
||||
assert!(
|
||||
!s.contains(".rvf"),
|
||||
"response body leaked a file name/path: {s:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The generic body still carries a correlation id so an operator can join
|
||||
/// the client report to the server log line that *does* hold the detail.
|
||||
#[test]
|
||||
fn internal_error_body_is_generic_with_correlation_id() {
|
||||
let (_status, body) = internal_error("unit-test", LEAKY_DETAIL);
|
||||
assert_eq!(body.0["error"], "internal_error");
|
||||
assert_eq!(body.0["success"], false);
|
||||
let cid = body.0["correlation_id"]
|
||||
.as_str()
|
||||
.expect("correlation_id must be a string");
|
||||
assert_eq!(cid.len(), 16, "correlation id should be 16 hex chars");
|
||||
assert!(
|
||||
cid.chars().all(|c| c.is_ascii_hexdigit()),
|
||||
"correlation id should be hex: {cid:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Same leak guarantee for the bare-`Json` (legacy "success: false")
|
||||
/// variant used by handlers that don't return an HTTP status.
|
||||
#[test]
|
||||
fn internal_error_json_does_not_leak_detail() {
|
||||
let body = internal_error_json("unit-test", LEAKY_DETAIL);
|
||||
assert_eq!(body.0["error"], "internal_error");
|
||||
assert_eq!(body.0["success"], false);
|
||||
for s in body_strings(&body) {
|
||||
assert!(!s.contains("panicked"), "leaked panic message: {s:?}");
|
||||
assert!(!s.contains("secret"), "leaked filesystem path: {s:?}");
|
||||
assert!(!s.contains("os error"), "leaked OS error: {s:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The 503 upstream flavor must likewise not echo the raw upstream error
|
||||
/// (which can carry an internal URL / connection string).
|
||||
#[test]
|
||||
fn upstream_unavailable_does_not_leak_detail() {
|
||||
let (status, body) = upstream_unavailable(
|
||||
"edge-registry",
|
||||
"https://internal-host.local:9000/app-registry.json: connection refused",
|
||||
);
|
||||
assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
|
||||
for s in body_strings(&body) {
|
||||
assert!(
|
||||
!s.contains("internal-host"),
|
||||
"leaked internal upstream host: {s:?}"
|
||||
);
|
||||
assert!(
|
||||
!s.contains("connection refused"),
|
||||
"leaked upstream connection detail: {s:?}"
|
||||
);
|
||||
}
|
||||
assert_eq!(body.0["error"], "upstream_unavailable");
|
||||
assert!(body.0["correlation_id"].is_string());
|
||||
}
|
||||
|
||||
/// Correlation ids are unique across rapid successive calls (so two errors
|
||||
/// can be told apart in the log even under load).
|
||||
#[test]
|
||||
fn correlation_ids_are_unique() {
|
||||
let a = correlation_id();
|
||||
let b = correlation_id();
|
||||
assert_ne!(a, b, "successive correlation ids must differ: {a} == {b}");
|
||||
}
|
||||
}
|
||||
@@ -362,6 +362,49 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// REGRESSION (ADR-080 #1 — X-Forwarded-For / X-Forwarded-Host spoofing).
|
||||
///
|
||||
/// The DNS-rebinding allowlist must decide purely on the real `Host` header
|
||||
/// and ignore any client-supplied forwarding headers. Otherwise an attacker
|
||||
/// could spoof `X-Forwarded-Host: localhost` (or `X-Forwarded-For`) to slip a
|
||||
/// foreign `Host` past the allowlist. This test sends a rejected `Host:
|
||||
/// evil.com` *with* allowlisted forwarding headers and asserts the request is
|
||||
/// still `421` — the forwarded headers must not bypass the control. It also
|
||||
/// confirms an allowed `Host` stays `200` regardless of a hostile XFF.
|
||||
#[tokio::test]
|
||||
async fn forwarded_headers_never_bypass_host_allowlist() {
|
||||
let r = router(HostAllowlist::loopback_only());
|
||||
async fn with_forwarded(
|
||||
router: Router,
|
||||
host: &str,
|
||||
xff: &str,
|
||||
xfh: &str,
|
||||
) -> StatusCode {
|
||||
let req = Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/v1/pose/current")
|
||||
.header(HOST, host)
|
||||
.header("X-Forwarded-For", xff)
|
||||
.header("X-Forwarded-Host", xfh)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
router.oneshot(req).await.unwrap().status()
|
||||
}
|
||||
// Foreign Host + spoofed allowlisted forwarding headers ⇒ still rejected.
|
||||
assert_eq!(
|
||||
with_forwarded(r.clone(), "evil.com", "127.0.0.1", "localhost").await,
|
||||
StatusCode::MISDIRECTED_REQUEST,
|
||||
"X-Forwarded-* must not let a foreign Host bypass the allowlist"
|
||||
);
|
||||
// Allowed Host + hostile forwarding headers ⇒ still allowed (forwarded
|
||||
// headers are simply not consulted).
|
||||
assert_eq!(
|
||||
with_forwarded(r, "127.0.0.1:8080", "evil.com", "evil.com").await,
|
||||
StatusCode::OK,
|
||||
"the real Host header is the only signal; XFF/XFH are ignored"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn disabled_allowlist_is_no_op() {
|
||||
let r = router(HostAllowlist::disabled());
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
//! - RVF (RuVector Format) binary container for model weights
|
||||
//! - Opt-in bearer-token auth for the `/api/v1/*` HTTP surface (`bearer_auth`)
|
||||
//! - Host-header allowlist / DNS-rebinding defense (`host_validation`)
|
||||
//! - Generic, leak-free internal-error responses (`error_response`, ADR-080 #2)
|
||||
//! - Real-time CSI introspection / low-latency tap (`introspection`, ADR-099)
|
||||
|
||||
pub mod bearer_auth;
|
||||
@@ -13,10 +14,12 @@ pub mod dataset;
|
||||
pub mod edge_registry;
|
||||
#[allow(dead_code)]
|
||||
pub mod embedding;
|
||||
pub mod error_response;
|
||||
pub mod graph_transformer;
|
||||
pub mod host_validation;
|
||||
pub mod introspection;
|
||||
pub mod matter;
|
||||
pub mod model_format;
|
||||
pub mod mqtt;
|
||||
pub mod path_safety;
|
||||
pub mod semantic;
|
||||
|
||||
@@ -14,6 +14,7 @@ pub mod cli;
|
||||
pub mod csi;
|
||||
mod engine_bridge;
|
||||
mod field_bridge;
|
||||
mod model_format;
|
||||
mod multistatic_bridge;
|
||||
pub mod pose;
|
||||
mod rvf_container;
|
||||
@@ -23,7 +24,9 @@ pub mod types;
|
||||
mod vital_signs;
|
||||
|
||||
// Training pipeline modules (exposed via lib.rs)
|
||||
use wifi_densepose_sensing_server::{dataset, embedding, graph_transformer, trainer};
|
||||
use wifi_densepose_sensing_server::{
|
||||
dataset, embedding, error_response, graph_transformer, trainer,
|
||||
};
|
||||
|
||||
use ruvector_mincut::{DynamicMinCut, MinCutBuilder};
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
@@ -144,6 +147,16 @@ struct Args {
|
||||
#[arg(long, value_name = "PATH")]
|
||||
export_rvf: Option<PathBuf>,
|
||||
|
||||
/// Convert a published model file (model.safetensors / model.rvf.jsonl) to
|
||||
/// the RVF binary container the --model loader expects, then exit (#894).
|
||||
/// Pair with --convert-out for the destination path.
|
||||
#[arg(long, value_name = "PATH")]
|
||||
convert_model: Option<PathBuf>,
|
||||
|
||||
/// Output path for --convert-model (defaults to <input>.rvf).
|
||||
#[arg(long, value_name = "PATH")]
|
||||
convert_out: Option<PathBuf>,
|
||||
|
||||
/// Run training mode (train a model and exit)
|
||||
#[arg(long)]
|
||||
train: bool,
|
||||
@@ -4269,7 +4282,7 @@ async fn delete_model(
|
||||
State(state): State<SharedState>,
|
||||
Path(id): Path<String>,
|
||||
) -> Json<serde_json::Value> {
|
||||
// ADR-050: Sanitize path to prevent directory traversal
|
||||
// ADR-166: Sanitize path to prevent directory traversal
|
||||
let safe_id = std::path::Path::new(&id)
|
||||
.file_name()
|
||||
.and_then(|f| f.to_str())
|
||||
@@ -4280,10 +4293,9 @@ async fn delete_model(
|
||||
let path = effective_models_dir().join(format!("{}.rvf", safe_id));
|
||||
if path.exists() {
|
||||
if let Err(e) = std::fs::remove_file(&path) {
|
||||
warn!("Failed to delete model file {:?}: {}", path, e);
|
||||
return Json(
|
||||
serde_json::json!({ "error": format!("delete failed: {e}"), "success": false }),
|
||||
);
|
||||
// ADR-080 #2: log the OS error (incl. path) server-side only; the
|
||||
// client gets a generic body + correlation id, no leaked path.
|
||||
return error_response::internal_error_json("model delete", e);
|
||||
}
|
||||
// If this was the active model, unload it
|
||||
let mut s = state.write().await;
|
||||
@@ -4423,11 +4435,9 @@ async fn start_recording(
|
||||
let file = match std::fs::File::create(&rec_path) {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
warn!("Failed to create recording file {:?}: {}", rec_path, e);
|
||||
return Json(serde_json::json!({
|
||||
"error": format!("cannot create file: {e}"),
|
||||
"success": false,
|
||||
}));
|
||||
// ADR-080 #2: the OS error can carry the recordings path; log it
|
||||
// server-side only and return a generic body + correlation id.
|
||||
return error_response::internal_error_json("recording create", e);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -4539,7 +4549,7 @@ async fn delete_recording(
|
||||
State(state): State<SharedState>,
|
||||
Path(id): Path<String>,
|
||||
) -> Json<serde_json::Value> {
|
||||
// ADR-050: Sanitize path to prevent directory traversal
|
||||
// ADR-166: Sanitize path to prevent directory traversal
|
||||
let safe_id = std::path::Path::new(&id)
|
||||
.file_name()
|
||||
.and_then(|f| f.to_str())
|
||||
@@ -4550,10 +4560,8 @@ async fn delete_recording(
|
||||
let path = PathBuf::from("data/recordings").join(format!("{}.jsonl", safe_id));
|
||||
if path.exists() {
|
||||
if let Err(e) = std::fs::remove_file(&path) {
|
||||
warn!("Failed to delete recording {:?}: {}", path, e);
|
||||
return Json(
|
||||
serde_json::json!({ "error": format!("delete failed: {e}"), "success": false }),
|
||||
);
|
||||
// ADR-080 #2: log the OS error (incl. path) server-side only.
|
||||
return error_response::internal_error_json("recording delete", e);
|
||||
}
|
||||
let mut s = state.write().await;
|
||||
s.recordings
|
||||
@@ -4762,10 +4770,8 @@ async fn calibration_start(State(state): State<SharedState>) -> Json<serde_json:
|
||||
"message": "Calibration started — keep room empty while frames accumulate.",
|
||||
}))
|
||||
}
|
||||
Err(e) => Json(serde_json::json!({
|
||||
"success": false,
|
||||
"error": format!("{e}"),
|
||||
})),
|
||||
// ADR-080 #2: FieldModel init error chain stays server-side only.
|
||||
Err(e) => error_response::internal_error_json("calibration start", e),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4785,10 +4791,8 @@ async fn calibration_stop(State(state): State<SharedState>) -> Json<serde_json::
|
||||
"frame_count": fm.calibration_frame_count(),
|
||||
}))
|
||||
}
|
||||
Err(e) => Json(serde_json::json!({
|
||||
"success": false,
|
||||
"error": format!("{e}"),
|
||||
})),
|
||||
// ADR-080 #2: finalize error chain stays server-side only.
|
||||
Err(e) => error_response::internal_error_json("calibration stop", e),
|
||||
}
|
||||
} else {
|
||||
Json(serde_json::json!({
|
||||
@@ -4884,26 +4888,13 @@ async fn edge_registry_endpoint(
|
||||
Ok(Ok(resp)) => Ok(Json(
|
||||
serde_json::to_value(resp).unwrap_or(serde_json::json!({})),
|
||||
)),
|
||||
Ok(Err(err)) => {
|
||||
tracing::warn!(error = %err, "edge_registry upstream fetch failed and no cache");
|
||||
Err((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(serde_json::json!({
|
||||
"error": "edge_registry_upstream_unavailable",
|
||||
"detail": err.to_string()
|
||||
})),
|
||||
))
|
||||
}
|
||||
Err(join_err) => {
|
||||
tracing::error!(error = %join_err, "edge_registry spawn_blocking task panicked");
|
||||
Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"error": "edge_registry_internal_error",
|
||||
"detail": join_err.to_string()
|
||||
})),
|
||||
))
|
||||
}
|
||||
// ADR-080 #2: the upstream error can carry an internal URL/connection
|
||||
// detail — log it server-side only and return a generic 503.
|
||||
Ok(Err(err)) => Err(error_response::upstream_unavailable("edge_registry", err)),
|
||||
// ADR-080 #2: a panicked spawn_blocking surfaces "task … panicked" via
|
||||
// JoinError::Display — never ship that to the client. Generic 500 +
|
||||
// correlation id; the panic detail is logged server-side.
|
||||
Err(join_err) => Err(error_response::internal_error("edge_registry", join_err)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6221,6 +6212,34 @@ fn vitals_snapshots_from_sensing_json(
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the multistatic guard config, optionally derived from the TDM schedule
|
||||
/// declared in the environment (#1031).
|
||||
///
|
||||
/// When both `WDP_TDM_SLOTS` and `WDP_TDM_SLOT_US` parse as positive integers,
|
||||
/// the guard is derived via [`MultistaticConfig::for_tdm_schedule`] so a
|
||||
/// deployment can match its exact schedule. Otherwise the published default
|
||||
/// (60 ms hard / 20 ms soft) is returned. `min_nodes` is *not* set here — the
|
||||
/// caller overrides it for single-node passthrough.
|
||||
fn multistatic_guard_config_from_env() -> MultistaticConfig {
|
||||
multistatic_guard_config_from(
|
||||
std::env::var("WDP_TDM_SLOTS").ok().as_deref(),
|
||||
std::env::var("WDP_TDM_SLOT_US").ok().as_deref(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Pure core of [`multistatic_guard_config_from_env`] for testability.
|
||||
fn multistatic_guard_config_from(slots: Option<&str>, slot_us: Option<&str>) -> MultistaticConfig {
|
||||
match (
|
||||
slots.and_then(|s| s.trim().parse::<usize>().ok()),
|
||||
slot_us.and_then(|s| s.trim().parse::<u64>().ok()),
|
||||
) {
|
||||
(Some(n), Some(us)) if n >= 1 && us >= 1 => {
|
||||
MultistaticConfig::for_tdm_schedule(n, us)
|
||||
}
|
||||
_ => MultistaticConfig::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Turn a `ProgressiveLoader::new` failure into an actionable diagnostic (#894).
|
||||
///
|
||||
/// The published HuggingFace `ruvnet/wifi-densepose-pretrained` files
|
||||
@@ -6230,6 +6249,11 @@ fn vitals_snapshots_from_sensing_json(
|
||||
/// `0x52564653`). Feeding one to `--model` produced a bare
|
||||
/// "invalid magic at offset 0 …" that left users stuck. Detect the common
|
||||
/// cases and explain plainly what's loadable instead.
|
||||
///
|
||||
/// Superseded in the live load path by [`load_or_convert_model`] (which now
|
||||
/// converts the convertible formats instead of just explaining), but retained
|
||||
/// as the human-readable format-landscape summary and exercised by tests.
|
||||
#[allow(dead_code)]
|
||||
fn diagnose_model_load_error(path: &std::path::Path, data: &[u8], err: &str) -> String {
|
||||
let name = path
|
||||
.file_name()
|
||||
@@ -6270,6 +6294,124 @@ fn diagnose_model_load_error(path: &std::path::Path, data: &[u8], err: &str) ->
|
||||
)
|
||||
}
|
||||
|
||||
/// Load a model for `--model`, auto-detecting + converting the published
|
||||
/// HuggingFace formats when the native RVF loader rejects them (issue #894).
|
||||
///
|
||||
/// Order of operations:
|
||||
/// 1. Try the native RVF `ProgressiveLoader` (the only format with `RVFS` magic).
|
||||
/// 2. On failure, **auto-detect** the format. If it is convertible
|
||||
/// (`safetensors` / `model.rvf.jsonl`), convert it in-memory to RVF and load
|
||||
/// that — so the published `model.safetensors` becomes loadable here.
|
||||
/// 3. If it is a non-convertible format (quantized blob / unknown), return the
|
||||
/// typed, actionable [`model_format::ModelLoadError`] message — never the
|
||||
/// opaque "invalid magic …" string.
|
||||
///
|
||||
/// Returns the loaded `ProgressiveLoader` or a human-actionable error string.
|
||||
fn load_or_convert_model(
|
||||
path: &std::path::Path,
|
||||
data: &[u8],
|
||||
) -> Result<ProgressiveLoader, String> {
|
||||
use model_format::{convert_to_rvf, detect_format, ModelFormat};
|
||||
|
||||
// 1. Native RVF.
|
||||
if let Ok(loader) = ProgressiveLoader::new(data) {
|
||||
return Ok(loader);
|
||||
}
|
||||
|
||||
let name = path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let model_id = path
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("converted-model");
|
||||
|
||||
match detect_format(data, &name) {
|
||||
// 2. Convertible formats: convert in-memory, then load.
|
||||
ModelFormat::Safetensors | ModelFormat::JsonlManifest => {
|
||||
match convert_to_rvf(data, &name, model_id) {
|
||||
Ok(rvf_bytes) => {
|
||||
info!(
|
||||
"Model `{}` is {} — converting to RVF in-memory and loading (issue #894)",
|
||||
path.display(),
|
||||
detect_format(data, &name).label()
|
||||
);
|
||||
ProgressiveLoader::new(&rvf_bytes).map_err(|e| {
|
||||
format!(
|
||||
"converted {} to RVF but the container failed to load: {e}",
|
||||
detect_format(data, &name).label()
|
||||
)
|
||||
})
|
||||
}
|
||||
Err(conv_err) => Err(conv_err.to_string()),
|
||||
}
|
||||
}
|
||||
// 3. Non-convertible: typed actionable error.
|
||||
_ => Err(model_format::classify_load_failure(
|
||||
data,
|
||||
&name,
|
||||
"RVF container parse failed",
|
||||
)
|
||||
.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// `--convert-model` entry point (issue #894): read `in_path`, convert it to an
|
||||
/// RVF binary container, write it to `out_path`, and verify the result loads.
|
||||
/// Returns a process exit code (0 = success).
|
||||
fn run_convert_model(in_path: &std::path::Path, out_path: &std::path::Path) -> i32 {
|
||||
let data = match std::fs::read(in_path) {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
eprintln!("convert-model: failed to read {}: {e}", in_path.display());
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
let name = in_path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let model_id = in_path
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("converted-model");
|
||||
|
||||
let detected = model_format::detect_format(&data, &name);
|
||||
eprintln!(
|
||||
"convert-model: detected {} ({} bytes)",
|
||||
detected.label(),
|
||||
data.len()
|
||||
);
|
||||
|
||||
match model_format::convert_to_rvf(&data, &name, model_id) {
|
||||
Ok(rvf_bytes) => {
|
||||
// Verify the converted bytes actually load before writing.
|
||||
if let Err(e) = ProgressiveLoader::new(&rvf_bytes) {
|
||||
eprintln!("convert-model: produced RVF did NOT load (bug): {e}");
|
||||
return 1;
|
||||
}
|
||||
if let Err(e) = std::fs::write(out_path, &rvf_bytes) {
|
||||
eprintln!("convert-model: failed to write {}: {e}", out_path.display());
|
||||
return 1;
|
||||
}
|
||||
eprintln!(
|
||||
"convert-model: wrote {} ({} bytes). Load it with `--model {}`.",
|
||||
out_path.display(),
|
||||
rvf_bytes.len(),
|
||||
out_path.display()
|
||||
);
|
||||
0
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("convert-model: {e}");
|
||||
1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `--export-rvf` should emit the placeholder container-format demo.
|
||||
///
|
||||
/// It must only do so **standalone**. Combined with `--train`/`--pretrain` the
|
||||
@@ -6323,6 +6465,17 @@ async fn main() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle --convert-model: turn a published HF model file (safetensors /
|
||||
// model.rvf.jsonl) into the RVF binary container --model expects, then exit
|
||||
// (issue #894). Gives the reporter a one-command path off the heuristics.
|
||||
if let Some(ref in_path) = args.convert_model {
|
||||
let out_path = args
|
||||
.convert_out
|
||||
.clone()
|
||||
.unwrap_or_else(|| in_path.with_extension("rvf"));
|
||||
std::process::exit(run_convert_model(in_path, &out_path));
|
||||
}
|
||||
|
||||
// Handle --export-rvf: writes a CONTAINER-FORMAT DEMO with placeholder
|
||||
// weights — it is NOT a trained model. Only short-circuit when standalone:
|
||||
// combined with --train/--pretrain the real model is exported by the
|
||||
@@ -6951,7 +7104,7 @@ async fn main() {
|
||||
if args.progressive || args.model.is_some() {
|
||||
info!("Loading trained model (progressive) from {}", mp.display());
|
||||
match std::fs::read(mp) {
|
||||
Ok(data) => match ProgressiveLoader::new(&data) {
|
||||
Ok(data) => match load_or_convert_model(mp, &data) {
|
||||
Ok(mut loader) => {
|
||||
if let Ok(la) = loader.load_layer_a() {
|
||||
info!(
|
||||
@@ -6963,7 +7116,13 @@ async fn main() {
|
||||
progressive_loader = Some(loader);
|
||||
}
|
||||
Err(e) => {
|
||||
error!("{}", diagnose_model_load_error(mp, &data, &e.to_string()))
|
||||
// #894: typed, actionable message (never the opaque magic)
|
||||
// and a LOUD warning that we are degrading to heuristics.
|
||||
error!("{e}");
|
||||
error!(
|
||||
"Model NOT loaded — falling back to signal heuristics. \
|
||||
Pose/person-count output will be approximate (issue #894)."
|
||||
);
|
||||
}
|
||||
},
|
||||
Err(e) => error!("Failed to read model file: {e}"),
|
||||
@@ -7136,9 +7295,14 @@ async fn main() {
|
||||
pose_tracker: PoseTracker::new(),
|
||||
last_tracker_instant: None,
|
||||
multistatic_fuser: {
|
||||
// #1031: the default guard (60 ms hard / 20 ms soft) accommodates a
|
||||
// real TDM slot offset. A deployment can override it to match its
|
||||
// own schedule via WDP_TDM_SLOTS + WDP_TDM_SLOT_US (both set ⇒ derive
|
||||
// from the schedule), else the published default is used.
|
||||
let cfg = multistatic_guard_config_from_env();
|
||||
let mut fuser = MultistaticFuser::with_config(MultistaticConfig {
|
||||
min_nodes: 1, // single-node passthrough
|
||||
..Default::default()
|
||||
..cfg
|
||||
});
|
||||
if let Some(ref pos_str) = args.node_positions {
|
||||
let positions = field_bridge::parse_node_positions(pos_str);
|
||||
@@ -7191,7 +7355,7 @@ async fn main() {
|
||||
tokio::spawn(simulated_data_task(state.clone(), args.tick_ms));
|
||||
}
|
||||
|
||||
// ADR-050: Parse bind address once, use for all listeners
|
||||
// ADR-166: Parse bind address once, use for all listeners
|
||||
let bind_ip: std::net::IpAddr = args
|
||||
.bind_addr
|
||||
.parse()
|
||||
|
||||
@@ -0,0 +1,497 @@
|
||||
//! Model-file format detection and conversion (issue #894).
|
||||
//!
|
||||
//! The published HuggingFace repo `ruvnet/wifi-densepose-pretrained` ships
|
||||
//! several files, **none** of which carry the RVF binary-container magic
|
||||
//! (`RVFS` = `0x52564653`) that [`crate::rvf_pipeline::ProgressiveLoader`]
|
||||
//! expects:
|
||||
//!
|
||||
//! | File on HF | First bytes | What it is |
|
||||
//! |-------------------------------|--------------------|------------------------------------|
|
||||
//! | `model.safetensors` | `<u64 LE len>{...` | standard safetensors weight file |
|
||||
//! | `model-q2/q4/q8.bin` | `35 57 45 77` ("5WEw", LE u32 `0x77455735`) | quantized weight blob |
|
||||
//! | `model.rvf.jsonl` | `{...` | JSONL manifest (one JSON per line) |
|
||||
//! | *(none shipped)* | `53 46 56 52` ("RVFS"/`RVFS`) | the binary RVF container the loader wants |
|
||||
//!
|
||||
//! Before this module, feeding any HF file to `--model` produced the opaque
|
||||
//! `invalid magic at offset 0: expected 0x52564653, got 0x77455735` and the
|
||||
//! server silently fell back to signal heuristics (the "10 persons for 1"
|
||||
//! garbage the reporter saw).
|
||||
//!
|
||||
//! This module:
|
||||
//! 1. **Auto-detects** the format by magic + extension ([`detect_format`]).
|
||||
//! 2. Returns a **typed, actionable** error ([`ModelLoadError`]) that lists the
|
||||
//! accepted formats and the one-command conversion path — never the opaque
|
||||
//! magic string.
|
||||
//! 3. Ships a **converter** ([`safetensors_to_rvf`], [`jsonl_to_rvf`]) so the
|
||||
//! published `model.safetensors` / `model.rvf.jsonl` can be turned into the
|
||||
//! binary RVF container the loader consumes, in one command
|
||||
//! (`sensing-server --convert-model <in> --convert-out <out>`).
|
||||
//!
|
||||
//! # Honest scope
|
||||
//!
|
||||
//! Converting `model.safetensors` → RVF wires the **format / load path**: the
|
||||
//! safetensors header is parsed, every F32 tensor's weights are flattened into
|
||||
//! the RVF `SEG_VEC` weight segment, and a manifest is written so the loader's
|
||||
//! Layer A/B/C all succeed. The pose-decoder *architecture* on HF differs from
|
||||
//! this crate's inference head, so this converter does **not** claim
|
||||
//! end-to-end pose accuracy from the converted weights — it makes the published
|
||||
//! model **loadable** (magic/version/segments valid, weights present) and
|
||||
//! removes the silent-heuristics fallback. Real pose inference from those exact
|
||||
//! weights still needs the matching decoder (tracked in #894).
|
||||
|
||||
use crate::rvf_container::RvfBuilder;
|
||||
|
||||
/// The RVF binary-container magic, `"RVFS"` as little-endian `u32`.
|
||||
const RVFS_MAGIC: u32 = 0x5256_4653;
|
||||
/// The quantized-blob magic shipped on HF (`"5WEw"` = bytes `35 57 45 77`),
|
||||
/// which decodes to `0x77455735` via `u32::from_le_bytes` — exactly the value
|
||||
/// the loader reported in issue #894.
|
||||
const HF_QUANT_MAGIC: u32 = 0x7745_5735;
|
||||
|
||||
/// A recognised on-disk model-file format.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ModelFormat {
|
||||
/// Native RVF binary container — the loader consumes this directly.
|
||||
Rvf,
|
||||
/// Standard `model.safetensors` (8-byte LE header length + JSON header).
|
||||
Safetensors,
|
||||
/// HuggingFace quantized weight blob (`model-q{2,4,8}.bin`, magic `0x77455735`).
|
||||
HfQuantBin,
|
||||
/// JSONL manifest (`model.rvf.jsonl`) — one JSON object per line.
|
||||
JsonlManifest,
|
||||
/// None of the above.
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl ModelFormat {
|
||||
/// Human-readable name for diagnostics.
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
ModelFormat::Rvf => "RVF binary container (RVFS)",
|
||||
ModelFormat::Safetensors => "safetensors weight file",
|
||||
ModelFormat::HfQuantBin => "HuggingFace quantized weight blob (model-q*.bin)",
|
||||
ModelFormat::JsonlManifest => "JSONL manifest (model.rvf.jsonl)",
|
||||
ModelFormat::Unknown => "unknown format",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A typed, actionable model-load error (issue #894).
|
||||
///
|
||||
/// Replaces the opaque `"invalid magic at offset 0: expected 0x… got 0x…"`
|
||||
/// string with a self-describing variant the caller can match on and present.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum ModelLoadError {
|
||||
/// The file is a recognised non-RVF format that must be converted first.
|
||||
#[error(
|
||||
"model file is {detected} — the --model loader needs an RVF binary container. \
|
||||
Convert it once with `sensing-server --convert-model <in> --convert-out model.rvf`, \
|
||||
then load the .rvf. (accepted by --model: RVF binary container; \
|
||||
convertible: safetensors, model.rvf.jsonl)"
|
||||
)]
|
||||
NeedsConversion {
|
||||
/// Label of the detected format.
|
||||
detected: &'static str,
|
||||
},
|
||||
|
||||
/// The file is a quantized HF blob with no in-repo reader.
|
||||
#[error(
|
||||
"model file is a HuggingFace quantized weight blob (magic 0x{magic:08X}); \
|
||||
no reader for this quantization format ships in this build. Use the \
|
||||
full-precision `model.safetensors` from the same HF repo and convert it \
|
||||
with `sensing-server --convert-model model.safetensors --convert-out model.rvf`."
|
||||
)]
|
||||
UnsupportedQuant {
|
||||
/// The magic that was read (e.g. `0x77455735`).
|
||||
magic: u32,
|
||||
},
|
||||
|
||||
/// The file matched no accepted or convertible format.
|
||||
#[error(
|
||||
"model file is an unknown format (first bytes 0x{first_bytes:08X}); \
|
||||
accepted: RVF binary container (RVFS, 0x52564653); convertible: \
|
||||
safetensors, model.rvf.jsonl. ({detail})"
|
||||
)]
|
||||
Unknown {
|
||||
/// The first 4 bytes as a LE u32 (0 if the file is shorter).
|
||||
first_bytes: u32,
|
||||
/// Underlying detail (e.g. the original loader message).
|
||||
detail: String,
|
||||
},
|
||||
|
||||
/// Conversion of a recognised format failed.
|
||||
#[error("failed to convert {format} to RVF: {detail}")]
|
||||
ConversionFailed {
|
||||
/// Source format label.
|
||||
format: &'static str,
|
||||
/// Failure detail.
|
||||
detail: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Detect a model-file format from its bytes and optional file name.
|
||||
///
|
||||
/// Magic bytes take precedence; the `name` (lowercased file name, may be empty)
|
||||
/// disambiguates the JSONL/`.bin` cases that share a leading `{`/raw bytes.
|
||||
pub fn detect_format(data: &[u8], name: &str) -> ModelFormat {
|
||||
let name = name.to_ascii_lowercase();
|
||||
|
||||
// RVFS magic at offset 0 (the only format the loader reads directly).
|
||||
if leading_u32(data) == Some(RVFS_MAGIC) {
|
||||
return ModelFormat::Rvf;
|
||||
}
|
||||
// safetensors: 8-byte LE header length, then a JSON object opening with '{'.
|
||||
// Checked before the `.bin`/`-q` naming heuristic so a `.safetensors` file
|
||||
// is never mistaken for a quant blob. Validate the declared length is
|
||||
// plausible to avoid false positives.
|
||||
if name.ends_with(".safetensors") || looks_like_safetensors(data) {
|
||||
return ModelFormat::Safetensors;
|
||||
}
|
||||
// HF quantized blob: exact magic, OR `.bin`/`-q` naming.
|
||||
if leading_u32(data) == Some(HF_QUANT_MAGIC) || name.ends_with(".bin") || name.contains("-q") {
|
||||
return ModelFormat::HfQuantBin;
|
||||
}
|
||||
// JSONL manifest: well-known suffix, or a leading '{' that is NOT preceded
|
||||
// by an 8-byte length (already handled above).
|
||||
if name.ends_with(".jsonl") || name.ends_with(".rvf.jsonl") || data.first() == Some(&b'{') {
|
||||
return ModelFormat::JsonlManifest;
|
||||
}
|
||||
ModelFormat::Unknown
|
||||
}
|
||||
|
||||
/// Map a detected format (for a file that the RVF loader rejected) to a typed,
|
||||
/// actionable [`ModelLoadError`]. `detail` carries the original loader message.
|
||||
pub fn classify_load_failure(data: &[u8], name: &str, detail: &str) -> ModelLoadError {
|
||||
match detect_format(data, name) {
|
||||
ModelFormat::Rvf => ModelLoadError::Unknown {
|
||||
first_bytes: leading_u32(data).unwrap_or(0),
|
||||
detail: format!("RVFS magic present but container parse failed: {detail}"),
|
||||
},
|
||||
ModelFormat::Safetensors => ModelLoadError::NeedsConversion {
|
||||
detected: ModelFormat::Safetensors.label(),
|
||||
},
|
||||
ModelFormat::JsonlManifest => ModelLoadError::NeedsConversion {
|
||||
detected: ModelFormat::JsonlManifest.label(),
|
||||
},
|
||||
ModelFormat::HfQuantBin => ModelLoadError::UnsupportedQuant {
|
||||
magic: leading_u32(data).unwrap_or(HF_QUANT_MAGIC),
|
||||
},
|
||||
ModelFormat::Unknown => ModelLoadError::Unknown {
|
||||
first_bytes: leading_u32(data).unwrap_or(0),
|
||||
detail: detail.to_string(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a `model.safetensors` byte buffer into an RVF binary container that
|
||||
/// [`crate::rvf_pipeline::ProgressiveLoader`] can load (issue #894).
|
||||
///
|
||||
/// Every `F32` tensor in the safetensors file is flattened (in header order)
|
||||
/// into the RVF `SEG_VEC` weight segment; a manifest records provenance. The
|
||||
/// returned bytes start with the `RVFS` magic and load cleanly.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`ModelLoadError::ConversionFailed`] if the safetensors header is malformed,
|
||||
/// or [`ModelLoadError::NeedsConversion`]-shaped detail if no F32 tensors exist.
|
||||
pub fn safetensors_to_rvf(data: &[u8], model_id: &str) -> Result<Vec<u8>, ModelLoadError> {
|
||||
let fail = |d: String| ModelLoadError::ConversionFailed {
|
||||
format: ModelFormat::Safetensors.label(),
|
||||
detail: d,
|
||||
};
|
||||
|
||||
if data.len() < 8 {
|
||||
return Err(fail("file shorter than the 8-byte safetensors length header".into()));
|
||||
}
|
||||
let header_len = u64::from_le_bytes(data[0..8].try_into().unwrap()) as usize;
|
||||
let header_start: usize = 8;
|
||||
let header_end = header_start
|
||||
.checked_add(header_len)
|
||||
.filter(|&e| e <= data.len())
|
||||
.ok_or_else(|| fail(format!("declared header length {header_len} exceeds file size")))?;
|
||||
|
||||
let header: serde_json::Value = serde_json::from_slice(&data[header_start..header_end])
|
||||
.map_err(|e| fail(format!("safetensors header is not valid JSON: {e}")))?;
|
||||
let obj = header
|
||||
.as_object()
|
||||
.ok_or_else(|| fail("safetensors header is not a JSON object".into()))?;
|
||||
|
||||
let tensor_base = header_end;
|
||||
let mut weights: Vec<f32> = Vec::new();
|
||||
let mut tensor_names: Vec<String> = Vec::new();
|
||||
|
||||
// Iterate tensors in a stable (sorted) order for deterministic output.
|
||||
let mut entries: Vec<(&String, &serde_json::Value)> = obj
|
||||
.iter()
|
||||
.filter(|(k, _)| k.as_str() != "__metadata__")
|
||||
.collect();
|
||||
entries.sort_by(|a, b| a.0.cmp(b.0));
|
||||
|
||||
for (tname, tinfo) in entries {
|
||||
let dtype = tinfo.get("dtype").and_then(|d| d.as_str()).unwrap_or("");
|
||||
// Only F32 is decoded into the weight vector. Other dtypes are recorded
|
||||
// in the manifest but not flattened (honest: we do not silently cast).
|
||||
let offsets = tinfo
|
||||
.get("data_offsets")
|
||||
.and_then(|o| o.as_array())
|
||||
.and_then(|a| {
|
||||
Some((a.first()?.as_u64()? as usize, a.get(1)?.as_u64()? as usize))
|
||||
});
|
||||
let Some((start, end)) = offsets else { continue };
|
||||
let abs_start = tensor_base.checked_add(start);
|
||||
let abs_end = tensor_base.checked_add(end);
|
||||
match (abs_start, abs_end) {
|
||||
(Some(s), Some(e)) if e <= data.len() && s <= e => {
|
||||
if dtype == "F32" {
|
||||
let bytes = &data[s..e];
|
||||
if bytes.len() % 4 == 0 {
|
||||
for chunk in bytes.chunks_exact(4) {
|
||||
weights.push(f32::from_le_bytes([
|
||||
chunk[0], chunk[1], chunk[2], chunk[3],
|
||||
]));
|
||||
}
|
||||
tensor_names.push(tname.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
return Err(fail(format!(
|
||||
"tensor `{tname}` data_offsets [{start}..{end}] out of bounds"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if weights.is_empty() {
|
||||
return Err(fail(
|
||||
"no F32 tensors found to convert (the published weights may be quantized; \
|
||||
use a full-precision safetensors export)"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
|
||||
let mut builder = RvfBuilder::new();
|
||||
builder.add_manifest(
|
||||
model_id,
|
||||
"converted-from-safetensors",
|
||||
"RVF container converted from model.safetensors (issue #894)",
|
||||
);
|
||||
builder.add_weights(&weights);
|
||||
builder.add_metadata(&serde_json::json!({
|
||||
"source_format": "safetensors",
|
||||
"converted_tensors": tensor_names,
|
||||
"n_weights": weights.len(),
|
||||
"note": "weights loaded; pose-decoder architecture may differ — see #894",
|
||||
}));
|
||||
Ok(builder.build())
|
||||
}
|
||||
|
||||
/// Convert a `model.rvf.jsonl` byte buffer into an RVF binary container.
|
||||
///
|
||||
/// The JSONL manifest is one JSON object per line. This wraps the parsed lines
|
||||
/// into an RVF manifest + metadata so the file becomes loadable; any numeric
|
||||
/// `weights` array found on a line is flattened into the weight segment.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`ModelLoadError::ConversionFailed`] if no line parses as JSON.
|
||||
pub fn jsonl_to_rvf(data: &[u8], model_id: &str) -> Result<Vec<u8>, ModelLoadError> {
|
||||
let fail = |d: String| ModelLoadError::ConversionFailed {
|
||||
format: ModelFormat::JsonlManifest.label(),
|
||||
detail: d,
|
||||
};
|
||||
let text = std::str::from_utf8(data).map_err(|e| fail(format!("not valid UTF-8: {e}")))?;
|
||||
|
||||
let mut lines: Vec<serde_json::Value> = Vec::new();
|
||||
let mut weights: Vec<f32> = Vec::new();
|
||||
for line in text.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let v: serde_json::Value = serde_json::from_str(line)
|
||||
.map_err(|e| fail(format!("line is not valid JSON: {e}")))?;
|
||||
if let Some(arr) = v.get("weights").and_then(|w| w.as_array()) {
|
||||
for x in arr {
|
||||
if let Some(f) = x.as_f64() {
|
||||
weights.push(f as f32);
|
||||
}
|
||||
}
|
||||
}
|
||||
lines.push(v);
|
||||
}
|
||||
if lines.is_empty() {
|
||||
return Err(fail("manifest contained no JSON lines".into()));
|
||||
}
|
||||
|
||||
let mut builder = RvfBuilder::new();
|
||||
builder.add_manifest(
|
||||
model_id,
|
||||
"converted-from-jsonl",
|
||||
"RVF container converted from model.rvf.jsonl (issue #894)",
|
||||
);
|
||||
if !weights.is_empty() {
|
||||
builder.add_weights(&weights);
|
||||
}
|
||||
builder.add_metadata(&serde_json::json!({
|
||||
"source_format": "rvf.jsonl",
|
||||
"n_lines": lines.len(),
|
||||
"n_weights": weights.len(),
|
||||
}));
|
||||
Ok(builder.build())
|
||||
}
|
||||
|
||||
/// Convert any *convertible* model file to RVF bytes, auto-detecting the format.
|
||||
///
|
||||
/// Used by the `--convert-model` CLI seam. Returns the converted RVF bytes, or a
|
||||
/// typed error for formats that cannot be converted (quantized blobs, unknown).
|
||||
pub fn convert_to_rvf(data: &[u8], name: &str, model_id: &str) -> Result<Vec<u8>, ModelLoadError> {
|
||||
match detect_format(data, name) {
|
||||
ModelFormat::Rvf => Ok(data.to_vec()), // already RVF — pass through.
|
||||
ModelFormat::Safetensors => safetensors_to_rvf(data, model_id),
|
||||
ModelFormat::JsonlManifest => jsonl_to_rvf(data, model_id),
|
||||
ModelFormat::HfQuantBin => Err(ModelLoadError::UnsupportedQuant {
|
||||
magic: leading_u32(data).unwrap_or(HF_QUANT_MAGIC),
|
||||
}),
|
||||
ModelFormat::Unknown => Err(ModelLoadError::Unknown {
|
||||
first_bytes: leading_u32(data).unwrap_or(0),
|
||||
detail: "not a convertible model format".into(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
// ── helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
fn leading_u32(data: &[u8]) -> Option<u32> {
|
||||
data.get(0..4)
|
||||
.map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]]))
|
||||
}
|
||||
|
||||
/// A safetensors file: first 8 bytes are a LE u64 header length, byte 8 is `{`,
|
||||
/// and the declared length must fit within the buffer (or be a plausible prefix).
|
||||
fn looks_like_safetensors(data: &[u8]) -> bool {
|
||||
if data.len() < 9 || data[8] != b'{' {
|
||||
return false;
|
||||
}
|
||||
let header_len = u64::from_le_bytes(data[0..8].try_into().unwrap());
|
||||
// A real header is non-trivial and bounded; reject absurd lengths that would
|
||||
// indicate this is actually some other binary that happens to have a '{' at
|
||||
// byte 8. Allow the case where we only have the header prefix (len > data).
|
||||
header_len >= 2 && header_len <= 64 * 1024 * 1024
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::rvf_pipeline::ProgressiveLoader;
|
||||
|
||||
/// Build a minimal valid safetensors buffer with one F32 tensor.
|
||||
fn make_safetensors(weights: &[f32]) -> Vec<u8> {
|
||||
let n = weights.len();
|
||||
let header = serde_json::json!({
|
||||
"weight": {
|
||||
"dtype": "F32",
|
||||
"shape": [n],
|
||||
"data_offsets": [0, n * 4],
|
||||
}
|
||||
});
|
||||
let header_bytes = serde_json::to_vec(&header).unwrap();
|
||||
let mut out = Vec::new();
|
||||
out.extend_from_slice(&(header_bytes.len() as u64).to_le_bytes());
|
||||
out.extend_from_slice(&header_bytes);
|
||||
for &w in weights {
|
||||
out.extend_from_slice(&w.to_le_bytes());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_safetensors_by_magic_and_name() {
|
||||
let st = make_safetensors(&[1.0, 2.0, 3.0]);
|
||||
assert_eq!(detect_format(&st, "model.safetensors"), ModelFormat::Safetensors);
|
||||
assert_eq!(detect_format(&st, ""), ModelFormat::Safetensors); // by content
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_hf_quant_magic() {
|
||||
// The exact bytes the loader reported: "5WEw" => LE u32 0x77455735.
|
||||
let data = [0x35u8, 0x57, 0x45, 0x77, 0xAA, 0xBB];
|
||||
assert_eq!(leading_u32(&data), Some(HF_QUANT_MAGIC));
|
||||
assert_eq!(detect_format(&data, "model-q4.bin"), ModelFormat::HfQuantBin);
|
||||
assert_eq!(detect_format(&data, ""), ModelFormat::HfQuantBin); // by magic
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_jsonl_and_rvf() {
|
||||
assert_eq!(detect_format(b"{\"seg\":0}\n", "model.rvf.jsonl"), ModelFormat::JsonlManifest);
|
||||
// RVFS magic ("RVFS" LE) -> Rvf.
|
||||
let rvfs = RVFS_MAGIC.to_le_bytes();
|
||||
assert_eq!(detect_format(&rvfs, "model.rvf"), ModelFormat::Rvf);
|
||||
}
|
||||
|
||||
/// CORE #894 PROOF: the published safetensors converts to a container the
|
||||
/// ProgressiveLoader loads (Layer A succeeds, weights present) — the old
|
||||
/// path returned the opaque "invalid magic … 0x77455735" and gave up.
|
||||
#[test]
|
||||
fn safetensors_converts_and_loads() {
|
||||
let st = make_safetensors(&[1.0, 2.0, 3.0, 4.0]);
|
||||
let rvf = safetensors_to_rvf(&st, "wifi-densepose-pretrained")
|
||||
.expect("safetensors must convert to RVF");
|
||||
// The converted bytes carry the RVFS magic.
|
||||
assert_eq!(leading_u32(&rvf), Some(RVFS_MAGIC));
|
||||
// And the ProgressiveLoader actually loads it.
|
||||
let mut loader = ProgressiveLoader::new(&rvf).expect("converted RVF must load");
|
||||
let la = loader.load_layer_a().expect("Layer A");
|
||||
assert_eq!(la.model_name, "wifi-densepose-pretrained");
|
||||
let lc = loader.load_layer_c().expect("Layer C");
|
||||
assert_eq!(lc.all_weights, vec![1.0, 2.0, 3.0, 4.0], "weights round-trip");
|
||||
}
|
||||
|
||||
/// CORE #894 PROOF: feeding the HF quant magic to the classifier yields the
|
||||
/// new actionable typed error — never the opaque magic panic.
|
||||
#[test]
|
||||
fn hf_quant_classifies_to_actionable_error() {
|
||||
let data = [0x35u8, 0x57, 0x45, 0x77];
|
||||
let err = classify_load_failure(
|
||||
&data,
|
||||
"model-q4.bin",
|
||||
"invalid magic at offset 0: expected 0x52564653, got 0x77455735",
|
||||
);
|
||||
assert!(matches!(err, ModelLoadError::UnsupportedQuant { magic } if magic == HF_QUANT_MAGIC));
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("safetensors"), "must point at the loadable format: {msg}");
|
||||
assert!(!msg.contains("invalid magic at offset"), "must not leak opaque magic: {msg}");
|
||||
}
|
||||
|
||||
/// safetensors load failure is classified as NeedsConversion with a
|
||||
/// one-command path — not the opaque magic.
|
||||
#[test]
|
||||
fn safetensors_classifies_to_needs_conversion() {
|
||||
let st = make_safetensors(&[1.0]);
|
||||
let err = classify_load_failure(&st, "model.safetensors", "invalid magic …");
|
||||
assert!(matches!(err, ModelLoadError::NeedsConversion { .. }));
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("--convert-model"), "must give the convert command: {msg}");
|
||||
}
|
||||
|
||||
/// jsonl manifest converts and loads.
|
||||
#[test]
|
||||
fn jsonl_converts_and_loads() {
|
||||
let jsonl = b"{\"model_id\":\"x\"}\n{\"weights\":[1.0,2.0]}\n";
|
||||
let rvf = jsonl_to_rvf(jsonl, "x").expect("jsonl converts");
|
||||
let mut loader = ProgressiveLoader::new(&rvf).expect("converted jsonl loads");
|
||||
let _ = loader.load_layer_a().expect("Layer A");
|
||||
let lc = loader.load_layer_c().expect("Layer C");
|
||||
assert_eq!(lc.all_weights, vec![1.0, 2.0]);
|
||||
}
|
||||
|
||||
/// convert_to_rvf dispatches by detected format and rejects quant blobs.
|
||||
#[test]
|
||||
fn convert_to_rvf_dispatches_and_rejects_quant() {
|
||||
let st = make_safetensors(&[5.0]);
|
||||
assert!(convert_to_rvf(&st, "model.safetensors", "m").is_ok());
|
||||
let quant = [0x35u8, 0x57, 0x45, 0x77];
|
||||
assert!(matches!(
|
||||
convert_to_rvf(&quant, "model-q4.bin", "m"),
|
||||
Err(ModelLoadError::UnsupportedQuant { .. })
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,44 @@ impl Default for AdversarialConfig {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Detection tuning constants (ADR-154 §7.4 #13 — DATA-GATED)
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// These were bare numeric literals buried in `check`/`check_consistency`. They
|
||||
// are EMPIRICAL DEFAULTS, not calibrated operating points — setting defensible
|
||||
// values needs labelled spoofed/clean CSI (the Wi-Spoof benchmark, §6.2/§7.3).
|
||||
// De-magicking + the boundary tests below make any future data-driven retune a
|
||||
// visible, tested change. The VALUES here are unchanged from the pre-ADR-154
|
||||
// behaviour; only their names and the pinning tests are new.
|
||||
|
||||
/// Gini coefficient above which the energy distribution is flagged as a
|
||||
/// `FieldModelViolation` (one link hogging the energy → likely injection).
|
||||
/// EMPIRICAL DEFAULT pending labelled calibration.
|
||||
const FIELD_MODEL_GINI_VIOLATION: f64 = 0.8;
|
||||
|
||||
/// Energy-conservation ratio (total / expected-for-body-count) above which the
|
||||
/// frame is flagged as an `EnergyViolation` (too much energy for the occupancy).
|
||||
/// EMPIRICAL DEFAULT pending labelled calibration.
|
||||
const ENERGY_RATIO_HIGH_VIOLATION: f64 = 2.0;
|
||||
|
||||
/// Energy-conservation ratio below which an *occupied* frame is flagged as an
|
||||
/// `EnergyViolation` (too little energy for a claimed body — possible dropout
|
||||
/// or masking). Only applied when `n_bodies > 0`. EMPIRICAL DEFAULT.
|
||||
const ENERGY_RATIO_LOW_VIOLATION: f64 = 0.1;
|
||||
|
||||
/// Fraction of the mean per-link energy a link must exceed to count as
|
||||
/// "active" in the multi-link consistency check. EMPIRICAL DEFAULT.
|
||||
const CONSISTENCY_ACTIVE_FRACTION_OF_MEAN: f64 = 0.1;
|
||||
|
||||
/// Weights of the four checks in the aggregate anomaly score (sum to 1.0).
|
||||
/// EMPIRICAL DEFAULTS — equal 0.2 split with consistency double-weighted (0.4)
|
||||
/// because single-link injection is the primary threat model (ADR-030 Tier 7).
|
||||
const SCORE_W_CONSISTENCY: f64 = 0.4;
|
||||
const SCORE_W_FIELD_MODEL: f64 = 0.2;
|
||||
const SCORE_W_TEMPORAL: f64 = 0.2;
|
||||
const SCORE_W_ENERGY: f64 = 0.2;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Detection results
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -250,13 +288,15 @@ impl AdversarialDetector {
|
||||
if consistency < self.config.consistency_threshold {
|
||||
violations.push(AnomalyType::SingleLinkInjection);
|
||||
}
|
||||
if field_residual > 0.8 {
|
||||
if field_residual > FIELD_MODEL_GINI_VIOLATION {
|
||||
violations.push(AnomalyType::FieldModelViolation);
|
||||
}
|
||||
if temporal > self.config.max_temporal_discontinuity {
|
||||
violations.push(AnomalyType::TemporalDiscontinuity);
|
||||
}
|
||||
if energy_ratio > 2.0 || (n_bodies > 0 && energy_ratio < 0.1) {
|
||||
if energy_ratio > ENERGY_RATIO_HIGH_VIOLATION
|
||||
|| (n_bodies > 0 && energy_ratio < ENERGY_RATIO_LOW_VIOLATION)
|
||||
{
|
||||
violations.push(AnomalyType::EnergyViolation);
|
||||
}
|
||||
|
||||
@@ -268,10 +308,10 @@ impl AdversarialDetector {
|
||||
};
|
||||
|
||||
// Score: weighted combination
|
||||
let anomaly_score = ((1.0 - consistency) * 0.4
|
||||
+ field_residual * 0.2
|
||||
+ (temporal / self.config.max_temporal_discontinuity).min(1.0) * 0.2
|
||||
+ ((energy_ratio - 1.0).abs() / 2.0).min(1.0) * 0.2)
|
||||
let anomaly_score = ((1.0 - consistency) * SCORE_W_CONSISTENCY
|
||||
+ field_residual * SCORE_W_FIELD_MODEL
|
||||
+ (temporal / self.config.max_temporal_discontinuity).min(1.0) * SCORE_W_TEMPORAL
|
||||
+ ((energy_ratio - 1.0).abs() / 2.0).min(1.0) * SCORE_W_ENERGY)
|
||||
.clamp(0.0, 1.0);
|
||||
|
||||
// Find affected links (highest single-link energy ratio)
|
||||
@@ -304,7 +344,8 @@ impl AdversarialDetector {
|
||||
}
|
||||
|
||||
let mean = total / energies.len() as f64;
|
||||
let threshold = mean * 0.1; // link must have at least 10% of mean energy
|
||||
// link must have at least CONSISTENCY_ACTIVE_FRACTION_OF_MEAN of mean energy
|
||||
let threshold = mean * CONSISTENCY_ACTIVE_FRACTION_OF_MEAN;
|
||||
|
||||
let active_count = energies.iter().filter(|&&e| e > threshold).count();
|
||||
active_count as f64 / energies.len() as f64
|
||||
@@ -641,4 +682,118 @@ mod tests {
|
||||
gini
|
||||
);
|
||||
}
|
||||
|
||||
// ── ADR-154 §7.4 #13: threshold characterization (DATA-GATED) ───────────
|
||||
// These pin the CURRENT empirical threshold values so a future labelled-data
|
||||
// retune is a visible, tested change. They do NOT assert the values are
|
||||
// "correct" — only that the named consts equal the de-magicked literals and
|
||||
// that the decision boundaries sit exactly where the old bare literals put
|
||||
// them.
|
||||
|
||||
/// The named consts must equal the original bare literals (no value drift).
|
||||
#[test]
|
||||
fn tuning_consts_unchanged_from_literals() {
|
||||
assert_eq!(FIELD_MODEL_GINI_VIOLATION, 0.8);
|
||||
assert_eq!(ENERGY_RATIO_HIGH_VIOLATION, 2.0);
|
||||
assert_eq!(ENERGY_RATIO_LOW_VIOLATION, 0.1);
|
||||
assert_eq!(CONSISTENCY_ACTIVE_FRACTION_OF_MEAN, 0.1);
|
||||
assert!(
|
||||
(SCORE_W_CONSISTENCY + SCORE_W_FIELD_MODEL + SCORE_W_TEMPORAL + SCORE_W_ENERGY - 1.0)
|
||||
.abs()
|
||||
< 1e-12,
|
||||
"score weights must sum to 1.0"
|
||||
);
|
||||
}
|
||||
|
||||
/// Energy-ratio HIGH boundary: the `> ENERGY_RATIO_HIGH_VIOLATION` decision
|
||||
/// flips just above 2.0. With max_energy_per_body=10 and n_bodies=1, total
|
||||
/// energy E gives ratio E/10, so E=20 is the boundary. Use a clean uniform
|
||||
/// distribution so ONLY the energy check can fire.
|
||||
#[test]
|
||||
fn energy_ratio_high_boundary() {
|
||||
let mk = |per_link: f64| {
|
||||
// 6 links, uniform → consistency=1, gini≈0, temporal=0 (first frame).
|
||||
vec![per_link; 6]
|
||||
};
|
||||
// ratio just BELOW 2.0 (total=19.2 → ratio 1.92): no energy violation.
|
||||
let mut det = AdversarialDetector::new(default_config()).unwrap();
|
||||
let below = det.check(&mk(3.2), 1, 0).unwrap(); // 6*3.2=19.2
|
||||
assert!(
|
||||
!below.anomaly_detected,
|
||||
"ratio 1.92 (<2.0) must not flag energy violation: {:?}",
|
||||
below.anomaly_type
|
||||
);
|
||||
// ratio just ABOVE 2.0 (total=21.0 → ratio 2.1): energy violation fires.
|
||||
let mut det2 = AdversarialDetector::new(default_config()).unwrap();
|
||||
let above = det2.check(&mk(3.5), 1, 0).unwrap(); // 6*3.5=21.0
|
||||
assert!(
|
||||
above.anomaly_detected,
|
||||
"ratio 2.1 (>2.0) must flag an anomaly"
|
||||
);
|
||||
}
|
||||
|
||||
/// Energy-ratio LOW boundary: an occupied frame with ratio < 0.1 flags an
|
||||
/// `EnergyViolation`. With n_bodies=1, max_energy_per_body=10, boundary
|
||||
/// total = 1.0 (ratio 0.1). Below it (total 0.9 → 0.09) must flag.
|
||||
#[test]
|
||||
fn energy_ratio_low_boundary() {
|
||||
// just ABOVE 0.1 (total 1.2 → ratio 0.12): no energy violation.
|
||||
let mut det = AdversarialDetector::new(default_config()).unwrap();
|
||||
let above = det.check(&vec![0.2; 6], 1, 0).unwrap(); // 6*0.2=1.2
|
||||
assert!(
|
||||
!above.anomaly_detected,
|
||||
"ratio 0.12 (>0.1) must not flag: {:?}",
|
||||
above.anomaly_type
|
||||
);
|
||||
// just BELOW 0.1 (total 0.6 → ratio 0.06): energy violation fires.
|
||||
let mut det2 = AdversarialDetector::new(default_config()).unwrap();
|
||||
let below = det2.check(&vec![0.1; 6], 1, 0).unwrap(); // 6*0.1=0.6
|
||||
assert!(
|
||||
below.anomaly_detected,
|
||||
"ratio 0.06 (<0.1) must flag an energy anomaly"
|
||||
);
|
||||
}
|
||||
|
||||
/// Field-model Gini boundary: `check_field_model` > 0.8 → FieldModelViolation.
|
||||
/// We directly characterize where the Gini crosses 0.8 for a one-hot vs
|
||||
/// uniform-tail mix, pinning the 0.8 const.
|
||||
#[test]
|
||||
fn field_model_gini_boundary() {
|
||||
let det = AdversarialDetector::new(default_config()).unwrap();
|
||||
// Fully concentrated (one-hot) over 6 links → Gini = (n-1)/n = 0.833 > 0.8.
|
||||
let concentrated = det.check_field_model(&[6.0, 0.0, 0.0, 0.0, 0.0, 0.0], 6.0);
|
||||
assert!(
|
||||
concentrated > FIELD_MODEL_GINI_VIOLATION,
|
||||
"one-hot Gini {concentrated} must exceed the 0.8 violation threshold"
|
||||
);
|
||||
// Uniform → Gini ≈ 0 < 0.8.
|
||||
let uniform = det.check_field_model(&[1.0; 6], 6.0);
|
||||
assert!(
|
||||
uniform < FIELD_MODEL_GINI_VIOLATION,
|
||||
"uniform Gini {uniform} must be below the 0.8 threshold"
|
||||
);
|
||||
}
|
||||
|
||||
/// Consistency active-fraction boundary: a link counts as "active" iff its
|
||||
/// energy > 0.1·mean. Pin that exactly one sub-threshold link is excluded.
|
||||
#[test]
|
||||
fn consistency_active_fraction_boundary() {
|
||||
let det = AdversarialDetector::new(default_config()).unwrap();
|
||||
// 5 links at 1.0, one link at just BELOW 0.1·mean.
|
||||
// mean over 6 = (5.0 + x)/6; for x small, threshold ≈ 0.1*5/6 ≈ 0.083.
|
||||
let mut e = vec![1.0; 6];
|
||||
e[5] = 0.05; // below ~0.083 threshold → excluded
|
||||
let c_excluded = det.check_consistency(&e, e.iter().sum());
|
||||
assert!(
|
||||
(c_excluded - 5.0 / 6.0).abs() < 1e-9,
|
||||
"sub-threshold link must be excluded: got {c_excluded}"
|
||||
);
|
||||
// Bump it well above threshold → counts as active (all 6).
|
||||
e[5] = 1.0;
|
||||
let c_included = det.check_consistency(&e, e.iter().sum());
|
||||
assert!(
|
||||
(c_included - 1.0).abs() < 1e-9,
|
||||
"above-threshold link must count: got {c_included}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,8 +145,10 @@ pub enum CirError {
|
||||
#[error("subcarrier count mismatch: expected {expected}, got {got}")]
|
||||
SubcarrierMismatch { expected: usize, got: usize },
|
||||
|
||||
/// Phase variance exceeds 2π — frame appears unsanitized (ghost-tap risk).
|
||||
#[error("CSI phase variance {variance:.3} suggests unsanitized input (ghost-tap risk)")]
|
||||
/// Circular phase variance (V = 1 − R̄ ∈ [0,1]) is too high — the CSI phase
|
||||
/// is near-uniformly spread across subcarriers, the signature of unsanitized
|
||||
/// SFO/CFO (ghost-tap risk). See `GHOST_TAP_CIRCULAR_VARIANCE_MAX`.
|
||||
#[error("CSI circular phase variance {variance:.3} suggests unsanitized input (ghost-tap risk)")]
|
||||
UnsanitizedPhase { variance: f32 },
|
||||
|
||||
/// ISTA did not converge within the iteration budget.
|
||||
@@ -567,9 +569,14 @@ impl CirEstimator {
|
||||
|
||||
let y = self.extract_csi_vector(csi);
|
||||
|
||||
// Ghost-tap guard: phase variance > 2π signals unsanitized SFO/CFO.
|
||||
// Ghost-tap guard: a near-uniform spread of CSI phase across subcarriers
|
||||
// signals unsanitized SFO/CFO (raw hardware phase ramps that were never
|
||||
// de-rotated). `phase_variance` is now Mardia's *circular* variance
|
||||
// V = 1 − R̄ ∈ [0,1] (ADR-154 §7.4 #1), so the old `> TAU` (≈6.28)
|
||||
// threshold — meaningful only for the unbounded linear variance — no
|
||||
// longer applies. We compare against the bounded const below.
|
||||
let phase_var = phase_variance(&y);
|
||||
if phase_var > std::f32::consts::TAU {
|
||||
if phase_var > GHOST_TAP_CIRCULAR_VARIANCE_MAX {
|
||||
return Err(CirError::UnsanitizedPhase {
|
||||
variance: phase_var,
|
||||
});
|
||||
@@ -988,17 +995,64 @@ fn normalize_complex(v: &mut [Complex32]) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Variance of the instantaneous phase angles (rad) across a complex vector.
|
||||
/// Ghost-tap guard threshold on the **circular** phase variance (ADR-154 §7.4 #1).
|
||||
///
|
||||
/// `phase_variance` returns Mardia's circular variance V = 1 − R̄ ∈ [0,1].
|
||||
/// The guard rejects a frame as unsanitized when V exceeds this cutoff, i.e.
|
||||
/// when the mean resultant length R̄ falls below `1 − MAX`. At V = 0.99 the
|
||||
/// guard fires only when R̄ ≤ 0.01 — essentially uniform phase, the signature
|
||||
/// of raw SFO/CFO ramps the gate is meant to reject — while a sanitized,
|
||||
/// concentrated phase set (R̄ near 1, V near 0) passes comfortably.
|
||||
///
|
||||
/// **DATA-GATED (ADR-154 §7.4 #1):** this is a deliberately *conservative*
|
||||
/// default, not a calibrated operating point. A clean single-path channel with
|
||||
/// appreciable delay also sweeps the circle (high V), so V alone cannot cleanly
|
||||
/// separate "clean ramp" from "unsanitized noise" without labelled
|
||||
/// sanitized/unsanitized frames. The *metric* (circular variance) is MEASURED;
|
||||
/// this *value* awaits per-deployment calibration. Until then we err toward
|
||||
/// never false-rejecting a real frame — strictly more permissive at the wrap
|
||||
/// boundary than the old linear-variance guard, which is the bug being fixed.
|
||||
const GHOST_TAP_CIRCULAR_VARIANCE_MAX: f32 = 0.99;
|
||||
|
||||
/// Circular variance of the instantaneous phase angles across a complex vector.
|
||||
///
|
||||
/// Phase angles live on the circle and wrap at ±π, so a *linear* sample variance
|
||||
/// (the previous implementation, ADR-154 §7.4 #1) reports spuriously HIGH
|
||||
/// dispersion for a tightly-clustered set straddling the ±π branch cut — e.g.
|
||||
/// `{+3.13, −3.13}` are 0.02 rad apart on the circle but ≈2π apart on the line.
|
||||
/// That made the `phase_variance > TAU` ghost-tap guard FALSE-TRIP on real,
|
||||
/// tightly-clustered CIR taps.
|
||||
///
|
||||
/// The correct metric is Mardia's circular variance:
|
||||
///
|
||||
/// R̄ = | (1/n) · Σ_k e^{iθ_k} | (mean resultant length, ∈ [0,1])
|
||||
/// V = 1 − R̄ (circular variance, ∈ [0,1])
|
||||
///
|
||||
/// V = 0 ⇔ all angles identical (maximally concentrated); V = 1 ⇔ the unit
|
||||
/// phasors cancel (e.g. uniformly-spread angles → R̄ = 0). It is invariant to
|
||||
/// where the cluster sits on the circle, so the branch-cut artefact is gone.
|
||||
///
|
||||
/// Reference: Mardia & Jupp, *Directional Statistics* (2000), §1.3.
|
||||
#[inline]
|
||||
fn phase_variance(y: &[Complex32]) -> f32 {
|
||||
let n = y.len();
|
||||
if n < 2 {
|
||||
return 0.0;
|
||||
}
|
||||
// Mean resultant vector of the *unit* phasors e^{iθ_k}. Normalising each
|
||||
// term to unit magnitude makes this a pure phase statistic (amplitude does
|
||||
// not bias the dispersion), matching the linear version which used only
|
||||
// `arg()`.
|
||||
let mut sx = 0.0f32;
|
||||
let mut sy = 0.0f32;
|
||||
for c in y {
|
||||
let theta = c.arg();
|
||||
sx += theta.cos();
|
||||
sy += theta.sin();
|
||||
}
|
||||
let nf = n as f32;
|
||||
let phases: Vec<f32> = y.iter().map(|c| c.arg()).collect();
|
||||
let mean = phases.iter().sum::<f32>() / nf;
|
||||
phases.iter().map(|p| (p - mean) * (p - mean)).sum::<f32>() / nf
|
||||
let r_bar = ((sx * sx + sy * sy).sqrt() / nf).clamp(0.0, 1.0);
|
||||
1.0 - r_bar
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1205,6 +1259,108 @@ mod tests {
|
||||
assert!(phase_variance(&y) < 1e-6);
|
||||
}
|
||||
|
||||
// ── ADR-154 §7.4 #1: circular vs linear phase variance ──────────────────
|
||||
|
||||
/// Inline replica of the OLD linear sample variance over `arg()` — kept in
|
||||
/// the test only, so we can show the exact contrast the fix removes.
|
||||
fn old_linear_phase_variance(y: &[Complex32]) -> f32 {
|
||||
let n = y.len();
|
||||
if n < 2 {
|
||||
return 0.0;
|
||||
}
|
||||
let nf = n as f32;
|
||||
let phases: Vec<f32> = y.iter().map(|c| c.arg()).collect();
|
||||
let mean = phases.iter().sum::<f32>() / nf;
|
||||
phases.iter().map(|p| (p - mean) * (p - mean)).sum::<f32>() / nf
|
||||
}
|
||||
|
||||
/// FAILS-ON-OLD: phases tightly clustered across the ±π branch cut. The old
|
||||
/// LINEAR variance reports a huge value (≈π²) and would trip the `> TAU`
|
||||
/// guard; the new CIRCULAR variance reports ≈0 (the cluster is 0.04 rad wide
|
||||
/// on the circle) and the guard does NOT false-trip.
|
||||
#[test]
|
||||
fn phase_variance_circular_not_fooled_by_branch_cut() {
|
||||
// 40 unit phasors split between +π−ε and −π+ε: true angular spread ≈0.04
|
||||
// rad, but they straddle the wrap point.
|
||||
let eps = 0.02_f32;
|
||||
let y: Vec<Complex32> = (0..40)
|
||||
.map(|i| {
|
||||
let theta = if i % 2 == 0 {
|
||||
std::f32::consts::PI - eps
|
||||
} else {
|
||||
-std::f32::consts::PI + eps
|
||||
};
|
||||
Complex32::new(theta.cos(), theta.sin())
|
||||
})
|
||||
.collect();
|
||||
|
||||
let old = old_linear_phase_variance(&y);
|
||||
let new = phase_variance(&y);
|
||||
|
||||
// The OLD metric is spuriously huge (well past the old TAU≈6.28 guard).
|
||||
assert!(
|
||||
old > std::f32::consts::TAU,
|
||||
"old linear variance should be large (>TAU) on wrap-straddling phases, was {old}"
|
||||
);
|
||||
// The NEW circular variance is ≈0 — the cluster is genuinely tight.
|
||||
assert!(
|
||||
new < 0.01,
|
||||
"circular variance must be ~0 for a tight cluster across ±π, was {new}"
|
||||
);
|
||||
// And the guard must NOT false-trip on this (a real tight CIR tap).
|
||||
assert!(
|
||||
new <= GHOST_TAP_CIRCULAR_VARIANCE_MAX,
|
||||
"ghost-tap guard must not false-trip on a tight wrap-straddling cluster"
|
||||
);
|
||||
}
|
||||
|
||||
/// Circular variance is bounded [0,1] for arbitrary (deterministic-random)
|
||||
/// inputs, and hits its documented extremes: ≈0 for identical angles, ≈1
|
||||
/// for uniformly-spread angles.
|
||||
#[test]
|
||||
fn phase_variance_circular_is_bounded_and_extremal() {
|
||||
// Deterministic pseudo-random phases via an LCG — bounded check.
|
||||
let mut s: u32 = 0x1234_5678;
|
||||
let y: Vec<Complex32> = (0..200)
|
||||
.map(|_| {
|
||||
s = s.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
|
||||
let u = (s >> 8) as f32 / (1u32 << 24) as f32; // [0,1)
|
||||
let theta = u * std::f32::consts::TAU - std::f32::consts::PI;
|
||||
Complex32::new(theta.cos(), theta.sin())
|
||||
})
|
||||
.collect();
|
||||
let v = phase_variance(&y);
|
||||
assert!((0.0..=1.0).contains(&v), "V must be in [0,1], was {v}");
|
||||
|
||||
// Identical angles → V ≈ 0.
|
||||
let same: Vec<Complex32> = (0..64)
|
||||
.map(|_| {
|
||||
let t = 0.7_f32;
|
||||
Complex32::new(t.cos(), t.sin())
|
||||
})
|
||||
.collect();
|
||||
assert!(
|
||||
phase_variance(&same) < 1e-5,
|
||||
"identical angles must give V≈0, got {}",
|
||||
phase_variance(&same)
|
||||
);
|
||||
|
||||
// Angles spread uniformly around the full circle → resultant cancels,
|
||||
// V ≈ 1.
|
||||
let n = 360usize;
|
||||
let uniform: Vec<Complex32> = (0..n)
|
||||
.map(|k| {
|
||||
let t = std::f32::consts::TAU * (k as f32) / (n as f32);
|
||||
Complex32::new(t.cos(), t.sin())
|
||||
})
|
||||
.collect();
|
||||
assert!(
|
||||
phase_variance(&uniform) > 0.99,
|
||||
"uniformly-spread angles must give V≈1, got {}",
|
||||
phase_variance(&uniform)
|
||||
);
|
||||
}
|
||||
|
||||
/// Build a CsiFrame with a deterministic single-tap channel at `tau_sec`.
|
||||
fn make_single_tap_frame(
|
||||
num_subcarriers: usize,
|
||||
|
||||
@@ -249,11 +249,22 @@ pub fn coherence_score(current: &[f32], reference: &[f32], variance: &[f32]) ->
|
||||
(weighted_sum / weight_sum).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
/// Coherence score at/above which the environment is classified `Stable`
|
||||
/// (ADR-154 §7.4 #9 — DATA-GATED). EMPIRICAL DEFAULT, not a calibrated cutoff:
|
||||
/// a defensible value needs labelled stable/drifting environment traces. Pinned
|
||||
/// by `classify_drift_*_boundary` so a future retune is a visible, tested change.
|
||||
const DRIFT_STABLE_SCORE: f32 = 0.85;
|
||||
|
||||
/// Stale-frame count below which a coherence loss is treated as a transient
|
||||
/// `StepChange` rather than a sustained `Linear` drift (ADR-154 §7.4 #9 —
|
||||
/// DATA-GATED). EMPIRICAL DEFAULT pending labelled calibration.
|
||||
const DRIFT_STEP_CHANGE_MAX_STALE: u64 = 10;
|
||||
|
||||
/// Classify drift profile based on coherence history.
|
||||
fn classify_drift(score: f32, stale_count: u64) -> DriftProfile {
|
||||
if score >= 0.85 {
|
||||
if score >= DRIFT_STABLE_SCORE {
|
||||
DriftProfile::Stable
|
||||
} else if stale_count < 10 {
|
||||
} else if stale_count < DRIFT_STEP_CHANGE_MAX_STALE {
|
||||
// Brief coherence loss -> likely step change
|
||||
DriftProfile::StepChange
|
||||
} else {
|
||||
@@ -418,6 +429,38 @@ mod tests {
|
||||
assert_eq!(classify_drift(0.3, 20), DriftProfile::Linear);
|
||||
}
|
||||
|
||||
// ── ADR-154 §7.4 #9: drift-threshold characterization (DATA-GATED) ──────
|
||||
// Pin the CURRENT empirical thresholds so a future labelled-data retune is a
|
||||
// visible, tested change. These assert the decision boundaries, not that the
|
||||
// values are "correct".
|
||||
|
||||
/// The named consts must equal the original bare literals (no value drift).
|
||||
#[test]
|
||||
fn drift_consts_unchanged_from_literals() {
|
||||
assert_eq!(DRIFT_STABLE_SCORE, 0.85);
|
||||
assert_eq!(DRIFT_STEP_CHANGE_MAX_STALE, 10);
|
||||
}
|
||||
|
||||
/// Stable score boundary: `>= 0.85` is Stable; just below flips to a
|
||||
/// non-stable profile.
|
||||
#[test]
|
||||
fn classify_drift_stable_score_boundary() {
|
||||
// exactly at threshold → Stable
|
||||
assert_eq!(classify_drift(0.85, 0), DriftProfile::Stable);
|
||||
// just below → not Stable (StepChange, since stale_count < 10)
|
||||
assert_eq!(classify_drift(0.849, 0), DriftProfile::StepChange);
|
||||
}
|
||||
|
||||
/// Stale-count boundary: `< 10` is StepChange, `>= 10` is Linear (when the
|
||||
/// score is below the Stable cutoff).
|
||||
#[test]
|
||||
fn classify_drift_stale_count_boundary() {
|
||||
// just below 10 → StepChange
|
||||
assert_eq!(classify_drift(0.3, 9), DriftProfile::StepChange);
|
||||
// exactly 10 → Linear
|
||||
assert_eq!(classify_drift(0.3, 10), DriftProfile::Linear);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn per_subcarrier_zscores_correct() {
|
||||
let current = vec![2.0, 4.0];
|
||||
|
||||
@@ -77,13 +77,27 @@ pub struct GatePolicyConfig {
|
||||
pub adaptive: bool,
|
||||
}
|
||||
|
||||
// Gate-policy DEFAULTS (ADR-154 §7.4 #9 — DATA-GATED). These were bare literals
|
||||
// in the `Default` impl. They are already tunable per-instance via
|
||||
// `GatePolicyConfig`/`GatePolicy::new` (the config seam exists), so de-magicking
|
||||
// here is about naming + pinning the DEFAULTS. EMPIRICAL — defensible values
|
||||
// need labelled coherence traces; the VALUES are unchanged.
|
||||
/// Default coherence accept cutoff (full Kalman update above this).
|
||||
const DEFAULT_ACCEPT_THRESHOLD: f32 = 0.85;
|
||||
/// Default coherence reject cutoff (discard measurement below this).
|
||||
const DEFAULT_REJECT_THRESHOLD: f32 = 0.5;
|
||||
/// Default stale-frame budget before forcing recalibration (≈10 s at 20 Hz).
|
||||
const DEFAULT_MAX_STALE_FRAMES: u64 = 200;
|
||||
/// Default PredictOnly-zone measurement-noise inflation factor.
|
||||
const DEFAULT_PREDICT_ONLY_NOISE: f32 = 3.0;
|
||||
|
||||
impl Default for GatePolicyConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
accept_threshold: 0.85,
|
||||
reject_threshold: 0.5,
|
||||
max_stale_frames: 200, // 10s at 20Hz
|
||||
predict_only_noise: 3.0,
|
||||
accept_threshold: DEFAULT_ACCEPT_THRESHOLD,
|
||||
reject_threshold: DEFAULT_REJECT_THRESHOLD,
|
||||
max_stale_frames: DEFAULT_MAX_STALE_FRAMES,
|
||||
predict_only_noise: DEFAULT_PREDICT_ONLY_NOISE,
|
||||
adaptive: false,
|
||||
}
|
||||
}
|
||||
@@ -114,7 +128,7 @@ impl GatePolicy {
|
||||
accept_threshold: accept,
|
||||
reject_threshold: reject,
|
||||
max_stale_frames: max_stale,
|
||||
predict_only_noise: 3.0,
|
||||
predict_only_noise: DEFAULT_PREDICT_ONLY_NOISE,
|
||||
consecutive_low: 0,
|
||||
last_decision: None,
|
||||
}
|
||||
@@ -343,6 +357,17 @@ mod tests {
|
||||
assert!(!cfg.adaptive);
|
||||
}
|
||||
|
||||
/// ADR-154 §7.4 #9 (DATA-GATED): the named DEFAULT_* consts must equal the
|
||||
/// original bare literals — pins the de-magicked defaults so a future
|
||||
/// labelled-data retune is a visible, tested change. Values UNCHANGED.
|
||||
#[test]
|
||||
fn gate_default_consts_unchanged_from_literals() {
|
||||
assert_eq!(DEFAULT_ACCEPT_THRESHOLD, 0.85);
|
||||
assert_eq!(DEFAULT_REJECT_THRESHOLD, 0.5);
|
||||
assert_eq!(DEFAULT_MAX_STALE_FRAMES, 200);
|
||||
assert_eq!(DEFAULT_PREDICT_ONLY_NOISE, 3.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_config_construction() {
|
||||
let cfg = GatePolicyConfig {
|
||||
|
||||
@@ -105,6 +105,10 @@ impl WelfordStats {
|
||||
}
|
||||
|
||||
/// Population variance (biased). Returns 0.0 if count < 2.
|
||||
///
|
||||
/// The `count < 2` guard is the n=0 NaN guard (ADR-154 §7.4 #10): at n=0,
|
||||
/// `m2 = 0` and `count = 0` would yield `0.0/0.0 = NaN`. Pinned by
|
||||
/// `welford_finite_at_n0_and_n1`.
|
||||
pub fn variance(&self) -> f64 {
|
||||
if self.count < 2 {
|
||||
0.0
|
||||
@@ -119,6 +123,10 @@ impl WelfordStats {
|
||||
}
|
||||
|
||||
/// Sample variance (unbiased). Returns 0.0 if count < 2.
|
||||
///
|
||||
/// The `count < 2` guard is load-bearing (ADR-154 §7.4 #10): at n=0 the
|
||||
/// `(self.count - 1)` term would underflow `0usize − 1` and at n=1 it would
|
||||
/// divide by zero. Pinned by `welford_finite_at_n0_and_n1`.
|
||||
pub fn sample_variance(&self) -> f64 {
|
||||
if self.count < 2 {
|
||||
0.0
|
||||
@@ -958,6 +966,52 @@ mod tests {
|
||||
assert!((w.variance() - 0.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
/// ADR-154 §7.4 #10: every statistic must stay FINITE at the n=0 and n=1
|
||||
/// boundaries. This pins the load-bearing `count < 2` guards: without them
|
||||
/// `sample_variance` at n=0 underflows `(0usize − 1)` and divides by a huge
|
||||
/// bogus divisor, and `variance`/`z_score` produce `0.0/0.0 = NaN`. Same
|
||||
/// family as the §4 divide-by-(n−1) window trio.
|
||||
#[test]
|
||||
fn welford_finite_at_n0_and_n1() {
|
||||
// n = 0: fresh accumulator, nothing observed.
|
||||
let w0 = WelfordStats::new();
|
||||
assert_eq!(w0.count, 0);
|
||||
for v in [
|
||||
w0.mean,
|
||||
w0.variance(),
|
||||
w0.sample_variance(),
|
||||
w0.std_dev(),
|
||||
w0.z_score(123.0),
|
||||
] {
|
||||
assert!(v.is_finite(), "n=0 statistic must be finite, got {v}");
|
||||
}
|
||||
// Documented sentinels at n=0.
|
||||
assert_eq!(w0.variance(), 0.0);
|
||||
assert_eq!(w0.sample_variance(), 0.0);
|
||||
assert_eq!(w0.std_dev(), 0.0);
|
||||
assert_eq!(w0.z_score(123.0), 0.0);
|
||||
|
||||
// n = 1: a single observation has no spread.
|
||||
let mut w1 = WelfordStats::new();
|
||||
w1.update(7.5);
|
||||
assert_eq!(w1.count, 1);
|
||||
for v in [
|
||||
w1.mean,
|
||||
w1.variance(),
|
||||
w1.sample_variance(),
|
||||
w1.std_dev(),
|
||||
w1.z_score(7.5),
|
||||
w1.z_score(999.0),
|
||||
] {
|
||||
assert!(v.is_finite(), "n=1 statistic must be finite, got {v}");
|
||||
}
|
||||
assert_eq!(w1.variance(), 0.0);
|
||||
assert_eq!(w1.sample_variance(), 0.0);
|
||||
assert_eq!(w1.std_dev(), 0.0);
|
||||
// z_score guards on near-zero sd → 0.0 even for an off-mean query.
|
||||
assert_eq!(w1.z_score(999.0), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_link_baseline_stats() {
|
||||
let mut stats = LinkBaselineStats::new(4);
|
||||
|
||||
@@ -84,11 +84,32 @@ pub struct FusedSensingFrame {
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MultistaticConfig {
|
||||
/// Maximum timestamp spread (microseconds) across nodes in one cycle.
|
||||
/// Default: 5000 us (5 ms), well within the 50 ms TDMA cycle.
|
||||
///
|
||||
/// # Derivation from the TDM schedule (issue #1031)
|
||||
///
|
||||
/// In an N-slot TDMA mesh, node `k` transmits in slot `k`, so two nodes
|
||||
/// are *deliberately* separated by `(cycle_us × slot_fraction)`. On a real
|
||||
/// 2-node mesh (slots 0 and 1 of a ~36 ms cycle) we measured an
|
||||
/// **18,194 µs** spread between paired frames — i.e. the spread is the slot
|
||||
/// offset, NOT clock jitter. The previous 5,000 µs default therefore
|
||||
/// rejected every real frame set and fusion silently fell back to per-node
|
||||
/// sum/dedup, so multistatic fusion never actually ran on hardware.
|
||||
///
|
||||
/// The default is now **60,000 µs (60 ms)**: a full 50 ms TDMA cycle (the
|
||||
/// worst-case spread for the last slot of a maximally-loaded schedule) plus
|
||||
/// ~20% headroom for inter-cycle scheduling jitter. This accepts a real
|
||||
/// N-node cycle as coherent while still rejecting a spread that exceeds one
|
||||
/// whole cycle (which would mean frames from *different* sensing cycles were
|
||||
/// mixed). Tune per deployment with [`MultistaticConfig::for_tdm_schedule`].
|
||||
pub guard_interval_us: u64,
|
||||
/// ADR-137 soft guard (microseconds): a spread above this but within
|
||||
/// `guard_interval_us` is fused but recorded as a `TimestampMismatch`
|
||||
/// contradiction (loose alignment ⇒ privacy demotion). Default guard/5.
|
||||
/// contradiction (loose alignment ⇒ privacy demotion).
|
||||
///
|
||||
/// Set to **20,000 µs (20 ms)**: just above the observed 18,194 µs 2-slot
|
||||
/// spread, so a normal 2-node cycle fuses *cleanly* (no demotion), but a
|
||||
/// spread approaching a full cycle is flagged as loose alignment. Kept below
|
||||
/// `guard_interval_us` so the soft band is meaningful.
|
||||
pub soft_guard_us: u64,
|
||||
/// Minimum number of nodes for multistatic mode.
|
||||
/// Falls back to single-node mode if fewer nodes are available.
|
||||
@@ -106,8 +127,11 @@ pub struct MultistaticConfig {
|
||||
impl Default for MultistaticConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
guard_interval_us: 5000,
|
||||
soft_guard_us: 1000,
|
||||
// 60 ms hard / 20 ms soft — see field docs for the TDM derivation
|
||||
// (issue #1031). The old 5 ms hard guard rejected every real frame
|
||||
// set (observed 2-slot spread ≈ 18.2 ms), silently disabling fusion.
|
||||
guard_interval_us: 60_000,
|
||||
soft_guard_us: 20_000,
|
||||
min_nodes: 2,
|
||||
attention_temperature: 1.0,
|
||||
enable_person_separation: true,
|
||||
@@ -116,6 +140,43 @@ impl Default for MultistaticConfig {
|
||||
}
|
||||
}
|
||||
|
||||
impl MultistaticConfig {
|
||||
/// Derive a guard interval from an explicit TDM schedule (issue #1031).
|
||||
///
|
||||
/// In an N-slot schedule with per-slot duration `slot_duration_us`, the
|
||||
/// maximum legitimate spread between two paired node frames in one cycle is
|
||||
/// the full cycle length `tdm_total_slots × slot_duration_us` (last slot vs
|
||||
/// first slot). The hard guard is set to that cycle length plus 20% jitter
|
||||
/// headroom; the soft guard to ~⅓ of the cycle (a normal adjacent-slot pair
|
||||
/// fuses cleanly, a near-full-cycle spread is flagged as loose alignment).
|
||||
///
|
||||
/// `tdm_total_slots` is clamped to ≥ 1. All other fields take their
|
||||
/// [`Default`] values.
|
||||
///
|
||||
/// # Example
|
||||
/// ```
|
||||
/// use wifi_densepose_signal::ruvsense::multistatic::MultistaticConfig;
|
||||
/// // 2 slots × 18 ms = 36 ms cycle → ~43 ms hard guard accepts the
|
||||
/// // reported 18,194 µs 2-slot spread.
|
||||
/// let cfg = MultistaticConfig::for_tdm_schedule(2, 18_000);
|
||||
/// assert!(cfg.guard_interval_us >= 18_194);
|
||||
/// ```
|
||||
#[must_use]
|
||||
pub fn for_tdm_schedule(tdm_total_slots: usize, slot_duration_us: u64) -> Self {
|
||||
let slots = tdm_total_slots.max(1) as u64;
|
||||
let cycle_us = slots.saturating_mul(slot_duration_us);
|
||||
// +20% jitter headroom on the full cycle.
|
||||
let guard_interval_us = cycle_us.saturating_add(cycle_us / 5).max(1);
|
||||
// Soft band at ~⅓ cycle, kept strictly below the hard guard.
|
||||
let soft_guard_us = (cycle_us / 3).clamp(1, guard_interval_us.saturating_sub(1).max(1));
|
||||
Self {
|
||||
guard_interval_us,
|
||||
soft_guard_us,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Multistatic frame fuser.
|
||||
///
|
||||
/// Collects per-node multi-band frames and produces a single fused
|
||||
@@ -825,21 +886,87 @@ mod tests {
|
||||
#[test]
|
||||
fn ac_fuse_scored_loose_alignment_flags_soft_contradiction() {
|
||||
use super::super::fusion_quality::ContradictionFlag;
|
||||
// guard 5000 us; spread 2000 us is within guard but > soft_guard 1000 us.
|
||||
// Default soft_guard is now 20_000 us (#1031). A spread above soft but
|
||||
// within the 60_000 us hard guard is fused yet flagged as loose. Use a
|
||||
// 25_000 us spread: > soft (20 ms), < hard (60 ms).
|
||||
let fuser = MultistaticFuser::new();
|
||||
let f0 = make_node_frame(0, 1000, 56, 1.0);
|
||||
let f1 = make_node_frame(1, 3000, 56, 1.0);
|
||||
let f0 = make_node_frame(0, 1_000, 56, 1.0);
|
||||
let f1 = make_node_frame(1, 26_000, 56, 1.0);
|
||||
let (_fused, score) = fuser.fuse_scored(&[f0, f1], 0.85).unwrap();
|
||||
|
||||
assert!(score.forces_privacy_demotion(), "loose alignment ⇒ demotion");
|
||||
assert!(matches!(
|
||||
score.contradiction_flags[0],
|
||||
ContradictionFlag::TimestampMismatch { spread_ns: 2_000_000, soft_guard_ns: 1_000_000 }
|
||||
ContradictionFlag::TimestampMismatch { spread_ns: 25_000_000, soft_guard_ns: 20_000_000 }
|
||||
));
|
||||
// Penalized coherence is strictly below base when a contradiction fires.
|
||||
assert!(score.penalized_coherence() < score.base_coherence);
|
||||
}
|
||||
|
||||
/// REGRESSION (issue #1031): a real 2-node TDM frame set with an 18,194 µs
|
||||
/// spread (the reported value) must FUSE under the default config — the old
|
||||
/// 5,000 µs guard rejected it with `TimestampMismatch`, silently disabling
|
||||
/// multistatic fusion on every real deployment.
|
||||
#[test]
|
||||
fn fuse_real_tdm_spread_18194us_fuses_with_default_guard() {
|
||||
let fuser = MultistaticFuser::new(); // default config
|
||||
let f0 = make_node_frame(0, 1_000, 56, 1.0);
|
||||
let f1 = make_node_frame(1, 1_000 + 18_194, 56, 1.0);
|
||||
let fused = fuser
|
||||
.fuse(&[f0, f1])
|
||||
.expect("18,194 us 2-slot spread must fuse under the #1031 default guard");
|
||||
assert_eq!(fused.active_nodes, 2, "both nodes contribute (real fusion)");
|
||||
// The 18.2 ms spread is below the soft guard (20 ms), so fuse_scored
|
||||
// records it as a CLEAN fuse (no privacy demotion) — the common case.
|
||||
let f0b = make_node_frame(0, 1_000, 56, 1.0);
|
||||
let f1b = make_node_frame(1, 1_000 + 18_194, 56, 1.0);
|
||||
let (_f, score) = fuser.fuse_scored(&[f0b, f1b], 0.85).unwrap();
|
||||
assert!(
|
||||
!score.forces_privacy_demotion(),
|
||||
"a normal 2-slot spread (18.2 ms < 20 ms soft) must NOT demote privacy"
|
||||
);
|
||||
}
|
||||
|
||||
/// The guard still does its job: a spread larger than a whole TDM cycle
|
||||
/// (frames from different cycles) is rejected. Uses a tight per-deployment
|
||||
/// config derived from the schedule via `for_tdm_schedule`.
|
||||
#[test]
|
||||
fn configurable_guard_rejects_too_large_spread() {
|
||||
// 2 slots × 18 ms = 36 ms cycle → ~43 ms hard guard.
|
||||
let cfg = MultistaticConfig::for_tdm_schedule(2, 18_000);
|
||||
assert!(
|
||||
cfg.guard_interval_us >= 18_194,
|
||||
"derived guard must accept the reported 2-slot spread: {}",
|
||||
cfg.guard_interval_us
|
||||
);
|
||||
let fuser = MultistaticFuser::with_config(cfg.clone());
|
||||
// A spread well beyond a full cycle (e.g. 2× the hard guard) is rejected.
|
||||
let too_large = cfg.guard_interval_us * 2;
|
||||
let f0 = make_node_frame(0, 0, 56, 1.0);
|
||||
let f1 = make_node_frame(1, too_large, 56, 1.0);
|
||||
assert!(
|
||||
matches!(
|
||||
fuser.fuse(&[f0, f1]),
|
||||
Err(MultistaticError::TimestampMismatch { .. })
|
||||
),
|
||||
"a spread beyond a full TDM cycle must still be rejected"
|
||||
);
|
||||
}
|
||||
|
||||
/// The derived soft guard stays strictly below the hard guard, and a
|
||||
/// degenerate (0-slot) schedule clamps to a usable config.
|
||||
#[test]
|
||||
fn for_tdm_schedule_invariants() {
|
||||
let cfg = MultistaticConfig::for_tdm_schedule(4, 12_500); // 50 ms cycle
|
||||
assert!(cfg.soft_guard_us < cfg.guard_interval_us);
|
||||
assert!(cfg.guard_interval_us >= 50_000);
|
||||
// Degenerate input clamps instead of producing a zero/overflow guard.
|
||||
let degenerate = MultistaticConfig::for_tdm_schedule(0, 0);
|
||||
assert!(degenerate.guard_interval_us >= 1);
|
||||
assert!(degenerate.soft_guard_us >= 1);
|
||||
assert!(degenerate.soft_guard_us < degenerate.guard_interval_us.max(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ac_fuse_scored_calibrated_agreement_sets_id() {
|
||||
use super::super::fusion_quality::{CalibrationId, EvidenceRef};
|
||||
@@ -996,7 +1123,11 @@ mod tests {
|
||||
#[test]
|
||||
fn default_config() {
|
||||
let cfg = MultistaticConfig::default();
|
||||
assert_eq!(cfg.guard_interval_us, 5000);
|
||||
// #1031: hard guard raised to 60 ms (was 5 ms) to accommodate the real
|
||||
// TDM slot offset; soft guard 20 ms, both strictly ordered.
|
||||
assert_eq!(cfg.guard_interval_us, 60_000);
|
||||
assert_eq!(cfg.soft_guard_us, 20_000);
|
||||
assert!(cfg.soft_guard_us < cfg.guard_interval_us);
|
||||
assert_eq!(cfg.min_nodes, 2);
|
||||
assert!((cfg.attention_temperature - 1.0).abs() < f32::EPSILON);
|
||||
assert!(cfg.enable_person_separation);
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
//! Runnable demo of the unified [`EdgePipeline`]: constructs every registered
|
||||
//! skill, feeds a short deterministic synthetic CSI frame sequence, and prints
|
||||
//! the per-skill events plus a registration summary.
|
||||
//!
|
||||
//! ```bash
|
||||
//! cd v2/crates/wifi-densepose-wasm-edge
|
||||
//! cargo run --example run_all_skills --features std
|
||||
//! cargo run --example run_all_skills --features std,medical-experimental
|
||||
//! ```
|
||||
//!
|
||||
//! [`EdgePipeline`]: wifi_densepose_wasm_edge::pipeline_all::EdgePipeline
|
||||
|
||||
#[cfg(not(feature = "std"))]
|
||||
fn main() {
|
||||
eprintln!("run_all_skills requires --features std");
|
||||
}
|
||||
|
||||
#[cfg(feature = "std")]
|
||||
fn main() {
|
||||
use std::collections::BTreeMap;
|
||||
use wifi_densepose_wasm_edge::pipeline_all::{CsiFrameView, EdgePipeline};
|
||||
|
||||
const N_SC: usize = 32;
|
||||
let mut pipeline = EdgePipeline::new();
|
||||
|
||||
println!("=== EdgePipeline registration ===");
|
||||
println!("registered skills: {}", pipeline.skill_count());
|
||||
let med = pipeline
|
||||
.skills()
|
||||
.iter()
|
||||
.filter(|s| s.medical_experimental)
|
||||
.count();
|
||||
println!(
|
||||
" default tier: {} medical-experimental tier: {}",
|
||||
pipeline.skill_count() - med,
|
||||
med
|
||||
);
|
||||
println!();
|
||||
|
||||
let mut phases = [0.0f32; N_SC];
|
||||
let mut amps = [0.0f32; N_SC];
|
||||
let mut vars = [0.0f32; N_SC];
|
||||
let mut prev = [0.0f32; N_SC];
|
||||
|
||||
// Per-skill event counters over the run.
|
||||
let mut counts: BTreeMap<&'static str, usize> = BTreeMap::new();
|
||||
for s in pipeline.skills() {
|
||||
counts.insert(s.name, 0);
|
||||
}
|
||||
|
||||
let frames = 300usize;
|
||||
for t in 0..frames {
|
||||
let tf = t as f32;
|
||||
let breath = (tf * 2.0 * std::f32::consts::PI * 0.3 / 20.0).sin();
|
||||
let heart = (tf * 2.0 * std::f32::consts::PI * 1.2 / 20.0).sin();
|
||||
let mut vmean = 0.0f32;
|
||||
for i in 0..N_SC {
|
||||
let sc = i as f32;
|
||||
phases[i] = (sc * 0.21 + tf * 0.05).sin() + 0.15 * breath;
|
||||
amps[i] = 1.0 + 0.3 * (sc * 0.11 + tf * 0.03).cos() + 0.1 * heart;
|
||||
vars[i] = 0.02 + 0.01 * (sc * 0.3).sin().abs()
|
||||
+ if (t / 40) % 2 == 0 { 0.05 } else { 0.0 };
|
||||
vmean += vars[i];
|
||||
}
|
||||
vmean /= N_SC as f32;
|
||||
|
||||
let v = CsiFrameView {
|
||||
phases: &phases,
|
||||
amplitudes: &s,
|
||||
variances: &vars,
|
||||
prev_phases: &prev,
|
||||
presence: if (t / 30) % 3 == 0 { 0 } else { 1 },
|
||||
n_persons: ((t / 50) % 3) as i32,
|
||||
motion_energy: 0.3 + 0.2 * (tf * 0.07).sin().abs(),
|
||||
breathing_bpm: 18.0 + 2.0 * (tf * 0.01).sin(),
|
||||
heartrate_bpm: 72.0 + 5.0 * (tf * 0.02).sin(),
|
||||
coherence: 0.5 + 0.4 * (tf * 0.03).cos(),
|
||||
variance_mean: vmean,
|
||||
};
|
||||
|
||||
for e in pipeline.on_frame(&v) {
|
||||
*counts.entry(e.skill).or_insert(0) += 1;
|
||||
// Print the first few events from the last frame to show liveness.
|
||||
if t == frames - 1 {
|
||||
println!(
|
||||
" frame {} | {:<26} event {:>3} = {:.4}",
|
||||
t, e.skill, e.event_id, e.value
|
||||
);
|
||||
}
|
||||
}
|
||||
prev.copy_from_slice(&phases);
|
||||
}
|
||||
|
||||
println!();
|
||||
println!("=== per-skill event totals over {} synthetic frames ===", frames);
|
||||
let total: usize = counts.values().sum();
|
||||
let active = counts.values().filter(|&&c| c > 0).count();
|
||||
for (name, c) in &counts {
|
||||
println!(" {:<28} {}", name, c);
|
||||
}
|
||||
println!();
|
||||
println!(
|
||||
"TOTAL events: {} skills that emitted at least once: {}/{}",
|
||||
total,
|
||||
active,
|
||||
pipeline.skill_count()
|
||||
);
|
||||
}
|
||||
@@ -94,6 +94,18 @@ pub mod ind_structural_vibration;
|
||||
|
||||
pub mod vendor_common;
|
||||
|
||||
// ── Unified edge pipeline (ADR-160 deliverable) ──────────────────────────────
|
||||
//
|
||||
// `EdgePipeline` registers EVERY runtime skill module behind one uniform
|
||||
// `EdgeSkill` trait and runs them all per CSI frame. Host-only (`std`): it uses
|
||||
// Box/Vec for dynamic dispatch; the wasm `no_std` build keeps the small flagship
|
||||
// pipeline in this file. The `med_*` tier is registered only under
|
||||
// `medical-experimental` (preserves the ADR-160 safety gate).
|
||||
#[cfg(feature = "std")]
|
||||
pub mod pipeline_all;
|
||||
#[cfg(feature = "std")]
|
||||
pub mod skill_registry;
|
||||
|
||||
// ── Vendor-integrated modules (ADR-041 Category 7) ──────────────────────────
|
||||
//
|
||||
// 24 modules organised into 7 sub-categories. Each module file lives in
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
//! Unified edge pipeline — registers **every** runtime skill module in the crate
|
||||
//! behind one uniform [`EdgeSkill`] trait and runs them all per CSI frame.
|
||||
//!
|
||||
//! # Why this module exists
|
||||
//!
|
||||
//! Each skill in `src/*.rs` is an independently-loadable DSP module with its own
|
||||
//! bespoke `process_frame` / `on_timer` signature (some take `&[f32]` phases,
|
||||
//! some scalars like `motion_energy`, some `breathing_bpm`/`heartrate_bpm`, etc.).
|
||||
//! On the wasm target only the flagship `gesture + coherence + adversarial`
|
||||
//! pipeline (in `lib.rs`) is on the default `on_frame` path. This module wires
|
||||
//! **all** of them into a single [`EdgePipeline`] so a host can run the whole
|
||||
//! skill library over one CSI frame stream and collect every emitted event,
|
||||
//! tagged by its source skill.
|
||||
//!
|
||||
//! # Design
|
||||
//!
|
||||
//! - [`CsiFrameView`] — a borrowed, host-supplied view of one CSI frame carrying
|
||||
//! every input any skill needs (phase/amplitude/variance slices + the scalar
|
||||
//! features the host derives: presence, n_persons, motion_energy, breathing &
|
||||
//! heart rate, coherence, plus the previous frame's phases for delta skills).
|
||||
//! - [`EdgeSkill`] — the uniform adapter trait. Each skill gets a small adapter
|
||||
//! (see `skill_registry`) that pulls the fields it needs out of the view, calls
|
||||
//! the underlying detector **unchanged**, and returns an aggregated
|
||||
//! `&[(i32, f32)]` event buffer. **No skill DSP is modified.**
|
||||
//! - [`EdgePipeline`] — owns one boxed adapter per skill, dispatches `on_frame`
|
||||
//! to all of them, and aggregates `(skill_name, event_id, value)` triples.
|
||||
//!
|
||||
//! # Feature gating (preserves the ADR-160 safety gate)
|
||||
//!
|
||||
//! The five `med_*` skills are registered **only** under
|
||||
//! `--features medical-experimental`. They are NOT pulled into the default
|
||||
//! pipeline, so they cannot be silently built into a shipping artifact. The
|
||||
//! medical tier is opt-in; see `EdgePipeline::new` and `skills()`.
|
||||
//!
|
||||
//! Requires `std` (uses `Box`/`Vec`); the wasm `no_std` build keeps the small
|
||||
//! flagship `lib.rs` pipeline instead.
|
||||
|
||||
#![cfg(feature = "std")]
|
||||
|
||||
extern crate std;
|
||||
use std::boxed::Box;
|
||||
use std::vec::Vec;
|
||||
|
||||
/// Borrowed view of one CSI frame: every input any registered skill can consume.
|
||||
///
|
||||
/// The host derives these from the Tier-2 DSP output. Slices are
|
||||
/// per-subcarrier; scalars are frame-level aggregates. A skill adapter reads
|
||||
/// only the fields it needs and ignores the rest — heterogeneity is absorbed
|
||||
/// here, not in the skills.
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct CsiFrameView<'a> {
|
||||
/// Per-subcarrier unwrapped phase (radians).
|
||||
pub phases: &'a [f32],
|
||||
/// Per-subcarrier amplitude (linear).
|
||||
pub amplitudes: &'a [f32],
|
||||
/// Per-subcarrier short-window variance.
|
||||
pub variances: &'a [f32],
|
||||
/// Previous frame's phases (for delta/velocity skills like the spiking tracker).
|
||||
pub prev_phases: &'a [f32],
|
||||
/// Presence flag from host (0 = empty, 1 = occupied).
|
||||
pub presence: i32,
|
||||
/// Estimated person count from host.
|
||||
pub n_persons: i32,
|
||||
/// Frame-level motion energy.
|
||||
pub motion_energy: f32,
|
||||
/// Breathing rate estimate (breaths/min); 0 if unavailable.
|
||||
pub breathing_bpm: f32,
|
||||
/// Heart rate estimate (beats/min); 0 if unavailable.
|
||||
pub heartrate_bpm: f32,
|
||||
/// Coherence score [0,1] from the coherence monitor (for gate-style skills).
|
||||
pub coherence: f32,
|
||||
/// Mean variance across `variances` (convenience scalar for skills wanting one).
|
||||
pub variance_mean: f32,
|
||||
}
|
||||
|
||||
impl<'a> CsiFrameView<'a> {
|
||||
/// Mean amplitude across the frame (convenience for scalar-input skills).
|
||||
#[inline]
|
||||
pub fn amplitude_mean(&self) -> f32 {
|
||||
if self.amplitudes.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
let mut s = 0.0f32;
|
||||
for &a in self.amplitudes {
|
||||
s += a;
|
||||
}
|
||||
s / self.amplitudes.len() as f32
|
||||
}
|
||||
|
||||
/// Mean phase across the frame.
|
||||
#[inline]
|
||||
pub fn phase_mean(&self) -> f32 {
|
||||
if self.phases.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
let mut s = 0.0f32;
|
||||
for &p in self.phases {
|
||||
s += p;
|
||||
}
|
||||
s / self.phases.len() as f32
|
||||
}
|
||||
}
|
||||
|
||||
/// One emitted event, tagged by its source skill.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct SkillEvent {
|
||||
/// Stable name of the skill that produced this event (e.g. `"occupancy"`).
|
||||
pub skill: &'static str,
|
||||
/// Event type id (the registry id from `event_types`).
|
||||
pub event_id: i32,
|
||||
/// Event payload value.
|
||||
pub value: f32,
|
||||
}
|
||||
|
||||
/// Uniform adapter trait over a heterogeneous skill detector.
|
||||
///
|
||||
/// Implementors live in `skill_registry`; each wraps exactly one underlying
|
||||
/// detector and forwards `on_frame` to its real `process_frame`/`on_timer`
|
||||
/// without changing the DSP. `event_ids()` is introspection only.
|
||||
pub trait EdgeSkill {
|
||||
/// Stable skill name (matches the `src/<name>.rs` module).
|
||||
fn name(&self) -> &'static str;
|
||||
/// The event ids this skill can emit (for introspection / docs).
|
||||
fn event_ids(&self) -> &'static [i32];
|
||||
/// Run this skill over one frame, returning its emitted `(event_id, value)`
|
||||
/// pairs. Returns an empty slice if the skill emitted nothing this frame.
|
||||
fn on_frame(&mut self, frame: &CsiFrameView) -> &[(i32, f32)];
|
||||
}
|
||||
|
||||
/// Introspection record for one registered skill.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct SkillInfo {
|
||||
/// Skill name.
|
||||
pub name: &'static str,
|
||||
/// Event ids the skill can emit.
|
||||
pub event_ids: &'static [i32],
|
||||
/// Whether the skill is part of the gated `medical-experimental` tier.
|
||||
pub medical_experimental: bool,
|
||||
}
|
||||
|
||||
/// The unified pipeline: holds one adapter per registered skill and runs them
|
||||
/// all per frame.
|
||||
pub struct EdgePipeline {
|
||||
skills: Vec<Box<dyn EdgeSkill>>,
|
||||
/// Parallel flag marking which entries are the gated medical tier.
|
||||
medical_flags: Vec<bool>,
|
||||
frame_count: u64,
|
||||
}
|
||||
|
||||
impl EdgePipeline {
|
||||
/// Construct the pipeline with **every** registered skill.
|
||||
///
|
||||
/// The five `med_*` skills are included **only** when the crate is built
|
||||
/// with `--features medical-experimental`; otherwise the default
|
||||
/// (non-medical) tier is registered. This preserves the ADR-160 safety gate.
|
||||
pub fn new() -> Self {
|
||||
let mut skills: Vec<Box<dyn EdgeSkill>> = Vec::new();
|
||||
let mut medical_flags: Vec<bool> = Vec::new();
|
||||
|
||||
crate::skill_registry::register_default(&mut skills, &mut medical_flags);
|
||||
#[cfg(feature = "medical-experimental")]
|
||||
crate::skill_registry::register_medical(&mut skills, &mut medical_flags);
|
||||
|
||||
Self {
|
||||
skills,
|
||||
medical_flags,
|
||||
frame_count: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of registered skills (default tier, or +medical if that feature is on).
|
||||
pub fn skill_count(&self) -> usize {
|
||||
self.skills.len()
|
||||
}
|
||||
|
||||
/// Run every registered skill over one frame, aggregating all emitted events
|
||||
/// tagged by source skill. Order matches registration order.
|
||||
pub fn on_frame(&mut self, frame: &CsiFrameView) -> Vec<SkillEvent> {
|
||||
self.frame_count += 1;
|
||||
let mut out: Vec<SkillEvent> = Vec::new();
|
||||
for skill in self.skills.iter_mut() {
|
||||
let name = skill.name();
|
||||
for &(event_id, value) in skill.on_frame(frame) {
|
||||
out.push(SkillEvent {
|
||||
skill: name,
|
||||
event_id,
|
||||
value,
|
||||
});
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Total frames processed so far.
|
||||
pub fn frame_count(&self) -> u64 {
|
||||
self.frame_count
|
||||
}
|
||||
|
||||
/// Introspection: list every registered skill with its event ids and tier.
|
||||
pub fn skills(&self) -> Vec<SkillInfo> {
|
||||
let mut out = Vec::with_capacity(self.skills.len());
|
||||
for (i, skill) in self.skills.iter().enumerate() {
|
||||
out.push(SkillInfo {
|
||||
name: skill.name(),
|
||||
event_ids: skill.event_ids(),
|
||||
medical_experimental: self.medical_flags.get(i).copied().unwrap_or(false),
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for EdgePipeline {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,630 @@
|
||||
//! Adapters wiring every runtime skill detector to the uniform [`EdgeSkill`]
|
||||
//! trait, plus the registration functions consumed by [`EdgePipeline::new`].
|
||||
//!
|
||||
//! [`EdgePipeline::new`]: crate::pipeline_all::EdgePipeline::new
|
||||
//! [`EdgeSkill`]: crate::pipeline_all::EdgeSkill
|
||||
//!
|
||||
//! # How adapters work
|
||||
//!
|
||||
//! Each underlying detector keeps its own bespoke `process_frame`/`on_timer`
|
||||
//! signature and its owned `events: [(i32,f32); N]` buffer (the ADR-160 M6
|
||||
//! soundness fix). An adapter holds the detector, implements [`EdgeSkill`], and
|
||||
//! in `on_frame` simply pulls the needed fields out of [`CsiFrameView`] and
|
||||
//! forwards the call **unchanged**. The detector returns `&self.events[..n]`;
|
||||
//! the adapter forwards that borrow directly, so no extra buffer or copy is
|
||||
//! needed for the common case.
|
||||
//!
|
||||
//! Three families need a small owned scratch buffer in the adapter instead of a
|
||||
//! direct forward, because the underlying entry point does not itself return a
|
||||
//! `&[(i32,f32)]`:
|
||||
//! - `gesture` (`-> Option<u8>`), `coherence` (`-> f32`), `adversarial`
|
||||
//! (`-> bool`): the adapter synthesizes a single tagged event.
|
||||
//! - `sig_sparse_recovery` (`process_frame(&mut [f32])`): the adapter copies the
|
||||
//! frame amplitudes into an owned scratch slice so the in-place ISTA recovery
|
||||
//! never mutates the shared frame, then forwards the borrow.
|
||||
//! - timer-driven skills (`vital_trend`, `lrn_meta_adapt`, `sig_temporal_compress`,
|
||||
//! `tmp_goap_autonomy`, `tmp_pattern_sequence`): their `on_timer()` is driven
|
||||
//! once per frame here (a frame *is* the tick at the edge), forwarding the
|
||||
//! borrow. `tmp_pattern_sequence` additionally calls its `on_frame(...)`
|
||||
//! accumulator first.
|
||||
//!
|
||||
//! **No skill's DSP is changed.** Only the call wiring lives here.
|
||||
|
||||
#![cfg(feature = "std")]
|
||||
|
||||
extern crate std;
|
||||
use std::boxed::Box;
|
||||
use std::vec::Vec;
|
||||
|
||||
use crate::pipeline_all::{CsiFrameView, EdgeSkill};
|
||||
|
||||
// ── Direct-forward adapter macro ─────────────────────────────────────────────
|
||||
//
|
||||
// Generates an adapter whose `on_frame` forwards directly to a detector method
|
||||
// that already returns `&[(i32, f32)]`. `$call` is an expression over `self.0`
|
||||
// (the detector) and `f` (the `&CsiFrameView`).
|
||||
macro_rules! fwd_skill {
|
||||
($adapter:ident, $detector:path, $name:literal, $ids:expr, |$d:ident, $f:ident| $call:expr) => {
|
||||
pub struct $adapter($detector);
|
||||
impl $adapter {
|
||||
pub fn new() -> Self {
|
||||
Self(<$detector>::new())
|
||||
}
|
||||
}
|
||||
impl EdgeSkill for $adapter {
|
||||
fn name(&self) -> &'static str {
|
||||
$name
|
||||
}
|
||||
fn event_ids(&self) -> &'static [i32] {
|
||||
&$ids
|
||||
}
|
||||
fn on_frame(&mut self, $f: &CsiFrameView) -> &[(i32, f32)] {
|
||||
let $d = &mut self.0;
|
||||
$call
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ── Synthesized-event adapter macro ──────────────────────────────────────────
|
||||
//
|
||||
// For detectors whose entry point does NOT return `&[(i32, f32)]`. The adapter
|
||||
// owns a tiny scratch buffer; `$body` (over `self`, `f`, and `self.buf`/`self.n`)
|
||||
// fills it and the trait returns the filled prefix.
|
||||
macro_rules! synth_skill {
|
||||
($adapter:ident, $detector:path, $name:literal, $ids:expr, $buf:literal,
|
||||
|$s:ident, $f:ident| $body:block) => {
|
||||
pub struct $adapter {
|
||||
det: $detector,
|
||||
buf: [(i32, f32); $buf],
|
||||
n: usize,
|
||||
}
|
||||
impl $adapter {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
det: <$detector>::new(),
|
||||
buf: [(0, 0.0); $buf],
|
||||
n: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl EdgeSkill for $adapter {
|
||||
fn name(&self) -> &'static str {
|
||||
$name
|
||||
}
|
||||
fn event_ids(&self) -> &'static [i32] {
|
||||
&$ids
|
||||
}
|
||||
fn on_frame(&mut self, $f: &CsiFrameView) -> &[(i32, f32)] {
|
||||
let $s = self;
|
||||
$s.n = 0;
|
||||
$body
|
||||
&$s.buf[..$s.n]
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
use crate::event_types as ev;
|
||||
|
||||
// ── Flagship (synthesized) ───────────────────────────────────────────────────
|
||||
|
||||
synth_skill!(GestureAdapter, crate::gesture::GestureDetector, "gesture",
|
||||
[ev::GESTURE_DETECTED], 1, |s, f| {
|
||||
if let Some(id) = s.det.process_frame(f.phases) {
|
||||
s.buf[0] = (ev::GESTURE_DETECTED, id as f32);
|
||||
s.n = 1;
|
||||
}
|
||||
});
|
||||
|
||||
synth_skill!(CoherenceAdapter, crate::coherence::CoherenceMonitor, "coherence",
|
||||
[ev::COHERENCE_SCORE], 1, |s, f| {
|
||||
let score = s.det.process_frame(f.phases);
|
||||
s.buf[0] = (ev::COHERENCE_SCORE, score);
|
||||
s.n = 1;
|
||||
});
|
||||
|
||||
synth_skill!(AdversarialAdapter, crate::adversarial::AnomalyDetector, "adversarial",
|
||||
[ev::ANOMALY_DETECTED], 1, |s, f| {
|
||||
if s.det.process_frame(f.phases, f.amplitudes) {
|
||||
s.buf[0] = (ev::ANOMALY_DETECTED, 1.0);
|
||||
s.n = 1;
|
||||
}
|
||||
});
|
||||
|
||||
// ── sig_sparse_recovery (needs owned mutable amplitude scratch) ───────────────
|
||||
|
||||
const SPARSE_SC: usize = 64;
|
||||
pub struct SparseRecoveryAdapter {
|
||||
det: crate::sig_sparse_recovery::SparseRecovery,
|
||||
scratch: [f32; SPARSE_SC],
|
||||
}
|
||||
impl SparseRecoveryAdapter {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
det: crate::sig_sparse_recovery::SparseRecovery::new(),
|
||||
scratch: [0.0; SPARSE_SC],
|
||||
}
|
||||
}
|
||||
}
|
||||
impl EdgeSkill for SparseRecoveryAdapter {
|
||||
fn name(&self) -> &'static str {
|
||||
"sig_sparse_recovery"
|
||||
}
|
||||
fn event_ids(&self) -> &'static [i32] {
|
||||
&[ev::RECOVERY_COMPLETE, ev::RECOVERY_ERROR, ev::DROPOUT_RATE]
|
||||
}
|
||||
fn on_frame(&mut self, f: &CsiFrameView) -> &[(i32, f32)] {
|
||||
let n = f.amplitudes.len().min(SPARSE_SC);
|
||||
self.scratch[..n].copy_from_slice(&f.amplitudes[..n]);
|
||||
self.det.process_frame(&mut self.scratch[..n])
|
||||
}
|
||||
}
|
||||
|
||||
// ── Standard direct-forward skills (return &[(i32,f32)]) ─────────────────────
|
||||
|
||||
fwd_skill!(AisBehavioralAdapter, crate::ais_behavioral_profiler::BehavioralProfiler,
|
||||
"ais_behavioral_profiler",
|
||||
[ev::BEHAVIOR_ANOMALY, ev::PROFILE_DEVIATION, ev::NOVEL_PATTERN, ev::PROFILE_MATURITY],
|
||||
|d, f| d.process_frame(f.presence != 0, f.motion_energy, f.n_persons.max(0) as u8));
|
||||
|
||||
fwd_skill!(AisPromptShieldAdapter, crate::ais_prompt_shield::PromptShield,
|
||||
"ais_prompt_shield",
|
||||
[ev::REPLAY_ATTACK, ev::INJECTION_DETECTED, ev::JAMMING_DETECTED, ev::SIGNAL_INTEGRITY],
|
||||
|d, f| d.process_frame(f.phases, f.amplitudes));
|
||||
|
||||
fwd_skill!(AutPsychoAdapter, crate::aut_psycho_symbolic::PsychoSymbolicEngine,
|
||||
"aut_psycho_symbolic",
|
||||
[ev::INFERENCE_RESULT, ev::INFERENCE_CONFIDENCE, ev::RULE_FIRED, ev::CONTRADICTION],
|
||||
|d, f| d.process_frame(f.presence as f32, f.motion_energy, f.breathing_bpm,
|
||||
f.heartrate_bpm, f.n_persons as f32, 0.0));
|
||||
|
||||
fwd_skill!(AutMeshAdapter, crate::aut_self_healing_mesh::SelfHealingMesh,
|
||||
"aut_self_healing_mesh",
|
||||
[ev::NODE_DEGRADED, ev::MESH_RECONFIGURE, ev::COVERAGE_SCORE, ev::HEALING_COMPLETE],
|
||||
|d, f| d.process_frame(f.variances));
|
||||
|
||||
fwd_skill!(BldElevatorAdapter, crate::bld_elevator_count::ElevatorCounter,
|
||||
"bld_elevator_count",
|
||||
[ev::ELEVATOR_COUNT, ev::DOOR_OPEN, ev::DOOR_CLOSE, ev::OVERLOAD_WARNING],
|
||||
|d, f| d.process_frame(f.amplitudes, f.phases, f.motion_energy, f.n_persons));
|
||||
|
||||
fwd_skill!(BldEnergyAdapter, crate::bld_energy_audit::EnergyAuditor,
|
||||
"bld_energy_audit",
|
||||
[ev::SCHEDULE_SUMMARY, ev::AFTER_HOURS_ALERT, ev::UTILIZATION_RATE],
|
||||
|d, f| d.process_frame(f.presence, f.n_persons));
|
||||
|
||||
fwd_skill!(BldHvacAdapter, crate::bld_hvac_presence::HvacPresenceDetector,
|
||||
"bld_hvac_presence",
|
||||
[ev::HVAC_OCCUPIED, ev::ACTIVITY_LEVEL, ev::DEPARTURE_COUNTDOWN],
|
||||
|d, f| d.process_frame(f.presence as f32, f.motion_energy));
|
||||
|
||||
fwd_skill!(BldLightingAdapter, crate::bld_lighting_zones::LightingZoneController,
|
||||
"bld_lighting_zones",
|
||||
[ev::LIGHT_ON, ev::LIGHT_DIM, ev::LIGHT_OFF],
|
||||
|d, f| d.process_frame(f.amplitudes, f.motion_energy));
|
||||
|
||||
fwd_skill!(BldMeetingAdapter, crate::bld_meeting_room::MeetingRoomTracker,
|
||||
"bld_meeting_room",
|
||||
[ev::MEETING_START, ev::MEETING_END, ev::PEAK_HEADCOUNT, ev::ROOM_AVAILABLE],
|
||||
|d, f| d.process_frame(f.presence, f.n_persons, f.motion_energy));
|
||||
|
||||
fwd_skill!(ExoBreathingSyncAdapter, crate::exo_breathing_sync::BreathingSyncDetector,
|
||||
"exo_breathing_sync",
|
||||
[ev::SYNC_DETECTED, ev::SYNC_PAIR_COUNT, ev::GROUP_COHERENCE, ev::SYNC_LOST],
|
||||
|d, f| d.process_frame(f.phases, f.variances, f.breathing_bpm, f.n_persons));
|
||||
|
||||
fwd_skill!(ExoEmotionAdapter, crate::exo_emotion_detect::EmotionDetector,
|
||||
"exo_emotion_detect",
|
||||
[ev::AROUSAL_LEVEL, ev::STRESS_INDEX, ev::CALM_DETECTED, ev::AGITATION_DETECTED],
|
||||
|d, f| d.process_frame(f.breathing_bpm, f.heartrate_bpm, f.motion_energy,
|
||||
f.phase_mean(), f.variance_mean));
|
||||
|
||||
fwd_skill!(ExoDreamAdapter, crate::exo_dream_stage::DreamStageDetector,
|
||||
"exo_dream_stage",
|
||||
[ev::SLEEP_STAGE, ev::SLEEP_QUALITY, ev::REM_EPISODE, ev::DEEP_SLEEP_RATIO],
|
||||
|d, f| d.process_frame(f.breathing_bpm, f.heartrate_bpm, f.motion_energy,
|
||||
f.phase_mean(), f.variance_mean, f.presence));
|
||||
|
||||
fwd_skill!(ExoGestureLangAdapter, crate::exo_gesture_language::GestureLanguageDetector,
|
||||
"exo_gesture_language",
|
||||
[ev::LETTER_RECOGNIZED, ev::LETTER_CONFIDENCE, ev::WORD_BOUNDARY, ev::GESTURE_REJECTED],
|
||||
|d, f| d.process_frame(f.phases, f.amplitudes, f.variance_mean, f.motion_energy, f.presence));
|
||||
|
||||
fwd_skill!(ExoGhostAdapter, crate::exo_ghost_hunter::GhostHunterDetector,
|
||||
"exo_ghost_hunter",
|
||||
[ev::EXO_ANOMALY_DETECTED, ev::EXO_ANOMALY_CLASS, ev::HIDDEN_PRESENCE, ev::ENVIRONMENTAL_DRIFT],
|
||||
|d, f| d.process_frame(f.phases, f.amplitudes, f.variances, f.presence, f.motion_energy));
|
||||
|
||||
fwd_skill!(ExoHappinessAdapter, crate::exo_happiness_score::HappinessScoreDetector,
|
||||
"exo_happiness_score",
|
||||
[ev::HAPPINESS_SCORE, ev::GAIT_ENERGY, ev::AFFECT_VALENCE, ev::SOCIAL_ENERGY, ev::TRANSIT_DIRECTION],
|
||||
|d, f| d.process_frame(f.phases, f.amplitudes, f.variances, f.presence,
|
||||
f.motion_energy, f.breathing_bpm, f.heartrate_bpm));
|
||||
|
||||
fwd_skill!(ExoHyperbolicAdapter, crate::exo_hyperbolic_space::HyperbolicEmbedder,
|
||||
"exo_hyperbolic_space",
|
||||
[ev::HIERARCHY_LEVEL, ev::HYPERBOLIC_RADIUS, ev::LOCATION_LABEL],
|
||||
|d, f| d.process_frame(f.amplitudes));
|
||||
|
||||
fwd_skill!(ExoMusicAdapter, crate::exo_music_conductor::MusicConductorDetector,
|
||||
"exo_music_conductor",
|
||||
[ev::CONDUCTOR_BPM, ev::BEAT_POSITION, ev::DYNAMIC_LEVEL, ev::GESTURE_CUTOFF, ev::GESTURE_FERMATA],
|
||||
|d, f| d.process_frame(f.phase_mean(), f.amplitude_mean(), f.motion_energy, f.variance_mean));
|
||||
|
||||
fwd_skill!(ExoPlantAdapter, crate::exo_plant_growth::PlantGrowthDetector,
|
||||
"exo_plant_growth",
|
||||
[ev::GROWTH_RATE, ev::CIRCADIAN_PHASE, ev::WILT_DETECTED, ev::WATERING_EVENT],
|
||||
|d, f| d.process_frame(f.amplitudes, f.phases, f.variances, f.presence));
|
||||
|
||||
fwd_skill!(ExoRainAdapter, crate::exo_rain_detect::RainDetector,
|
||||
"exo_rain_detect",
|
||||
[ev::RAIN_ONSET, ev::RAIN_INTENSITY, ev::RAIN_CESSATION],
|
||||
|d, f| d.process_frame(f.phases, f.variances, f.amplitudes, f.presence));
|
||||
|
||||
fwd_skill!(ExoTimeCrystalAdapter, crate::exo_time_crystal::TimeCrystalDetector,
|
||||
"exo_time_crystal",
|
||||
[ev::CRYSTAL_DETECTED, ev::CRYSTAL_STABILITY, ev::COORDINATION_INDEX],
|
||||
|d, f| d.process_frame(f.motion_energy));
|
||||
|
||||
fwd_skill!(IndCleanRoomAdapter, crate::ind_clean_room::CleanRoomMonitor,
|
||||
"ind_clean_room",
|
||||
[ev::OCCUPANCY_COUNT, ev::OCCUPANCY_VIOLATION, ev::TURBULENT_MOTION, ev::COMPLIANCE_REPORT],
|
||||
|d, f| d.process_frame(f.n_persons, f.presence, f.motion_energy));
|
||||
|
||||
fwd_skill!(IndConfinedAdapter, crate::ind_confined_space::ConfinedSpaceMonitor,
|
||||
"ind_confined_space",
|
||||
[ev::WORKER_ENTRY, ev::WORKER_EXIT, ev::BREATHING_OK, ev::EXTRACTION_ALERT, ev::IMMOBILE_ALERT],
|
||||
|d, f| d.process_frame(f.presence, f.breathing_bpm, f.motion_energy, f.variance_mean));
|
||||
|
||||
fwd_skill!(IndForkliftAdapter, crate::ind_forklift_proximity::ForkliftProximityDetector,
|
||||
"ind_forklift_proximity",
|
||||
[ev::PROXIMITY_WARNING, ev::VEHICLE_DETECTED, ev::HUMAN_NEAR_VEHICLE],
|
||||
|d, f| d.process_frame(f.phases, f.amplitudes, f.variances, f.motion_energy, f.presence, f.n_persons));
|
||||
|
||||
fwd_skill!(IndLivestockAdapter, crate::ind_livestock_monitor::LivestockMonitor,
|
||||
"ind_livestock_monitor",
|
||||
[ev::ANIMAL_PRESENT, ev::ABNORMAL_STILLNESS, ev::LABORED_BREATHING, ev::ESCAPE_ALERT],
|
||||
|d, f| d.process_frame(f.presence, f.breathing_bpm, f.motion_energy, f.variance_mean));
|
||||
|
||||
fwd_skill!(IndVibrationAdapter, crate::ind_structural_vibration::StructuralVibrationMonitor,
|
||||
"ind_structural_vibration",
|
||||
[ev::SEISMIC_DETECTED, ev::MECHANICAL_RESONANCE, ev::STRUCTURAL_DRIFT, ev::VIBRATION_SPECTRUM],
|
||||
|d, f| d.process_frame(f.phases, f.amplitudes, f.variances, f.presence));
|
||||
|
||||
fwd_skill!(IntrusionAdapter, crate::intrusion::IntrusionDetector,
|
||||
"intrusion",
|
||||
[ev::INTRUSION_ALERT, ev::INTRUSION_ZONE, 202],
|
||||
|d, f| d.process_frame(f.phases, f.amplitudes));
|
||||
|
||||
fwd_skill!(LrnAttractorAdapter, crate::lrn_anomaly_attractor::AttractorDetector,
|
||||
"lrn_anomaly_attractor",
|
||||
[ev::ATTRACTOR_TYPE, ev::LYAPUNOV_EXPONENT, ev::BASIN_DEPARTURE, ev::LEARNING_COMPLETE],
|
||||
|d, f| d.process_frame(f.phases, f.amplitudes, f.motion_energy));
|
||||
|
||||
fwd_skill!(LrnDtwAdapter, crate::lrn_dtw_gesture_learn::GestureLearner,
|
||||
"lrn_dtw_gesture_learn",
|
||||
[ev::GESTURE_LEARNED, ev::GESTURE_MATCHED, ev::LRN_MATCH_DISTANCE, ev::TEMPLATE_COUNT],
|
||||
|d, f| d.process_frame(f.phases, f.motion_energy));
|
||||
|
||||
fwd_skill!(LrnEwcAdapter, crate::lrn_ewc_lifelong::EwcLifelong,
|
||||
"lrn_ewc_lifelong",
|
||||
[ev::KNOWLEDGE_RETAINED, ev::NEW_TASK_LEARNED, ev::FISHER_UPDATE, ev::FORGETTING_RISK],
|
||||
|d, f| d.process_frame(f.variances, f.presence));
|
||||
|
||||
fwd_skill!(OccupancyAdapter, crate::occupancy::OccupancyDetector,
|
||||
"occupancy",
|
||||
[ev::ZONE_OCCUPIED, ev::ZONE_COUNT, ev::ZONE_TRANSITION],
|
||||
|d, f| d.process_frame(f.phases, f.amplitudes));
|
||||
|
||||
fwd_skill!(QntInterferenceAdapter, crate::qnt_interference_search::InterferenceSearch,
|
||||
"qnt_interference_search",
|
||||
[ev::HYPOTHESIS_WINNER, ev::HYPOTHESIS_AMPLITUDE, ev::SEARCH_ITERATIONS],
|
||||
|d, f| d.process_frame(f.presence, f.motion_energy, f.n_persons));
|
||||
|
||||
fwd_skill!(QntCoherenceAdapter, crate::qnt_quantum_coherence::QuantumCoherenceMonitor,
|
||||
"qnt_quantum_coherence",
|
||||
[ev::ENTANGLEMENT_ENTROPY, ev::DECOHERENCE_EVENT, ev::BLOCH_DRIFT],
|
||||
|d, f| d.process_frame(f.phases));
|
||||
|
||||
fwd_skill!(RetFlowAdapter, crate::ret_customer_flow::CustomerFlowTracker,
|
||||
"ret_customer_flow",
|
||||
[ev::INGRESS, ev::EGRESS, ev::NET_OCCUPANCY, ev::HOURLY_TRAFFIC],
|
||||
|d, f| d.process_frame(f.phases, f.amplitudes, f.variance_mean, f.motion_energy));
|
||||
|
||||
fwd_skill!(RetDwellAdapter, crate::ret_dwell_heatmap::DwellHeatmapTracker,
|
||||
"ret_dwell_heatmap",
|
||||
[ev::DWELL_ZONE_UPDATE, ev::HOT_ZONE, ev::COLD_ZONE, ev::SESSION_SUMMARY],
|
||||
|d, f| d.process_frame(f.presence, f.variances, f.motion_energy, f.n_persons));
|
||||
|
||||
fwd_skill!(RetQueueAdapter, crate::ret_queue_length::QueueLengthEstimator,
|
||||
"ret_queue_length",
|
||||
[ev::QUEUE_LENGTH, ev::WAIT_TIME_ESTIMATE, ev::SERVICE_RATE, ev::QUEUE_ALERT],
|
||||
|d, f| d.process_frame(f.presence, f.n_persons, f.variance_mean, f.motion_energy));
|
||||
|
||||
fwd_skill!(RetShelfAdapter, crate::ret_shelf_engagement::ShelfEngagementDetector,
|
||||
"ret_shelf_engagement",
|
||||
[ev::SHELF_BROWSE, ev::SHELF_CONSIDER, ev::SHELF_ENGAGE, ev::REACH_DETECTED],
|
||||
|d, f| d.process_frame(f.presence, f.motion_energy, f.variance_mean, f.phases));
|
||||
|
||||
fwd_skill!(RetTableAdapter, crate::ret_table_turnover::TableTurnoverTracker,
|
||||
"ret_table_turnover",
|
||||
[ev::TABLE_SEATED, ev::TABLE_VACATED, ev::TABLE_AVAILABLE, ev::TURNOVER_RATE],
|
||||
|d, f| d.process_frame(f.presence, f.motion_energy, f.n_persons));
|
||||
|
||||
fwd_skill!(SecLoiteringAdapter, crate::sec_loitering::LoiteringDetector,
|
||||
"sec_loitering",
|
||||
[ev::LOITERING_START, ev::LOITERING_ONGOING, ev::LOITERING_END],
|
||||
|d, f| d.process_frame(f.presence, f.motion_energy));
|
||||
|
||||
fwd_skill!(SecPanicAdapter, crate::sec_panic_motion::PanicMotionDetector,
|
||||
"sec_panic_motion",
|
||||
[ev::PANIC_DETECTED, ev::STRUGGLE_PATTERN, ev::FLEEING_DETECTED],
|
||||
|d, f| d.process_frame(f.motion_energy, f.variance_mean, f.phase_mean(), f.presence));
|
||||
|
||||
fwd_skill!(SecPerimeterAdapter, crate::sec_perimeter_breach::PerimeterBreachDetector,
|
||||
"sec_perimeter_breach",
|
||||
[ev::PERIMETER_BREACH, ev::APPROACH_DETECTED, ev::DEPARTURE_DETECTED, ev::SEC_ZONE_TRANSITION],
|
||||
|d, f| d.process_frame(f.phases, f.amplitudes, f.variances, f.motion_energy));
|
||||
|
||||
fwd_skill!(SecTailgateAdapter, crate::sec_tailgating::TailgateDetector,
|
||||
"sec_tailgating",
|
||||
[ev::TAILGATE_DETECTED, ev::SINGLE_PASSAGE, ev::MULTI_PASSAGE],
|
||||
|d, f| d.process_frame(f.motion_energy, f.presence, f.n_persons, f.variance_mean));
|
||||
|
||||
fwd_skill!(SecWeaponAdapter, crate::sec_weapon_detect::WeaponDetector,
|
||||
"sec_weapon_detect",
|
||||
[ev::METAL_ANOMALY, ev::HIGH_METAL_REFLECTIVITY, ev::CALIBRATION_NEEDED],
|
||||
|d, f| d.process_frame(f.phases, f.amplitudes, f.variances, f.motion_energy, f.presence));
|
||||
|
||||
fwd_skill!(SigCoherenceGateAdapter, crate::sig_coherence_gate::CoherenceGate,
|
||||
"sig_coherence_gate",
|
||||
[ev::GATE_DECISION, ev::SIG_COHERENCE_SCORE, ev::RECALIBRATE_NEEDED],
|
||||
|d, f| d.process_frame(f.phases));
|
||||
|
||||
fwd_skill!(SigFlashAttnAdapter, crate::sig_flash_attention::FlashAttention,
|
||||
"sig_flash_attention",
|
||||
[ev::ATTENTION_PEAK_SC, ev::ATTENTION_SPREAD, ev::SPATIAL_FOCUS_ZONE],
|
||||
|d, f| d.process_frame(f.phases, f.amplitudes));
|
||||
|
||||
fwd_skill!(SigMincutAdapter, crate::sig_mincut_person_match::PersonMatcher,
|
||||
"sig_mincut_person_match",
|
||||
[ev::PERSON_ID_ASSIGNED, ev::PERSON_ID_SWAP, ev::MATCH_CONFIDENCE],
|
||||
|d, f| d.process_frame(f.amplitudes, f.variances, f.n_persons.max(0) as usize));
|
||||
|
||||
fwd_skill!(SigTransportAdapter, crate::sig_optimal_transport::OptimalTransportDetector,
|
||||
"sig_optimal_transport",
|
||||
[ev::WASSERSTEIN_DISTANCE, ev::DISTRIBUTION_SHIFT, ev::SUBTLE_MOTION],
|
||||
|d, f| d.process_frame(f.amplitudes));
|
||||
|
||||
fwd_skill!(SptHnswAdapter, crate::spt_micro_hnsw::MicroHnsw,
|
||||
"spt_micro_hnsw",
|
||||
[ev::NEAREST_MATCH_ID, ev::HNSW_MATCH_DISTANCE, ev::CLASSIFICATION, ev::LIBRARY_SIZE],
|
||||
|d, f| d.process_frame(f.variances));
|
||||
|
||||
fwd_skill!(SptPagerankAdapter, crate::spt_pagerank_influence::PageRankInfluence,
|
||||
"spt_pagerank_influence",
|
||||
[ev::DOMINANT_PERSON, ev::INFLUENCE_SCORE, ev::INFLUENCE_CHANGE],
|
||||
|d, f| d.process_frame(f.phases, f.n_persons.max(0) as usize));
|
||||
|
||||
fwd_skill!(SptSpikingAdapter, crate::spt_spiking_tracker::SpikingTracker,
|
||||
"spt_spiking_tracker",
|
||||
[ev::TRACK_UPDATE, ev::TRACK_VELOCITY, ev::SPIKE_RATE, ev::TRACK_LOST],
|
||||
|d, f| d.process_frame(f.phases, f.prev_phases));
|
||||
|
||||
fwd_skill!(TmpLogicGuardAdapter, crate::tmp_temporal_logic_guard::TemporalLogicGuard,
|
||||
"tmp_temporal_logic_guard",
|
||||
[ev::LTL_VIOLATION, ev::LTL_SATISFACTION, ev::COUNTEREXAMPLE],
|
||||
|d, f| {
|
||||
let input = crate::tmp_temporal_logic_guard::FrameInput {
|
||||
presence: f.presence,
|
||||
n_persons: f.n_persons,
|
||||
motion_energy: f.motion_energy,
|
||||
coherence: f.coherence,
|
||||
breathing_bpm: f.breathing_bpm,
|
||||
heartrate_bpm: f.heartrate_bpm,
|
||||
fall_alert: false,
|
||||
intrusion_alert: false,
|
||||
person_id_active: f.n_persons > 0,
|
||||
vital_signs_active: f.breathing_bpm > 0.0,
|
||||
seizure_detected: false,
|
||||
normal_gait: true,
|
||||
};
|
||||
d.on_frame(&input)
|
||||
});
|
||||
|
||||
// ── Timer-driven skills (driven once per frame) ──────────────────────────────
|
||||
|
||||
fwd_skill!(VitalTrendAdapter, crate::vital_trend::VitalTrendAnalyzer,
|
||||
"vital_trend",
|
||||
// 101-105 = brady/tachypnea, brady/tachycardia, apnea; 110/111 = breathing/heartrate
|
||||
// moving averages (module-local EVENT_BREATHING_AVG / EVENT_HEARTRATE_AVG).
|
||||
[ev::BRADYPNEA, ev::TACHYPNEA, ev::BRADYCARDIA, ev::TACHYCARDIA, ev::APNEA, 110, 111],
|
||||
|d, f| d.on_timer(f.breathing_bpm, f.heartrate_bpm));
|
||||
|
||||
fwd_skill!(LrnMetaAdapter, crate::lrn_meta_adapt::MetaAdapter,
|
||||
"lrn_meta_adapt",
|
||||
[ev::PARAM_ADJUSTED, ev::ADAPTATION_SCORE, ev::ROLLBACK_TRIGGERED, ev::META_LEVEL],
|
||||
|d, _f| d.on_timer());
|
||||
|
||||
fwd_skill!(SigTemporalCompressAdapter, crate::sig_temporal_compress::TemporalCompressor,
|
||||
"sig_temporal_compress",
|
||||
[ev::COMPRESSION_RATIO, ev::TIER_TRANSITION, ev::HISTORY_DEPTH_HOURS],
|
||||
|d, _f| d.on_timer());
|
||||
|
||||
fwd_skill!(TmpGoapAdapter, crate::tmp_goap_autonomy::GoapPlanner,
|
||||
"tmp_goap_autonomy",
|
||||
[ev::GOAL_SELECTED, ev::MODULE_ACTIVATED, ev::MODULE_DEACTIVATED, ev::PLAN_COST],
|
||||
|d, _f| d.on_timer());
|
||||
|
||||
// tmp_pattern_sequence: accumulate via on_frame, then drive on_timer per frame.
|
||||
pub struct TmpPatternAdapter(crate::tmp_pattern_sequence::PatternSequenceAnalyzer);
|
||||
impl TmpPatternAdapter {
|
||||
pub fn new() -> Self {
|
||||
Self(crate::tmp_pattern_sequence::PatternSequenceAnalyzer::new())
|
||||
}
|
||||
}
|
||||
impl EdgeSkill for TmpPatternAdapter {
|
||||
fn name(&self) -> &'static str {
|
||||
"tmp_pattern_sequence"
|
||||
}
|
||||
fn event_ids(&self) -> &'static [i32] {
|
||||
&[ev::PATTERN_DETECTED, ev::PATTERN_CONFIDENCE, ev::ROUTINE_DEVIATION, ev::PREDICTION_NEXT]
|
||||
}
|
||||
fn on_frame(&mut self, f: &CsiFrameView) -> &[(i32, f32)] {
|
||||
self.0.on_frame(f.presence, f.motion_energy, f.n_persons);
|
||||
self.0.on_timer()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Medical tier (gated) ─────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(feature = "medical-experimental")]
|
||||
mod medical {
|
||||
use super::*;
|
||||
|
||||
// Medical event ids verified against each module's local consts (100-199 block).
|
||||
fwd_skill!(MedCardiacAdapter, crate::med_cardiac_arrhythmia::CardiacArrhythmiaDetector,
|
||||
"med_cardiac_arrhythmia",
|
||||
[110, 111, 112, 113],
|
||||
|d, f| d.process_frame(f.heartrate_bpm, f.phase_mean()));
|
||||
|
||||
fwd_skill!(MedGaitAdapter, crate::med_gait_analysis::GaitAnalyzer,
|
||||
"med_gait_analysis",
|
||||
[130, 131, 132, 133, 134],
|
||||
|d, f| d.process_frame(f.phase_mean(), f.amplitude_mean(), f.variance_mean, f.motion_energy));
|
||||
|
||||
fwd_skill!(MedRespiratoryAdapter, crate::med_respiratory_distress::RespiratoryDistressDetector,
|
||||
"med_respiratory_distress",
|
||||
[120, 121, 122, 123],
|
||||
|d, f| d.process_frame(f.breathing_bpm, f.phase_mean(), f.variance_mean));
|
||||
|
||||
fwd_skill!(MedSeizureAdapter, crate::med_seizure_detect::SeizureDetector,
|
||||
"med_seizure_detect",
|
||||
[140, 141, 142, 143],
|
||||
|d, f| d.process_frame(f.phase_mean(), f.amplitude_mean(), f.motion_energy, f.presence));
|
||||
|
||||
fwd_skill!(MedApneaAdapter, crate::med_sleep_apnea::SleepApneaDetector,
|
||||
"med_sleep_apnea",
|
||||
[100, 101, 102],
|
||||
|d, f| d.process_frame(f.breathing_bpm, f.presence, f.variance_mean));
|
||||
|
||||
pub fn register(skills: &mut Vec<Box<dyn EdgeSkill>>, med: &mut Vec<bool>) {
|
||||
macro_rules! push {
|
||||
($a:ty) => {{
|
||||
skills.push(Box::new(<$a>::new()));
|
||||
med.push(true);
|
||||
}};
|
||||
}
|
||||
push!(MedSeizureAdapter);
|
||||
push!(MedCardiacAdapter);
|
||||
push!(MedRespiratoryAdapter);
|
||||
push!(MedApneaAdapter);
|
||||
push!(MedGaitAdapter);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Registration ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// Register every default-tier (non-medical) skill.
|
||||
pub fn register_default(skills: &mut Vec<Box<dyn EdgeSkill>>, med: &mut Vec<bool>) {
|
||||
macro_rules! push {
|
||||
($a:ty) => {{
|
||||
skills.push(Box::new(<$a>::new()));
|
||||
med.push(false);
|
||||
}};
|
||||
}
|
||||
|
||||
// Flagship + synthesized
|
||||
push!(GestureAdapter);
|
||||
push!(CoherenceAdapter);
|
||||
push!(AdversarialAdapter);
|
||||
push!(OccupancyAdapter);
|
||||
push!(IntrusionAdapter);
|
||||
push!(VitalTrendAdapter);
|
||||
|
||||
// Security
|
||||
push!(SecPerimeterAdapter);
|
||||
push!(SecWeaponAdapter);
|
||||
push!(SecTailgateAdapter);
|
||||
push!(SecLoiteringAdapter);
|
||||
push!(SecPanicAdapter);
|
||||
|
||||
// Smart building
|
||||
push!(BldHvacAdapter);
|
||||
push!(BldLightingAdapter);
|
||||
push!(BldElevatorAdapter);
|
||||
push!(BldMeetingAdapter);
|
||||
push!(BldEnergyAdapter);
|
||||
|
||||
// Retail
|
||||
push!(RetQueueAdapter);
|
||||
push!(RetDwellAdapter);
|
||||
push!(RetFlowAdapter);
|
||||
push!(RetTableAdapter);
|
||||
push!(RetShelfAdapter);
|
||||
|
||||
// Industrial
|
||||
push!(IndForkliftAdapter);
|
||||
push!(IndConfinedAdapter);
|
||||
push!(IndCleanRoomAdapter);
|
||||
push!(IndLivestockAdapter);
|
||||
push!(IndVibrationAdapter);
|
||||
|
||||
// Exotic / research
|
||||
push!(ExoTimeCrystalAdapter);
|
||||
push!(ExoHyperbolicAdapter);
|
||||
push!(ExoDreamAdapter);
|
||||
push!(ExoEmotionAdapter);
|
||||
push!(ExoGestureLangAdapter);
|
||||
push!(ExoMusicAdapter);
|
||||
push!(ExoPlantAdapter);
|
||||
push!(ExoGhostAdapter);
|
||||
push!(ExoRainAdapter);
|
||||
push!(ExoBreathingSyncAdapter);
|
||||
push!(ExoHappinessAdapter);
|
||||
|
||||
// Signal intelligence
|
||||
push!(SigCoherenceGateAdapter);
|
||||
push!(SigFlashAttnAdapter);
|
||||
push!(SigTemporalCompressAdapter);
|
||||
push!(SparseRecoveryAdapter);
|
||||
push!(SigMincutAdapter);
|
||||
push!(SigTransportAdapter);
|
||||
|
||||
// Adaptive learning
|
||||
push!(LrnDtwAdapter);
|
||||
push!(LrnAttractorAdapter);
|
||||
push!(LrnMetaAdapter);
|
||||
push!(LrnEwcAdapter);
|
||||
|
||||
// Spatial reasoning
|
||||
push!(SptPagerankAdapter);
|
||||
push!(SptHnswAdapter);
|
||||
push!(SptSpikingAdapter);
|
||||
|
||||
// Temporal analysis
|
||||
push!(TmpPatternAdapter);
|
||||
push!(TmpLogicGuardAdapter);
|
||||
push!(TmpGoapAdapter);
|
||||
|
||||
// AI security
|
||||
push!(AisPromptShieldAdapter);
|
||||
push!(AisBehavioralAdapter);
|
||||
|
||||
// Quantum-inspired
|
||||
push!(QntCoherenceAdapter);
|
||||
push!(QntInterferenceAdapter);
|
||||
|
||||
// Autonomous systems
|
||||
push!(AutPsychoAdapter);
|
||||
push!(AutMeshAdapter);
|
||||
|
||||
let _ = (skills.len(), med.len());
|
||||
}
|
||||
|
||||
/// Register the gated `medical-experimental` tier (5 `med_*` skills).
|
||||
#[cfg(feature = "medical-experimental")]
|
||||
pub fn register_medical(skills: &mut Vec<Box<dyn EdgeSkill>>, med: &mut Vec<bool>) {
|
||||
medical::register(skills, med);
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
//! Integration test for the unified [`EdgePipeline`] (ADR-160 deliverable 1).
|
||||
//!
|
||||
//! Proves that EVERY registered skill executes over a deterministic synthetic
|
||||
//! CSI frame sequence without panicking, that the aggregated event stream is
|
||||
//! well-formed (each event tagged with a known skill name + a declared event
|
||||
//! id), and pins the registered-skill count (default vs +medical-experimental).
|
||||
//!
|
||||
//! Run:
|
||||
//! cargo test --features std --test pipeline_all
|
||||
//! cargo test --features std,medical-experimental --test pipeline_all
|
||||
//!
|
||||
//! [`EdgePipeline`]: wifi_densepose_wasm_edge::pipeline_all::EdgePipeline
|
||||
|
||||
#![cfg(feature = "std")]
|
||||
|
||||
use wifi_densepose_wasm_edge::pipeline_all::{CsiFrameView, EdgePipeline};
|
||||
|
||||
const N_SC: usize = 32;
|
||||
|
||||
/// Deterministic synthetic frame: a moving breathing/heartbeat target plus
|
||||
/// structured per-subcarrier phase/amplitude. No randomness — fully reproducible.
|
||||
fn synth_frame(t: usize, phases: &mut [f32], amps: &mut [f32], vars: &mut [f32]) {
|
||||
let tf = t as f32;
|
||||
// 0.3 Hz breathing modulation @ 20 Hz frame rate -> period ~66 frames.
|
||||
let breath = (tf * 2.0 * core::f32::consts::PI * 0.3 / 20.0).sin();
|
||||
// 1.2 Hz heartbeat.
|
||||
let heart = (tf * 2.0 * core::f32::consts::PI * 1.2 / 20.0).sin();
|
||||
for i in 0..phases.len() {
|
||||
let sc = i as f32;
|
||||
phases[i] = (sc * 0.21 + tf * 0.05).sin() + 0.15 * breath;
|
||||
amps[i] = 1.0 + 0.3 * (sc * 0.11 + tf * 0.03).cos() + 0.1 * heart;
|
||||
// motion-correlated variance, with one occasionally-hot zone.
|
||||
vars[i] = 0.02 + 0.01 * (sc * 0.3).sin().abs() + if (t / 40) % 2 == 0 { 0.05 } else { 0.0 };
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a view over the supplied buffers for frame `t`.
|
||||
fn view<'a>(
|
||||
t: usize,
|
||||
phases: &'a [f32],
|
||||
amps: &'a [f32],
|
||||
vars: &'a [f32],
|
||||
prev_phases: &'a [f32],
|
||||
) -> CsiFrameView<'a> {
|
||||
let tf = t as f32;
|
||||
let motion = 0.3 + 0.2 * (tf * 0.07).sin().abs();
|
||||
let mut vmean = 0.0f32;
|
||||
for &v in vars {
|
||||
vmean += v;
|
||||
}
|
||||
vmean /= vars.len().max(1) as f32;
|
||||
CsiFrameView {
|
||||
phases,
|
||||
amplitudes: amps,
|
||||
variances: vars,
|
||||
prev_phases,
|
||||
presence: if (t / 30) % 3 == 0 { 0 } else { 1 },
|
||||
n_persons: ((t / 50) % 3) as i32,
|
||||
motion_energy: motion,
|
||||
breathing_bpm: 18.0 + 2.0 * (tf * 0.01).sin(),
|
||||
heartrate_bpm: 72.0 + 5.0 * (tf * 0.02).sin(),
|
||||
coherence: 0.5 + 0.4 * (tf * 0.03).cos(),
|
||||
variance_mean: vmean,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_skills_execute_without_panic_over_synthetic_stream() {
|
||||
let mut pipeline = EdgePipeline::new();
|
||||
let n_skills = pipeline.skill_count();
|
||||
assert!(n_skills > 0, "pipeline must register skills");
|
||||
|
||||
let mut phases = [0.0f32; N_SC];
|
||||
let mut amps = [0.0f32; N_SC];
|
||||
let mut vars = [0.0f32; N_SC];
|
||||
let mut prev_phases = [0.0f32; N_SC];
|
||||
|
||||
let known: std::collections::HashSet<&'static str> =
|
||||
pipeline.skills().iter().map(|s| s.name).collect();
|
||||
|
||||
// Feed 300 frames (15 s @ 20 Hz) — enough for calibration windows, DTW
|
||||
// enrollment, periodicity buffers, and timer cadences to fire.
|
||||
let mut total_events = 0usize;
|
||||
for t in 0..300 {
|
||||
synth_frame(t, &mut phases, &mut amps, &mut vars);
|
||||
let v = view(t, &phases, &s, &vars, &prev_phases);
|
||||
let events = pipeline.on_frame(&v);
|
||||
for e in &events {
|
||||
// Every event must be tagged with a registered skill name.
|
||||
assert!(known.contains(e.skill), "unknown skill tag: {}", e.skill);
|
||||
// Value must be finite (no NaN/Inf leaking from the DSP).
|
||||
assert!(e.value.is_finite(), "non-finite value from {}", e.skill);
|
||||
}
|
||||
total_events += events.len();
|
||||
prev_phases.copy_from_slice(&phases);
|
||||
}
|
||||
|
||||
assert_eq!(pipeline.frame_count(), 300);
|
||||
// A real run over 300 frames must emit *some* events across 59+ skills.
|
||||
assert!(
|
||||
total_events > 0,
|
||||
"expected the skill library to emit events over 300 frames, got 0"
|
||||
);
|
||||
println!(
|
||||
"pipeline: {} skills, {} aggregated events over 300 synthetic frames",
|
||||
n_skills, total_events
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_emitted_event_id_is_declared_by_its_skill() {
|
||||
// Stronger well-formedness: each event's id must be one the producing skill
|
||||
// declared in its `event_ids()` introspection list.
|
||||
let mut pipeline = EdgePipeline::new();
|
||||
|
||||
// skill name -> its declared event id set
|
||||
let mut declared: std::collections::HashMap<&'static str, std::collections::HashSet<i32>> =
|
||||
std::collections::HashMap::new();
|
||||
for s in pipeline.skills() {
|
||||
declared.insert(s.name, s.event_ids.iter().copied().collect());
|
||||
}
|
||||
|
||||
let mut phases = [0.0f32; N_SC];
|
||||
let mut amps = [0.0f32; N_SC];
|
||||
let mut vars = [0.0f32; N_SC];
|
||||
let mut prev_phases = [0.0f32; N_SC];
|
||||
|
||||
for t in 0..300 {
|
||||
synth_frame(t, &mut phases, &mut amps, &mut vars);
|
||||
let v = view(t, &phases, &s, &vars, &prev_phases);
|
||||
for e in &pipeline.on_frame(&v) {
|
||||
let set = declared.get(e.skill).expect("skill declared");
|
||||
assert!(
|
||||
set.contains(&e.event_id),
|
||||
"{} emitted undeclared event id {}",
|
||||
e.skill,
|
||||
e.event_id
|
||||
);
|
||||
}
|
||||
prev_phases.copy_from_slice(&phases);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn introspection_lists_every_skill_with_event_ids() {
|
||||
let pipeline = EdgePipeline::new();
|
||||
let infos = pipeline.skills();
|
||||
assert_eq!(infos.len(), pipeline.skill_count());
|
||||
for info in &infos {
|
||||
assert!(!info.name.is_empty());
|
||||
assert!(
|
||||
!info.event_ids.is_empty(),
|
||||
"skill {} declares no event ids",
|
||||
info.name
|
||||
);
|
||||
}
|
||||
// No duplicate skill names.
|
||||
let names: std::collections::HashSet<_> = infos.iter().map(|i| i.name).collect();
|
||||
assert_eq!(names.len(), infos.len(), "duplicate skill registration");
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "medical-experimental"))]
|
||||
#[test]
|
||||
fn default_tier_count_excludes_medical() {
|
||||
let pipeline = EdgePipeline::new();
|
||||
assert_eq!(
|
||||
pipeline.skill_count(),
|
||||
59,
|
||||
"default (non-medical) tier must register exactly 59 skills"
|
||||
);
|
||||
// The ADR-160 safety gate: no med_* skill is present in the default build.
|
||||
for info in pipeline.skills() {
|
||||
assert!(
|
||||
!info.medical_experimental,
|
||||
"medical skill {} leaked into default tier",
|
||||
info.name
|
||||
);
|
||||
assert!(
|
||||
!info.name.starts_with("med_"),
|
||||
"med_* skill {} present without the medical-experimental feature",
|
||||
info.name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "medical-experimental")]
|
||||
#[test]
|
||||
fn medical_tier_adds_five_skills() {
|
||||
let pipeline = EdgePipeline::new();
|
||||
assert_eq!(
|
||||
pipeline.skill_count(),
|
||||
64,
|
||||
"default 59 + 5 medical = 64 skills"
|
||||
);
|
||||
let med: Vec<_> = pipeline
|
||||
.skills()
|
||||
.into_iter()
|
||||
.filter(|s| s.medical_experimental)
|
||||
.collect();
|
||||
assert_eq!(med.len(), 5, "exactly 5 medical-experimental skills");
|
||||
for m in &med {
|
||||
assert!(
|
||||
m.name.starts_with("med_"),
|
||||
"medical-flagged skill has non-med_ name: {}",
|
||||
m.name
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,762 @@
|
||||
//! Synthetic-ground-truth validation harness (ADR-160 deliverable 2).
|
||||
//!
|
||||
//! For the subset of edge skills whose detection target can be PLANTED with
|
||||
//! known ground truth, we generate N signals with known answers, run the real
|
||||
//! detector, and MEASURE detection rate / precision / recall / rate-error.
|
||||
//!
|
||||
//! # Honesty boundary
|
||||
//!
|
||||
//! This is **synthetic-ground-truth validation, NOT field accuracy.** A skill
|
||||
//! that recovers a planted sinusoid here is proven to do the math it claims on
|
||||
//! a constructed signal; it is NOT proven to work on real CSI in a real room.
|
||||
//!
|
||||
//! Skills whose detection target cannot be honestly planted on synthetic data
|
||||
//! (clinical seizure/apnea/arrhythmia/gait, weapon discrimination, affect/
|
||||
//! emotion/happiness, dream stage, sign language) are **NOT** validated here —
|
||||
//! see RESULTS.md "DATA-GATED" section. Planting a "seizure-like" wiggle and
|
||||
//! claiming the detector works validates nothing real.
|
||||
//!
|
||||
//! Run:
|
||||
//! cargo test --features std --test synthetic_validation -- --nocapture
|
||||
//!
|
||||
//! The printed `MEASURED` lines are the source of `benchmarks/edge-skills/RESULTS.md`.
|
||||
|
||||
#![cfg(feature = "std")]
|
||||
|
||||
use std::f32::consts::PI;
|
||||
|
||||
// ── Confusion-matrix accumulator ─────────────────────────────────────────────
|
||||
|
||||
#[derive(Default, Clone, Copy)]
|
||||
struct Confusion {
|
||||
tp: u32,
|
||||
fp: u32,
|
||||
tn: u32,
|
||||
fn_: u32,
|
||||
}
|
||||
impl Confusion {
|
||||
fn observe(&mut self, predicted_positive: bool, actual_positive: bool) {
|
||||
match (predicted_positive, actual_positive) {
|
||||
(true, true) => self.tp += 1,
|
||||
(true, false) => self.fp += 1,
|
||||
(false, false) => self.tn += 1,
|
||||
(false, true) => self.fn_ += 1,
|
||||
}
|
||||
}
|
||||
fn precision(&self) -> f32 {
|
||||
let d = self.tp + self.fp;
|
||||
if d == 0 {
|
||||
1.0
|
||||
} else {
|
||||
self.tp as f32 / d as f32
|
||||
}
|
||||
}
|
||||
fn recall(&self) -> f32 {
|
||||
let d = self.tp + self.fn_;
|
||||
if d == 0 {
|
||||
1.0
|
||||
} else {
|
||||
self.tp as f32 / d as f32
|
||||
}
|
||||
}
|
||||
fn accuracy(&self) -> f32 {
|
||||
let d = self.tp + self.fp + self.tn + self.fn_;
|
||||
if d == 0 {
|
||||
0.0
|
||||
} else {
|
||||
(self.tp + self.tn) as f32 / d as f32
|
||||
}
|
||||
}
|
||||
fn report(&self, name: &str) {
|
||||
println!(
|
||||
"MEASURED-on-synthetic | {:<34} | acc={:.3} prec={:.3} recall={:.3} | TP={} FP={} TN={} FN={}",
|
||||
name,
|
||||
self.accuracy(),
|
||||
self.precision(),
|
||||
self.recall(),
|
||||
self.tp,
|
||||
self.fp,
|
||||
self.tn,
|
||||
self.fn_
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 1. vital_trend — rate-threshold detection (directly verified thresholds) ─
|
||||
// Thresholds (from src/vital_trend.rs): BRADYPNEA<12, TACHYPNEA>25,
|
||||
// BRADYCARDIA<50, TACHYCARDIA>120, APNEA at breathing<1.0 for 20 calls;
|
||||
// ALERT_DEBOUNCE=5. Drive on_timer with known BPM, count event presence.
|
||||
|
||||
#[test]
|
||||
fn vital_trend_rate_thresholds() {
|
||||
use wifi_densepose_wasm_edge::vital_trend::VitalTrendAnalyzer;
|
||||
|
||||
// event ids: 101 brady-pnea, 102 tachy-pnea, 103 brady-cardia, 104 tachy-cardia, 105 apnea
|
||||
fn drive_breathing(bpm: f32, n: u32) -> std::collections::HashSet<i32> {
|
||||
let mut det = VitalTrendAnalyzer::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for _ in 0..n {
|
||||
for &(id, _) in det.on_timer(bpm, 72.0) {
|
||||
seen.insert(id);
|
||||
}
|
||||
}
|
||||
seen
|
||||
}
|
||||
fn drive_heart(bpm: f32, n: u32) -> std::collections::HashSet<i32> {
|
||||
let mut det = VitalTrendAnalyzer::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for _ in 0..n {
|
||||
for &(id, _) in det.on_timer(16.0, bpm) {
|
||||
seen.insert(id);
|
||||
}
|
||||
}
|
||||
seen
|
||||
}
|
||||
|
||||
// 6 calls > ALERT_DEBOUNCE(5) so a sustained abnormal value fires.
|
||||
let mut c = Confusion::default();
|
||||
// Bradypnea: <12 positive; normal 16 negative.
|
||||
c.observe(drive_breathing(8.0, 6).contains(&101), true);
|
||||
c.observe(drive_breathing(16.0, 6).contains(&101), false);
|
||||
// Tachypnea: >25 positive; normal negative.
|
||||
c.observe(drive_breathing(30.0, 6).contains(&102), true);
|
||||
c.observe(drive_breathing(16.0, 6).contains(&102), false);
|
||||
// Bradycardia: <50.
|
||||
c.observe(drive_heart(40.0, 6).contains(&103), true);
|
||||
c.observe(drive_heart(72.0, 6).contains(&103), false);
|
||||
// Tachycardia: >120.
|
||||
c.observe(drive_heart(140.0, 6).contains(&104), true);
|
||||
c.observe(drive_heart(72.0, 6).contains(&104), false);
|
||||
// Apnea: breathing < 1.0 for >= 20 calls.
|
||||
c.observe(drive_breathing(0.0, 20).contains(&105), true);
|
||||
c.observe(drive_breathing(0.0, 10).contains(&105), false); // only 10 calls -> below APNEA_SECONDS
|
||||
|
||||
c.report("vital_trend (brady/tachy-pnea/cardia, apnea)");
|
||||
// All 5 thresholds + their negatives must classify correctly.
|
||||
assert_eq!(c.accuracy(), 1.0, "vital_trend rate thresholds must be exact");
|
||||
}
|
||||
|
||||
// ── 2. exo_time_crystal — period-doubling (sub-harmonic) detection ───────────
|
||||
// Detects a peak at lag L AND a peak at lag 2L in motion-energy autocorrelation.
|
||||
// PLANT positive: period-2 modulation (alternating amplitude on a base period)
|
||||
// so autocorr has peaks at both L and 2L.
|
||||
// PLANT negative: a single clean period (peak at L only) or noise.
|
||||
|
||||
fn run_time_crystal(motion: &[f32]) -> bool {
|
||||
use wifi_densepose_wasm_edge::exo_time_crystal::TimeCrystalDetector;
|
||||
let mut det = TimeCrystalDetector::new();
|
||||
let mut detected = false;
|
||||
for &m in motion {
|
||||
for &(id, v) in det.process_frame(m) {
|
||||
if id == 680 && v >= 2.0 {
|
||||
detected = true; // CRYSTAL_DETECTED with multiplier 2
|
||||
}
|
||||
}
|
||||
}
|
||||
detected
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exo_time_crystal_period_doubling() {
|
||||
let n = 256usize;
|
||||
// Positive: period-2 subharmonic. Base period P=16; alternate full periods
|
||||
// are scaled differently so the waveform only repeats every 2P=32 (peak at
|
||||
// lag 32) while still correlating at P=16. Plain sine (no abs, which would
|
||||
// itself fold frequency and fake a sub-harmonic).
|
||||
let base_p = 16.0f32;
|
||||
let mut pos = Vec::with_capacity(n);
|
||||
for t in 0..n {
|
||||
let phase = (t as f32) * 2.0 * PI / base_p;
|
||||
let sub = if ((t as f32 / base_p) as i32) % 2 == 0 { 1.0 } else { 0.45 };
|
||||
pos.push(0.6 + 0.35 * phase.sin() * sub);
|
||||
}
|
||||
// HONEST LIMIT (measured below): a *pure* periodic signal already has
|
||||
// autocorrelation peaks at L AND 2L (natural harmonics), so this detector
|
||||
// cannot separate a true period-2 sub-harmonic from a plain periodic signal.
|
||||
// The construct it CAN discriminate with known ground truth is
|
||||
// "periodic-with-coordination vs aperiodic". We validate that.
|
||||
//
|
||||
// Negative 1: incrementing-seed pseudo-noise (no periodicity).
|
||||
let mut noise = Vec::with_capacity(n);
|
||||
let mut s: u32 = 12345;
|
||||
for _ in 0..n {
|
||||
s = s.wrapping_mul(1664525).wrapping_add(1013904223);
|
||||
noise.push(0.3 + 0.4 * ((s >> 8) & 0xffff) as f32 / 65535.0);
|
||||
}
|
||||
// Negative 2: near-constant motion (no oscillation at all).
|
||||
let flat: Vec<f32> = (0..n).map(|t| 0.5 + 1e-4 * (t as f32 * 0.01).sin()).collect();
|
||||
|
||||
let mut c = Confusion::default();
|
||||
c.observe(run_time_crystal(&pos), true); // planted period-2 -> detect
|
||||
c.observe(run_time_crystal(&noise), false); // pseudo-noise -> reject
|
||||
c.observe(run_time_crystal(&flat), false); // flat -> reject
|
||||
c.report("exo_time_crystal (periodic-coordination vs aperiodic)");
|
||||
assert!(
|
||||
run_time_crystal(&pos),
|
||||
"must detect planted period-2 coordinated motion"
|
||||
);
|
||||
assert!(
|
||||
!run_time_crystal(&noise),
|
||||
"must NOT fire on pseudo-noise"
|
||||
);
|
||||
assert!(!run_time_crystal(&flat), "must NOT fire on flat motion");
|
||||
}
|
||||
|
||||
// ── 3. exo_ghost_hunter — hidden breathing (autocorr at breathing-range lag) ─
|
||||
// When presence==0, aggregate phase is autocorrelated at lags 5..=15; a peak
|
||||
// there above HIDDEN_PRESENCE_THRESHOLD(0.3) emits HIDDEN_PRESENCE(652).
|
||||
// PLANT positive: phase sinusoid at a lag in [5,15] across an empty room.
|
||||
// PLANT negative: flat phase (no periodic breathing signature).
|
||||
|
||||
fn run_ghost_hidden_breathing(period: f32, amp: f32, frames: usize) -> f32 {
|
||||
use wifi_densepose_wasm_edge::exo_ghost_hunter::GhostHunterDetector;
|
||||
let mut det = GhostHunterDetector::new();
|
||||
let n_sc = 32usize;
|
||||
let mut max_hidden = 0.0f32;
|
||||
for t in 0..frames {
|
||||
let breath = if period > 0.0 {
|
||||
amp * (t as f32 * 2.0 * PI / period).sin()
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let mut phases = [0.0f32; 32];
|
||||
let mut amps = [0.0f32; 32];
|
||||
let mut vars = [0.0f32; 32];
|
||||
for i in 0..n_sc {
|
||||
// breathing modulates phase uniformly (chest motion -> common phase shift)
|
||||
phases[i] = 0.1 * (i as f32 * 0.2).sin() + breath;
|
||||
amps[i] = 1.0;
|
||||
vars[i] = 0.01;
|
||||
}
|
||||
// presence = 0 (empty room) is required for the hidden-breathing path.
|
||||
for &(id, v) in det.process_frame(&phases, &s, &vars, 0, 0.0) {
|
||||
if id == 652 {
|
||||
if v > max_hidden {
|
||||
max_hidden = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
max_hidden
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exo_ghost_hunter_hidden_breathing() {
|
||||
// Period 8 frames is within the breathing lag window [5,15].
|
||||
let pos = run_ghost_hidden_breathing(8.0, 0.5, 200);
|
||||
// Flat phase (no breathing) -> no hidden-presence event.
|
||||
let neg = run_ghost_hidden_breathing(0.0, 0.0, 200);
|
||||
|
||||
let mut c = Confusion::default();
|
||||
c.observe(pos > 0.0, true);
|
||||
c.observe(neg > 0.0, false);
|
||||
c.report("exo_ghost_hunter (hidden breathing, lag 8)");
|
||||
println!(
|
||||
" detail: planted-breathing hidden-presence score={:.3}, flat-phase score={:.3}",
|
||||
pos, neg
|
||||
);
|
||||
assert!(
|
||||
pos > 0.3,
|
||||
"planted breathing must score above HIDDEN_PRESENCE_THRESHOLD (0.3); got {}",
|
||||
pos
|
||||
);
|
||||
assert!(
|
||||
neg <= 0.0,
|
||||
"flat phase must not emit hidden presence; got {}",
|
||||
neg
|
||||
);
|
||||
}
|
||||
|
||||
// ── 4. occupancy — calibration + variance-driven zone occupancy ──────────────
|
||||
// BASELINE_FRAMES=200 of low-variance amplitudes establish baseline; then
|
||||
// high amplitude-variance per zone (score > ZONE_THRESHOLD=0.02) flips a zone
|
||||
// to occupied (EVENT_ZONE_OCCUPIED=300).
|
||||
|
||||
#[test]
|
||||
fn occupancy_variance_detection() {
|
||||
use wifi_densepose_wasm_edge::occupancy::OccupancyDetector;
|
||||
|
||||
fn run(occupied_signal: bool) -> bool {
|
||||
let mut det = OccupancyDetector::new();
|
||||
let n_sc = 32usize;
|
||||
let mut phases = [0.0f32; 32];
|
||||
// Calibration: 220 frames of near-flat amplitudes (low variance).
|
||||
for t in 0..220 {
|
||||
let mut amps = [1.0f32; 32];
|
||||
for i in 0..n_sc {
|
||||
amps[i] = 1.0 + 1e-3 * ((t + i) as f32 * 0.7).sin();
|
||||
phases[i] = 0.01 * (i as f32).sin();
|
||||
}
|
||||
det.process_frame(&phases, &s);
|
||||
}
|
||||
// Test phase: 60 frames. If occupied, inject strong per-zone amplitude
|
||||
// variance; else keep flat.
|
||||
let mut fired = false;
|
||||
for t in 0..60 {
|
||||
let mut amps = [1.0f32; 32];
|
||||
for i in 0..n_sc {
|
||||
amps[i] = if occupied_signal {
|
||||
// strong structured variance within each zone
|
||||
1.0 + 2.0 * (((i % 4) as f32) - 1.5) + 0.5 * (t as f32 * 0.3 + i as f32).sin()
|
||||
} else {
|
||||
1.0 + 1e-3 * ((t + i) as f32 * 0.7).sin()
|
||||
};
|
||||
}
|
||||
for &(id, _) in det.process_frame(&phases, &s) {
|
||||
if id == 300 {
|
||||
fired = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
fired
|
||||
}
|
||||
|
||||
let mut c = Confusion::default();
|
||||
c.observe(run(true), true);
|
||||
c.observe(run(false), false);
|
||||
c.report("occupancy (zone variance vs flat baseline)");
|
||||
assert!(run(true), "high zone variance after calibration must occupy a zone");
|
||||
assert!(!run(false), "flat amplitude must stay unoccupied");
|
||||
}
|
||||
|
||||
// ── 5. intrusion — calibrate, arm, then disturbance>=0.8 alerts ──────────────
|
||||
// disturbance = 0.6*frac(|Δphase|>1.5) + 0.4*frac(|Δamp|>3σ). Calibrate 200
|
||||
// quiet frames, monitor 100 quiet frames -> Armed, then 3 frames of large
|
||||
// phase+amp disturbance -> EVENT_INTRUSION_ALERT(200).
|
||||
|
||||
#[test]
|
||||
fn intrusion_disturbance_alert() {
|
||||
use wifi_densepose_wasm_edge::intrusion::IntrusionDetector;
|
||||
|
||||
fn run(intrude: bool) -> bool {
|
||||
let mut det = IntrusionDetector::new();
|
||||
let n_sc = 32usize;
|
||||
// Calibration (200) + monitoring quiet (120) -> Armed. Quiet = constant.
|
||||
for _ in 0..330 {
|
||||
let phases = [0.5f32; 32];
|
||||
let amps = [1.0f32; 32];
|
||||
det.process_frame(&phases, &s);
|
||||
}
|
||||
let mut alerted = false;
|
||||
// 10 test frames.
|
||||
for t in 0..10 {
|
||||
let mut phases = [0.5f32; 32];
|
||||
let mut amps = [1.0f32; 32];
|
||||
if intrude {
|
||||
for i in 0..n_sc {
|
||||
// alternate phase by 3.0 (>1.5) and amplitude far from baseline 1.0.
|
||||
phases[i] = if t % 2 == 0 { 0.5 } else { 4.0 };
|
||||
amps[i] = 1.0 + 8.0; // huge deviation vs ~0 baseline variance
|
||||
}
|
||||
}
|
||||
for &(id, _) in det.process_frame(&phases, &s) {
|
||||
if id == 200 {
|
||||
alerted = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
alerted
|
||||
}
|
||||
|
||||
let mut c = Confusion::default();
|
||||
c.observe(run(true), true);
|
||||
c.observe(run(false), false);
|
||||
c.report("intrusion (armed -> disturbance alert vs quiet)");
|
||||
assert!(run(true), "large phase+amplitude disturbance must alert when armed");
|
||||
assert!(!run(false), "quiet environment must not alert");
|
||||
}
|
||||
|
||||
// ── 6. sig_sparse_recovery — ISTA recovery of planted null subcarriers ───────
|
||||
// Initialize correlation on clean frames, then null >10% of subcarriers and
|
||||
// MEASURE how well ISTA recovers them (rate-error style: recovery residual).
|
||||
|
||||
#[test]
|
||||
fn sig_sparse_recovery_recovers_nulls() {
|
||||
use wifi_densepose_wasm_edge::sig_sparse_recovery::SparseRecovery;
|
||||
|
||||
let mut det = SparseRecovery::new();
|
||||
let n_sc = 32usize;
|
||||
// Underlying smooth signal (neighbor-correlated) the model can learn.
|
||||
let truth: Vec<f32> = (0..n_sc).map(|i| 1.0 + 0.5 * (i as f32 * 0.4).sin()).collect();
|
||||
|
||||
// Warm up correlation model with 30 clean frames.
|
||||
for _ in 0..30 {
|
||||
let mut amps: Vec<f32> = truth.clone();
|
||||
det.process_frame(&mut amps);
|
||||
}
|
||||
|
||||
// Null subcarriers 5..13 (8/32 = 25% > MIN_DROPOUT_RATE 0.10).
|
||||
let mut amps: Vec<f32> = truth.clone();
|
||||
let nulled: Vec<usize> = (5..13).collect();
|
||||
for &i in &nulled {
|
||||
amps[i] = 0.0;
|
||||
}
|
||||
// Baseline error if the nulls were left at 0.0 (unrecovered).
|
||||
let mut sse0 = 0.0f32;
|
||||
for &i in &nulled {
|
||||
sse0 += truth[i] * truth[i];
|
||||
}
|
||||
let baseline_rmse = (sse0 / nulled.len() as f32).sqrt();
|
||||
|
||||
let mut recovery_seen = false;
|
||||
for &(id, _) in det.process_frame(&mut amps) {
|
||||
if id == 715 {
|
||||
recovery_seen = true; // RECOVERY_COMPLETE
|
||||
}
|
||||
}
|
||||
// Measure recovery error on the nulled positions (now written back in-place).
|
||||
let mut sse = 0.0f32;
|
||||
for &i in &nulled {
|
||||
let d = amps[i] - truth[i];
|
||||
sse += d * d;
|
||||
}
|
||||
let rmse = (sse / nulled.len() as f32).sqrt();
|
||||
println!(
|
||||
"MEASURED-on-synthetic | {:<34} | dropout-detect+recovery-trigger=PASS | recovered RMSE={:.4} vs unrecovered-null RMSE={:.4} ({:+.1}%) over {} nulled subcarriers",
|
||||
"sig_sparse_recovery (ISTA)",
|
||||
rmse,
|
||||
baseline_rmse,
|
||||
100.0 * (1.0 - rmse / baseline_rmse),
|
||||
nulled.len()
|
||||
);
|
||||
// CONSTRUCTIBLE + MEASURED: the dropout detection and recovery-trigger
|
||||
// pipeline fires correctly on >10% planted nulls. This is the validatable
|
||||
// claim and we assert it.
|
||||
assert!(recovery_seen, "dropout > 10% must trigger ISTA recovery (RECOVERY_COMPLETE)");
|
||||
// HONEST MEASURED RESULT (reported, NOT asserted as a win): on this
|
||||
// neighbor-correlated synthetic signal the tridiagonal-model ISTA recovery
|
||||
// does NOT beat leaving the nulls at zero (RMSE ~1.00 vs ~0.98). The skill's
|
||||
// *recovery accuracy* is therefore NOT validated as effective on synthetic
|
||||
// data — only its dropout-detection/trigger path is. Reported in RESULTS.md.
|
||||
assert!(
|
||||
rmse.is_finite() && rmse < 5.0,
|
||||
"recovered values must be finite and bounded; got {}",
|
||||
rmse
|
||||
);
|
||||
}
|
||||
|
||||
// ── 7. exo_rain_detect — broadband variance onset (empty room) ───────────────
|
||||
// presence=0, MIN_EMPTY_FRAMES=40 baseline, then >=6/8 groups with variance
|
||||
// ratio > 2.5 for ONSET_FRAMES=10 -> EVENT_RAIN_ONSET(660).
|
||||
|
||||
#[test]
|
||||
fn exo_rain_detect_broadband_onset() {
|
||||
use wifi_densepose_wasm_edge::exo_rain_detect::RainDetector;
|
||||
|
||||
fn run(rain: bool) -> bool {
|
||||
let mut det = RainDetector::new();
|
||||
let n_sc = 32usize;
|
||||
let phases = [0.1f32; 32];
|
||||
let amps = [1.0f32; 32];
|
||||
// 60 empty baseline frames with low variance.
|
||||
for _ in 0..60 {
|
||||
let vars = [0.001f32; 32];
|
||||
det.process_frame(&phases, &vars, &s, 0);
|
||||
}
|
||||
let mut onset = false;
|
||||
// 40 frames: broadband-high variance if rain, else stay low.
|
||||
for _ in 0..40 {
|
||||
let vars = if rain { [0.5f32; 32] } else { [0.001f32; 32] };
|
||||
for &(id, _) in det.process_frame(&phases, &vars, &s, 0) {
|
||||
if id == 660 {
|
||||
onset = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = n_sc;
|
||||
onset
|
||||
}
|
||||
|
||||
let mut c = Confusion::default();
|
||||
c.observe(run(true), true);
|
||||
c.observe(run(false), false);
|
||||
c.report("exo_rain_detect (broadband variance onset)");
|
||||
assert!(run(true), "broadband variance elevation must trigger rain onset");
|
||||
assert!(!run(false), "stable low variance must not trigger rain");
|
||||
}
|
||||
|
||||
// ── 8. sig_flash_attention — peak-attention subcarrier localization ──────────
|
||||
// Q=mean(phase) per group, K=mean(prev_phase), score=Q*K/sqrt(8), softmax peak.
|
||||
// Plant a sustained large phase in a KNOWN group -> assert that group becomes
|
||||
// the reported attention peak (EVENT_ATTENTION_PEAK_SC=700).
|
||||
|
||||
#[test]
|
||||
fn sig_flash_attention_peak_localization() {
|
||||
use wifi_densepose_wasm_edge::sig_flash_attention::FlashAttention;
|
||||
|
||||
fn peak_for_group(target_group: usize) -> i32 {
|
||||
let mut det = FlashAttention::new();
|
||||
let n_sc = 32usize;
|
||||
let subs_per = n_sc / 8;
|
||||
let mut last_peak = -1;
|
||||
// Sustain the spike so both Q (this frame) and K (prev frame) are large
|
||||
// in the target group -> highest score there.
|
||||
for _ in 0..20 {
|
||||
let mut phases = [0.05f32; 32];
|
||||
let mut amps = [1.0f32; 32];
|
||||
for i in (target_group * subs_per)..((target_group + 1) * subs_per) {
|
||||
phases[i] = 3.0;
|
||||
amps[i] = 3.0;
|
||||
}
|
||||
for &(id, v) in det.process_frame(&phases, &s) {
|
||||
if id == 700 {
|
||||
last_peak = v as i32;
|
||||
}
|
||||
}
|
||||
}
|
||||
last_peak
|
||||
}
|
||||
|
||||
let mut correct = 0u32;
|
||||
let total = 8u32;
|
||||
for g in 0..8usize {
|
||||
let got = peak_for_group(g);
|
||||
if got == g as i32 {
|
||||
correct += 1;
|
||||
}
|
||||
println!(" flash_attention: planted group {} -> reported peak {}", g, got);
|
||||
}
|
||||
let acc = correct as f32 / total as f32;
|
||||
println!(
|
||||
"MEASURED-on-synthetic | {:<34} | peak-localization accuracy = {}/{} = {:.3}",
|
||||
"sig_flash_attention", correct, total, acc
|
||||
);
|
||||
assert!(acc >= 0.75, "must localize the planted attention group in >=75% of cases; got {}", acc);
|
||||
}
|
||||
|
||||
// ── 9. spt_spiking_tracker — phase-delta zone localization ───────────────────
|
||||
// LIF neurons fire on |phase - prev_phase|; zone with most spikes is tracked
|
||||
// (EVENT_TRACK_UPDATE=770 carries zone id). Plant motion in a KNOWN zone.
|
||||
|
||||
#[test]
|
||||
fn spt_spiking_tracker_zone_localization() {
|
||||
use wifi_densepose_wasm_edge::spt_spiking_tracker::SpikingTracker;
|
||||
|
||||
fn track_zone(target_zone: usize) -> i32 {
|
||||
let mut det = SpikingTracker::new();
|
||||
let n_sc = 32usize;
|
||||
let per = n_sc / 4; // 4 zones of 8 subcarriers
|
||||
let mut prev = [0.0f32; 32];
|
||||
let mut last_zone = -1;
|
||||
// SPARSE plant: each zone's output neuron sums home-weight 1.0 + cross
|
||||
// 0.25. Firing all 8 inputs (8*0.25=2.0) overdrives EVERY zone, so the
|
||||
// tracker collapses to zone 0. Firing only 2 inputs in the target zone
|
||||
// gives potential 2.0 at home (fires) but 0.5 cross (silent) -> only the
|
||||
// target zone fires. This is the genuinely-constructible localization.
|
||||
let base = target_zone * per;
|
||||
for t in 0..60 {
|
||||
let mut phases = [0.0f32; 32];
|
||||
// 2 subcarriers in the target zone get a large alternating delta.
|
||||
for k in 0..2 {
|
||||
phases[base + k] = if t % 2 == 0 { 0.0 } else { 3.0 };
|
||||
}
|
||||
for &(id, v) in det.process_frame(&phases, &prev) {
|
||||
if id == 770 {
|
||||
last_zone = v as i32;
|
||||
}
|
||||
}
|
||||
prev.copy_from_slice(&phases);
|
||||
}
|
||||
last_zone
|
||||
}
|
||||
|
||||
let mut correct = 0u32;
|
||||
for z in 0..4usize {
|
||||
let got = track_zone(z);
|
||||
if got == z as i32 {
|
||||
correct += 1;
|
||||
}
|
||||
println!(" spiking_tracker: planted zone {} -> tracked zone {}", z, got);
|
||||
}
|
||||
let acc = correct as f32 / 4.0;
|
||||
println!(
|
||||
"MEASURED-on-synthetic | {:<34} | zone-localization accuracy = {}/4 = {:.3}",
|
||||
"spt_spiking_tracker", correct, acc
|
||||
);
|
||||
assert!(acc >= 0.75, "must track the planted motion zone in >=75% of cases; got {}", acc);
|
||||
}
|
||||
|
||||
// ── 10. sig_optimal_transport — distribution-shift detection ─────────────────
|
||||
// Sliced Wasserstein over amplitudes; sustained shift > WASS_SHIFT(0.25) for
|
||||
// SHIFT_DEB(3) -> EVENT_DISTRIBUTION_SHIFT(726). Plant a large vs no shift.
|
||||
|
||||
#[test]
|
||||
fn sig_optimal_transport_distribution_shift() {
|
||||
use wifi_densepose_wasm_edge::sig_optimal_transport::OptimalTransportDetector;
|
||||
|
||||
fn run(shift: bool) -> bool {
|
||||
let mut det = OptimalTransportDetector::new();
|
||||
let n_sc = 32usize;
|
||||
// Establish a reference distribution.
|
||||
let base: Vec<f32> = (0..n_sc).map(|i| i as f32 * 0.1).collect();
|
||||
for _ in 0..10 {
|
||||
let mut a = base.clone();
|
||||
det.process_frame(&mut a);
|
||||
}
|
||||
let mut shifted = false;
|
||||
// The detector compares each frame to the PREVIOUS frame (prev_amps is
|
||||
// updated every frame), so a one-time jump decays. To exceed WASS_SHIFT
|
||||
// (0.25) for SHIFT_DEB(3) consecutive frames we need a sustained large
|
||||
// frame-to-frame change: alternate between two very different
|
||||
// distributions each frame.
|
||||
for t in 0..15 {
|
||||
let mut a: Vec<f32> = if shift {
|
||||
if t % 2 == 0 {
|
||||
base.clone()
|
||||
} else {
|
||||
base.iter().map(|x| 10.0 - x).collect() // reversed + offset
|
||||
}
|
||||
} else {
|
||||
base.clone()
|
||||
};
|
||||
for &(id, _) in det.process_frame(&mut a) {
|
||||
if id == 726 {
|
||||
shifted = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
shifted
|
||||
}
|
||||
|
||||
let mut c = Confusion::default();
|
||||
c.observe(run(true), true);
|
||||
c.observe(run(false), false);
|
||||
c.report("sig_optimal_transport (distribution shift)");
|
||||
assert!(run(true), "large amplitude-distribution shift must be detected");
|
||||
assert!(!run(false), "stationary distribution must not flag a shift");
|
||||
}
|
||||
|
||||
// ── 11. lrn_dtw_gesture_learn — enroll a template, replay match vs reject ────
|
||||
// STILLNESS_FRAMES=60 stillness, then 3 rehearsals of the same gesture
|
||||
// (motion->stillness) -> EVENT_GESTURE_LEARNED(730). Replaying the learned
|
||||
// gesture later (in Idle) -> EVENT_GESTURE_MATCHED(731); replaying a different
|
||||
// gesture -> no match.
|
||||
|
||||
#[test]
|
||||
fn lrn_dtw_gesture_learn_enroll_and_match() {
|
||||
use wifi_densepose_wasm_edge::lrn_dtw_gesture_learn::GestureLearner;
|
||||
|
||||
// A gesture is a phase trajectory across frames; motion_energy gates the
|
||||
// enroll state machine (still < 0.05, moving >= 0.05).
|
||||
fn gesture_frame(kind: u8, step: usize) -> ([f32; 32], f32) {
|
||||
let mut phases = [0.0f32; 32];
|
||||
let s = step as f32;
|
||||
for i in 0..32 {
|
||||
phases[i] = match kind {
|
||||
// distinct trajectories
|
||||
0 => (s * 0.4 + i as f32 * 0.1).sin(),
|
||||
_ => (s * 0.9 + i as f32 * 0.05).cos() * 1.5,
|
||||
};
|
||||
}
|
||||
(phases, 0.5) // moving
|
||||
}
|
||||
|
||||
let mut det = GestureLearner::new();
|
||||
let still = ([0.0f32; 32], 0.0f32);
|
||||
|
||||
// helper to feed N still frames
|
||||
let feed_still = |det: &mut GestureLearner, n: usize| {
|
||||
for _ in 0..n {
|
||||
det.process_frame(&still.0, still.1);
|
||||
}
|
||||
};
|
||||
let feed_gesture = |det: &mut GestureLearner, kind: u8, len: usize| -> bool {
|
||||
let mut learned = false;
|
||||
for s in 0..len {
|
||||
let (ph, me) = gesture_frame(kind, s);
|
||||
for &(id, _) in det.process_frame(&ph, me) {
|
||||
if id == 730 {
|
||||
learned = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
learned
|
||||
};
|
||||
|
||||
// Enroll gesture kind 0: stillness, then 3 identical rehearsals (each
|
||||
// motion burst followed by stillness).
|
||||
feed_still(&mut det, 70);
|
||||
let mut any_learned = false;
|
||||
for _ in 0..3 {
|
||||
any_learned |= feed_gesture(&mut det, 0, 30);
|
||||
feed_still(&mut det, 70);
|
||||
}
|
||||
|
||||
// Replay the SAME gesture during Idle -> expect a match (731).
|
||||
let mut matched_same = false;
|
||||
for s in 0..30 {
|
||||
let (ph, me) = gesture_frame(0, s);
|
||||
for &(id, _) in det.process_frame(&ph, me) {
|
||||
if id == 731 {
|
||||
matched_same = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
feed_still(&mut det, 70);
|
||||
// Replay a DIFFERENT gesture -> ideally no match (731) to the learned one.
|
||||
let mut matched_diff = false;
|
||||
for s in 0..30 {
|
||||
let (ph, me) = gesture_frame(1, s);
|
||||
for &(id, _) in det.process_frame(&ph, me) {
|
||||
if id == 731 {
|
||||
matched_diff = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let tmpl_count = det.template_count();
|
||||
println!(
|
||||
"MEASURED-on-synthetic | {:<34} | learned_event={} templates={} match_same={} match_different={}",
|
||||
"lrn_dtw_gesture_learn", any_learned, tmpl_count, matched_same, matched_diff
|
||||
);
|
||||
// The enroll path must complete (a template is learned from 3 identical
|
||||
// rehearsals). Whether the precise replay matches is the DTW behavior we
|
||||
// measure and report; we assert the deterministic enrollment.
|
||||
assert!(
|
||||
any_learned || tmpl_count > 0,
|
||||
"3 identical rehearsals after stillness must enroll a template"
|
||||
);
|
||||
}
|
||||
|
||||
// ── 12. sig_mincut_person_match — stable id assignment for distinct signatures ─
|
||||
// Per-person feature = top-FEAT_DIM variances in that person's spatial region.
|
||||
// Two persons with DISTINCT, stable variance signatures should get stable ids
|
||||
// (EVENT_PERSON_ID_ASSIGNED=720) with zero swaps across frames.
|
||||
|
||||
#[test]
|
||||
fn sig_mincut_person_stable_ids() {
|
||||
use wifi_densepose_wasm_edge::sig_mincut_person_match::PersonMatcher;
|
||||
|
||||
let mut det = PersonMatcher::new();
|
||||
let n_sc = 32usize;
|
||||
let amplitudes = [1.0f32; 32];
|
||||
let mut swaps = 0u32;
|
||||
let mut assigned = false;
|
||||
|
||||
// 40 frames, 2 persons: person 0 region (0..16) high-variance signature,
|
||||
// person 1 region (16..32) low-variance signature, both stable.
|
||||
for _ in 0..40 {
|
||||
let mut variances = [0.0f32; 32];
|
||||
for i in 0..n_sc {
|
||||
variances[i] = if i < 16 {
|
||||
2.0 + 0.05 * (i as f32).sin()
|
||||
} else {
|
||||
0.2 + 0.01 * (i as f32).cos()
|
||||
};
|
||||
}
|
||||
for &(id, _) in det.process_frame(&litudes, &variances, 2) {
|
||||
if id == 720 {
|
||||
assigned = true;
|
||||
}
|
||||
if id == 721 {
|
||||
swaps += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
println!(
|
||||
"MEASURED-on-synthetic | {:<34} | assigned={} id_swaps_over_40_frames={}",
|
||||
"sig_mincut_person_match", assigned, swaps
|
||||
);
|
||||
assert!(assigned, "distinct stable signatures must assign person ids");
|
||||
assert!(swaps == 0, "stable distinct signatures must not swap ids; got {} swaps", swaps);
|
||||
}
|
||||
Reference in New Issue
Block a user