Compare commits

..

12 Commits

Author SHA1 Message Date
rUv 74ecce3218 Merge pull request #1048 from ruvnet/fix/issues-1031-894-fusion-guard-model-load
fix: multistatic fusion guard for real TDM (#1031) + load published HF model via auto-detect/convert (#894)
2026-06-13 12:23:06 -04:00
ruv fd1430e46f test(engine): update contradiction_demotes_privacy for #1031 guard thresholds
The streaming-engine privacy-demotion test fed a 2 ms timestamp spread, which
demoted under the old 1 ms soft guard. #1031 raised the default soft guard to
20 ms (to accommodate the real TDM slot offset), so 2 ms now fuses cleanly with
no demotion. Bump the test spread to 25 ms (above the 20 ms soft guard, within
the 60 ms hard guard) so it still proves the ADR-137 -> ADR-141 demotion wiring.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-13 12:14:11 -04:00
ruv 107232c0be fix(sensing-server): load published HuggingFace model via RVF auto-detect+convert (#894)
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 the loader wants.

- 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); converts
  safetensors / model.rvf.jsonl -> RVF in-memory so the published full-precision
  model loads via --model.
- load_or_convert_model: native RVF first, else auto-detect+convert+load, else
  typed error. The silent heuristics fallback is now a loud, actionable message.
- --convert-model <in> --convert-out <out> CLI subcommand: one-command offline
  conversion, verifies the output loads before writing.
- #1031 env seam: WDP_TDM_SLOTS + WDP_TDM_SLOT_US derive the multistatic guard
  from a deployment TDM schedule (default 60 ms / 20 ms otherwise).

Honest scope: the converter wires the format/load path (safetensors F32 tensors
-> RVF weight segment, manifest written, Layer A/B/C succeed, weights
round-trip). It does NOT claim end-to-end pose accuracy -- the HF pose-decoder
architecture differs from this crate inference head (data-gated in #894).
Quantized .bin blobs are rejected with a typed error pointing at safetensors.

Tests (fail on the old opaque-magic path):
- model_format::safetensors_converts_and_loads
- model_format::hf_quant_classifies_to_actionable_error
- model_format::{jsonl_converts_and_loads, convert_to_rvf_dispatches_and_rejects_quant, ...}

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-13 12:05:05 -04:00
ruv 287885776b fix(signal): multistatic fusion guard too tight for real TDM hardware (#1031)
MultistaticConfig::default().guard_interval_us was 5_000 us (5 ms) with a
comment claiming "well within the 50 ms TDMA cycle". That is wrong: on an
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 us spread, so every real frame set exceeded the 5 ms guard and fuse()
silently fell back to per-node sum/dedup -- multistatic fusion never ran on
hardware.

- Raise default hard guard to 60 ms (full 50 ms TDMA cycle + 20% jitter
  headroom, derived from the slot model and documented in the field doc).
- Raise soft guard to 20 ms (just above the observed 18.2 ms 2-slot spread).
- Add MultistaticConfig::for_tdm_schedule(total_slots, slot_duration_us).
- Keep the honest per-node fallback for genuinely-mismatched frames.

Tests (fail on the old 5 ms default):
- fuse_real_tdm_spread_18194us_fuses_with_default_guard
- configurable_guard_rejects_too_large_spread
- for_tdm_schedule_invariants

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-13 12:04:47 -04:00
rUv 29e937ef52 Merge pull request #1044 from ruvnet/feat/edge-skills-synthetic-validation
feat(wasm-edge): unified EdgePipeline (all ~64 skills) + honest synthetic validation harness
2026-06-13 00:46:29 -04:00
ruv 41665d3de9 test(wasm-edge): synthetic-ground-truth validation harness for edge skills (ADR-160)
Plant signals with known answers, run the real detector, MEASURE detection
accuracy / precision / recall / rate-error — synthetic-ground-truth ONLY, not
field accuracy.

MEASURED-on-synthetic (12 tests, all green):
- vital_trend, exo_ghost_hunter(hidden breathing), occupancy, intrusion,
  exo_rain_detect, sig_optimal_transport: acc 1.000
- exo_time_crystal: 1.000 on periodic-vs-aperiodic (its sub-harmonic-vs-clean-
  period claim is NOT separable by autocorrelation — recorded honestly)
- sig_flash_attention: 8/8 peak localization; spt_spiking_tracker: 4/4 zone
  localization (sparse plant); sig_mincut_person_match: 0 id-swaps/40 frames
- lrn_dtw_gesture_learn: enrollment validated (replay-match reported, not asserted)
- sig_sparse_recovery: trigger validated; recovery accuracy reported NEGATIVE
  (-2.2% vs unrecovered baseline) — only its detect/trigger path is validated

DATA-GATED (listed, NOT faked): med_seizure/apnea/cardiac/respiratory/gait,
sec_weapon_detect, exo_emotion/happiness/dream_stage/gesture_language — each
needs real labelled clinical/affect/ASL/metal-object data; no number claimed.

benchmarks/edge-skills/RESULTS.md documents every result + reproduce command and
the explicit honesty boundary. ADR-160 deferred 'per-skill accuracy validation'
item updated to PARTIALLY MEASURED-on-synthetic + DATA-GATED.

Suite: 631 passed default / 669 medical, 0 failed.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-13 00:33:51 -04:00
ruv c6eacb7ff8 feat(wasm-edge): unified EdgePipeline wiring all ~64 edge skills (ADR-160)
Register every runtime skill module behind one uniform EdgeSkill trait and
run them all per CSI frame, aggregating (skill, event_id, value) triples.

- src/pipeline_all.rs: CsiFrameView (borrowed per-frame inputs), EdgeSkill
  trait, EdgePipeline (Box<dyn> dispatch over all skills), SkillEvent/SkillInfo
  introspection. Host-only (std); the wasm no_std build keeps the flagship
  lib.rs pipeline.
- src/skill_registry.rs: per-skill adapters (fwd_skill! direct-forward +
  synth_skill! for non-tuple returns). No skill DSP changed — only call wiring.
  gesture/coherence/adversarial synthesize one event; sig_sparse_recovery gets
  an owned mutable amplitude scratch; timer skills driven once per frame.
- med_* tier registered only under --features medical-experimental (preserves
  the ADR-160 safety gate). Default tier = 59 skills; +medical = 64.
- tests/pipeline_all.rs: 4 tests — all skills run without panic over 300
  deterministic synthetic frames, every emitted id is declared by its skill,
  introspection well-formed, default tier excludes medical (59) / medical adds 5 (64).
- examples/run_all_skills.rs: runnable demo printing per-skill event totals.

Full suite: 619 passed default (615 M6 baseline + 4 new), 0 failed.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-13 00:20:29 -04:00
rUv 153bc0595b Merge pull request #1043 from ruvnet/docs/adr-gap-remediation-1
docs(adr): Gap Register remediation — write phantom ADR-132/165, fix ADR-134 collision, correct statuses
2026-06-12 23:11:10 -04:00
ruv 8fd4ee917d docs(adr): mark ADR-164 Gap Register items resolved (G3, G5) + correct G2
Records the remediation done in this branch:
- G3 (homecore-recorder/migrate phantom ADRs) → RESOLVED: ADR-132 + ADR-165 written.
- G5 (10 streaming-engine Proposed-while-built) → RESOLVED: 136-145 flipped to
  "Accepted — partial", with the honest caveat that the notes describe building
  blocks built+tested, not live-path integration.
- G2 (missing Status headers) → corrected: ADR-134-CIR was mislabeled as missing
  (it has a Status row); the 2 genuine misses (147-benchmark-proof, 052-ddd) are
  both inside owner-gated duplicate-number collisions, so left untouched. Early
  ADRs using "| Status |" vs "| **Status** |" are different-format-but-present.
  Net: 0 status headers added.
- Updated Coverage-Gaps bullets for recorder/migrate.

Renumbering/dedup of the 6 collisions left owner-gated, as instructed.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-12 23:01:10 -04:00
ruv 5c5112db0e docs(adr): correct streaming-engine statuses 136-145 Proposed→Accepted — ADR-164 G5
All 10 streaming-engine ADRs (136-145) carried Status: Proposed while each has a
concrete commit-pinned "Built -- tested building block" Implementation-Status note
(136: 11f89727f; 137: 4fa3847ac; 138: fc7674bde; 139: 521a012d8; 140: 169a355bd;
141: 7d88eb84c; 142: 1f8e180d6; 143: 2d4f3dea5; 144: b10bc2e9a; 145: 0f336b7d3),
each with a test count.

Flipped each to "Accepted — partial (built + tested building block; integration
glue pending — see Implementation Status, commit <hash>)". Honest "partial", not
full Accepted: the notes themselves state the blocks are tested+compiling but
"mostly not yet on the live 20 Hz path". 143 (v2 dataset-gated) and 144 (no UWB
radio in fleet) carry their specific residual gates inline.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-12 23:00:54 -04:00
ruv e3696da8d8 docs(adr): write ADR-165 (HOMECORE-MIGRATE), repoint migrate 134→165 — ADR-164 G3
homecore-migrate cited "ADR-134 (HOMECORE-MIGRATE)", but on-disk ADR-134 is
"First-Class CIR Support" — a different decision. The migrate crate was governed
by a phantom identity (ADR-164 Gap G3).

- New ADR-165-homecore-migrate-from-home-assistant.md (next free number),
  reverse-documented from the shipped P1 scaffold: HA .storage reader, versioned
  format gate (unknown minor_version = hard error), per-artifact parsers, inspect
  CLI, structured errors. Status: Accepted — P1 scaffold (full conversion P2).
  Trust-boundary rationale for the untrusted .storage import is the centerpiece.
- Repointed every ADR-134 governing reference in v2/crates/homecore-migrate/
  (Cargo.toml, README.md, src/lib.rs, src/config_entries.rs,
  src/storage_format/mod.rs) → ADR-165. Left the ADR-132 (recorder-feature)
  refs intact. Explanatory renumber notes retained.
- On-disk ADR-134 (CIR) untouched. ADR-126 series-map registry row owner-gated.

Docs/comments only — cargo build -p homecore-migrate --no-default-features
still compiles.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-12 23:00:33 -04:00
ruv 9457d441b2 docs(adr): write missing ADR-132 (HOMECORE-RECORDER) — resolves ADR-164 G3
homecore-recorder cites "ADR-132" in Cargo.toml/README/lib.rs/schema.rs/
semantic.rs, but no ADR-132 file existed — the durable-state backbone was
ungoverned (ADR-164 Gap G3 / Coverage-Gaps Lens A).

Reverse-documented from the shipped, tested crate (not invented): SQLite
HA-compatible recorder schema v48 (P1, 14 tests), ruvector HNSW semantic
index (P2, feature-gated, 20 tests), hash-embedding honesty note, P3 real
embeddings planned. Status: Accepted (shipped). Filename matches the link
the crate README already pointed at. Documented retroactively; honest about
hash-embedding limits and unbenchmarked latency targets.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-12 23:00:15 -04:00
32 changed files with 3214 additions and 43 deletions
+4
View File
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Fixed
- **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).
+132
View File
@@ -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 515) 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.)
@@ -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` |
@@ -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`
+5 -5
View File
@@ -51,10 +51,10 @@ Severity: CRITICAL (corpus integrity / tooling-breaking / life-safety / security
| ID | Gap | Severity | Affected ADRs | Recommended action |
|----|-----|----------|---------------|--------------------|
| G1 | 6 duplicate ADR numbers (two ADRs answer to one number; breaks index/`/adr` tooling) | CRITICAL | 050×2, 052×2, 147×3, 148×2, 149×2, 134 (identity split) | renumber 2-of-3 at 147, 1 each at 050/148/149; demote 052-ddd to appendix; resolve 134 identity |
| G2 | 3 files with no Status header (cannot triage) | CRITICAL | 147-benchmark-proof, 052-ddd-appendix, 134-CIR | add canonical `## Status`; relocate 147-proof to `benchmarks/`; label 052-ddd as appendix |
| G3 | Shipped crates cite a non-existent or wrong-identity governing ADR | CRITICAL | homecore-recorder→"ADR-132" (no file); homecore-migrate→"ADR-134" (file is CIR) | write-missing-ADR (HOMECORE-RECORDER, HOMECORE-MIGRATE) |
| 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 | 147-benchmark-proof, 052-ddd-appendix, ~~134-CIR~~ | add canonical `## Status`; relocate 147-proof to `benchmarks/`; label 052-ddd as appendix**NOTE: ADR-134-CIR DOES have a Status (`\| Status \| Proposed \|` in its header table) — mislabeled here. The two real misses (147-benchmark-proof, 052-ddd) are both inside owner-gated duplicate-number collisions (147×3, 052×2), 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 | HIGH | 136145 | mark-stale → "Accepted — partial (integration glue pending)" (one batch) |
| G5 | ~~10 streaming-engine ADRs marked `Proposed` while §Impl-Status reports Built + commits + tests~~ **RESOLVED in `docs/adr-gap-remediation-1`** | HIGH | 136145 | ~~mark-stale → "Accepted — partial (integration glue pending)" (one batch)~~ DONE: all 10 (136145) 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, 154157, 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" |
@@ -91,8 +91,8 @@ The four CRITICAL items are the corpus's load-bearing AI-slop admissions — eac
## 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.
- **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).
- ~~**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.
@@ -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/).
+3 -2
View File
@@ -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]]
+3 -3
View File
@@ -6,7 +6,7 @@ Migration tooling for importing Home Assistant configuration, entities, and secr
![License](https://img.shields.io/badge/license-MIT-blue.svg)
![MSRV: 1.89+](https://img.shields.io/badge/MSRV-1.89%2B-purple.svg)
[![Tests](https://img.shields.io/badge/tests-19%20passing-brightgreen.svg)](https://github.com/ruvnet/RuView)
[![ADR-134](https://img.shields.io/badge/ADR-134-orange.svg)](../../docs/adr/ADR-134-homecore-migration-from-python-ha.md)
[![ADR-165](https://img.shields.io/badge/ADR-165-orange.svg)](../../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
+4 -3
View File
@@ -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).
+3 -2
View File
@@ -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");
@@ -17,6 +17,7 @@ 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;
@@ -144,6 +145,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,
@@ -6221,6 +6232,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 +6269,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 +6314,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 +6485,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 +7124,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 +7136,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 +7315,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);
@@ -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 { .. })
));
}
}
@@ -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: &amps,
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, &amps, &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, &amps, &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, &amps, &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, &amps);
}
// 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, &amps) {
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, &amps);
}
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, &amps) {
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, &amps, 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, &amps, 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, &amps) {
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(&amplitudes, &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);
}