Compare commits

..

25 Commits

Author SHA1 Message Date
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
rUv 626b4b2e97 Merge pull request #1042 from ruvnet/docs/adr-164-gap-analysis
docs(adr): ADR-164 — ADR corpus gap analysis & remediation backlog (162 ADRs)
2026-06-12 22:47:21 -04:00
ruv 260fceefe9 docs(adr): ADR-164 corpus gap analysis + research notes (162 ADRs)
Parallel gap analysis of all 162 ADRs (14-agent workflow): status distribution,
prioritized Gap Register, supersession integrity, contradictions/retractions
(anti-slop centerpiece), coverage gaps, and the honestly-gated backlog.

Key findings: 6 duplicate ADR numbers + 3 missing Status headers (breaks the
index); shipped crates citing phantom governing ADRs (homecore-recorder->ADR-132
nonexistent, homecore-migrate->ADR-134 mis-identified); streaming-engine ADRs
136-145 marked Proposed but actually Built; open ADR-080 sensing-server security
findings never closed; ~64 proposed-only ADRs; pre-ADR-155 accuracy claims are
CLAIMED not MEASURED. Detail in docs/adr/gap-analysis/{census,lens-findings}.md.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-12 22:40:32 -04:00
rUv e063de5970 Merge pull request #1039 from ruvnet/release/patch-1009-1004
release: patch-bump signal/sensing-server/cli for #1009+#1004 fixes (+ first-publish calibration)
2026-06-12 17:09:29 -04:00
ruv 53b327e649 release: bump signal 0.3.4 / sensing-server 0.3.3 / cli 0.3.1 (fixes #1009, #1004)
HE20 calibration baseline fix (signal), sensing-server --source auto simulate-latch
fix (sensing-server), HE20 calibrate parser/asserts (cli). See PR #1038.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-12 16:55:27 -04:00
rUv ad3908bd9e Merge pull request #1038 from ruvnet/fix/issues-1009-1004-real-csi-ingest
fix: real CSI-ingest bugs — HE20 baseline corruption (#1009) + sensing-server simulate-latch (#1004)
2026-06-12 16:47:25 -04:00
ruv a27ee6f6cd fix(csi-ingest): real HE20 CSI no longer dropped or replaced with simulated data (#1009, #1004)
Two ingest bugs caused real ESP32-C6 HE20 CSI to be silently discarded or
never received — the "real data silently lost" failure class. Each fix is
pinned by a test that fails on the old code.

#1009 §1b — HE20 baseline recorder trimmed 256->242 bins by sequential index.
ESP-IDF v5.5.2 delivers all 256 FFT bins for an HE20 frame, but
CalibrationConfig::he20() carried num_active: 242, so the recorder (no HE20
tone map — extract_first_stream takes the first num_active columns
sequentially) kept bins 0..242 = the lower guard band + DC, NOT the 242 active
tones, silently corrupting the empty-room baseline. Now num_active: 256 records
every delivered bin, aligned 1:1 with the live deviation() path. The exact-242
tone map stays only in cir.rs (HE20_ACTIVE), where the Phi sensing matrix needs
it. HE20 synthetic/bench fixtures updated to feed 256-bin frames.

#1009 §1a/§1c — u8->u16 n_subcarriers truncation, regression-pinned.
The ADR-018 wire format carries n_subcarriers as u16 LE at bytes 6-7; a 256-bin
HE20 frame (byte6=0x00) read as one byte decodes to 0 subcarriers -> every
frame skipped. The CLI parser and the sensing-server parse_esp32_frame were
already corrected to u16 under #1005/ADR-110; added regression tests that fail
on the old single-byte read so the truncation cannot silently return.

#1004 — --source auto latched on simulate forever, never binding UDP :5005.
A one-shot boot probe resolved the source once; with no CSI flowing at boot
(the normal firmware/server startup race) it served simulated poses for the
whole process and ignored real CSI arriving seconds later (the prior #937 fix
hard-exited instead — equally wrong). New plan_source() state machine: in auto
mode ALWAYS bind the UDP receiver and serve simulated only until the first real
frame, then udp_receiver_task promotes source -> esp32 (mirroring the existing
esp32 -> esp32:offline reversion). simulated_data_task self-suspends once
promoted. Explicit --source simulated stays a hard, UDP-free offline override.

Validation: 3-crate tests 1118 passed / 0 failed; workspace 3166 passed /
0 failed; Python proof VERDICT: PASS (bit-exact, unaffected). cir.rs untouched.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-12 16:37:55 -04:00
rUv 3d7530f08d Merge pull request #1033 from ruvnet/feat/v2-zero-warnings-hygiene
chore: zero-warnings hygiene — clear 13 build warnings across v2/crates
2026-06-12 09:09:18 -04:00
ruv d4170ad159 fix: revert config-dependent cargo-fix changes (kept only always-safe edits)
cargo fix ran under --no-default-features and removed an import/mut that are
'unused' ONLY in the minimal build but genuinely USED in CI's full build
(error[E0596]: cannot borrow result as mutable in desktop discovery.rs). Those
are false-positive warnings in the minimal config. Reverted bridge.rs/
commissioning.rs/discovery.rs to origin/main; kept the always-safe edits
(dead-code #[allow] notes + ClockGateDecision doc fields + camera macOS-only
allow). Full-features build of all four crates: Finished, 0 errors.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-12 08:56:26 -04:00
ruv 0d6c20c278 chore(v2): zero-warnings hygiene — clear 13 build warnings across 4 crates
Removed unused Matter imports (sensing-server bridge/commissioning), dropped
needless mut (bridge, desktop discovery), documented ClockGateDecision variant
fields (ruvector coherence), and marked deferred-P2/platform-only helpers
#[allow(dead_code)] with honest notes (entity_on_matter/next_endpoint =
Matter-publisher API deferred per ADR-159 §A5; decode_jpeg_to_rgb = macOS-only).
Behavior-neutral; touched-crate tests green. Remaining 1 warning is a benign
Windows .pdb filename collision inherent to the Tauri lib+bin desktop crate
(renaming the bin would break Tauri bundling — won't-fix for a cosmetic warning).

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-12 08:44:42 -04:00
rUv 3fb40a9deb Merge pull request #1030 from ruvnet/feat/v2-beyond-sota-sweep-m9
Beyond-SOTA sweep M9 (ADR-163): edge-latency measurement debt → MEASURED-on-host benches
2026-06-12 08:14:57 -04:00
ruv 1a17cc5b06 docs(ADR-163): edge-latency RESULTS + PROOF/prove.sh wiring (T3)
Adds benchmarks/edge-latency/RESULTS.md (wiflow-std RESULTS style: each
measured number with reproduce command, machine, MEASURED-on-host grade,
and the honest host-vs-ESP32 / steady-state-vs-cold-start caveats) and
ADR-163 (HEADLINE: CLAIMED latency budgets -> MEASURED-on-host, closing
M5/M6 measurement debt; ESP32-on-hardware still pending).

- ADR-160 deferred 'criterion benches for process_frame budget claims'
  line updated to DONE (host) with the ESP32-pending note.
- PROOF.md performance table gains the two edge-latency reproduce rows;
  provenance ADR range extended to ADR-163.
- prove.sh gated section gains the edge-latency bench note (host proxy
  only; not asserted, never claims the ESP32 figure).

Benches/docs only; no crate republishes.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-12 08:02:07 -04:00
ruv 7c13ec6a00 bench(cogs): steady-state CPU infer latency benches (ADR-163 T2)
Criterion benches over InferenceEngine::infer for cog-person-count and
cog-pose-estimation, on Device::Cpu with the real shipped safetensors
weights (asserts candle backend so the stub is never silently benched),
over a fixed CSI window after a warm-up forward.

HOST-MEASURED steady-state medians (idle box): ~305us each. This is the
recurring per-frame cost and is explicitly NOT the pose manifest's
cold_start_ms_avg=5.4 (a different measurement, weight-load included, taken
on ruvultra/RTX 5080) -- the two are labelled and not conflated.

Closes the ADR-159/160 deferred cog inference-latency item. No production-
code behavior change.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-12 08:01:50 -04:00
ruv d3606d51a7 bench(wasm-edge): host process_frame latency benches (ADR-163 T1)
Criterion benches over the M6-audit-named heaviest hot paths:
exo_time_crystal 256x128 autocorrelation, exo_ghost_hunter periodicity,
sec_weapon_detect per-subcarrier Welford, med_seizure_detect clonic rhythm
(medical-experimental-gated). Drives each through the public process_frame
on a fixed synthetic CSI frame after warming the relevant buffers.

Crate is workspace-excluded: run from the crate dir with --features std.
Set lib bench=false so libtest does not intercept criterion CLI flags.

HOST-MEASURED medians (Intel Core Ultra 9 285H, native --release), NOT the
ESP32/WASM3 doc budget (that needs hardware): time_crystal 17.3us,
ghost_hunter 1.44us, weapon 0.42us, seizure 0.10us.

Closes the ADR-160 deferred 'criterion benches for process_frame budget
claims' item on host. No production-code behavior change.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-12 08:01:29 -04:00
rUv 48db9d37a6 Merge pull request #1026 from ruvnet/feat/v2-beyond-sota-sweep-m8
Beyond-SOTA sweep M8 (ADR-162): enforce plugin Ed25519 signatures + capability isolation + bounded RunModes
2026-06-12 02:04:24 -04:00
ruv e7b1b66f74 docs(adr): ADR-162 — plugin security + bounded RunModes; mark ADR-161 P4/P5/§A5 DONE
ADR-162 records the M8 work that makes ADR-161's honestly-deferred plugin
security claims TRUE: P4 (Ed25519 signature + SHA-256 integrity verification,
secure-default trust policy), P5 (capability/authority isolation on
hc_state_set), and §A5 (bounded Restart/Queued/max RunModes). Each fix MEASURED
with a failing-on-old test; threat model table (tampered module, untrusted
publisher, over-privileged write, run-mode exhaustion); cog-ha-matter Ed25519
reuse cited; remaining honest deferral (key provisioning/rotation, native
in-process plugins, HAP pairing).

ADR-161 deferred-backlog lines for P4/P5/RunModes struck through and marked
DONE → ADR-162; §B5 note points forward to the now-implemented P4 gate.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-12 01:47:30 -04:00
ruv 3292bd2c5d feat(homecore-automation): implement bounded RunModes Restart/Queued/max (ADR-162, completes ADR-161 §A5)
ADR-161 implemented RunMode::Single (AtomicBool re-entrancy guard) + Parallel
but honestly left Restart/Queued/max as "ACCEPTED-FUTURE / unbounded parallel" —
every non-Single mode spawned an unbounded task. This makes them real.

New `runmode` module — per-automation RunState owns the machinery:
- Restart: aborts the in-flight action task (tokio::task::AbortHandle) and
  starts a fresh one.
- Queued: serializes runs in arrival order via a per-automation async Mutex —
  sequential, never concurrent, nothing dropped.
- max: N: caps concurrency at N via a per-automation Semaphore; triggers beyond
  N queue (await a permit) rather than running concurrently (HA bounded
  semantics). Documented in the module table.
- Single/IgnoreFirst/Parallel preserved.

engine.rs now holds a RunState per registration and calls run_state.dispatch()
at all three trigger sites (event loop, timer, fire_time_for_test); the old
spawn_run is removed. engine.rs trimmed to 433 lines.

Tests (tests/engine_behaviors.rs) — verified to FAIL on the old unbounded-
parallel dispatch (simulated and confirmed each panics), pass on the new:
- restart_mode_cancels_prior_run (old: both runs complete → 2; new: 1)
- queued_mode_runs_sequentially_not_concurrently (old: max concurrency 3; new:
  all 3 run, max concurrency 1)
- max_two_caps_concurrency_at_two (old: 4 concurrent; new: all 4 run, max 2)

homecore-automation --no-default-features: 45 passed (lib 37, engine_behaviors
8), 0 failed.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-12 01:40:23 -04:00
ruv 0ca903b497 feat(homecore-plugins): enforce plugin signature + capability isolation (ADR-162 P4/P5)
ADR-161 honestly relabelled the manifest's wasm_module_hash / wasm_module_sig /
publisher_key as "(P4 — not yet enforced)" and the homecore_permissions claims
as deferred P5 authority isolation. This makes both real and tested.

P4 (signature/integrity verification, SECURITY):
- New `verify` module: SHA-256 module-hash check + Ed25519 signature
  verification over the digest against publisher_key, with a PluginPolicy
  trust allowlist and an explicit AllowUnsigned dev escape hatch (loud warn).
  Secure default rejects unsigned / unknown-publisher / tampered modules.
- Reuses the in-repo cog-ha-matter::witness_signing Ed25519 pattern; sha2 is a
  workspace dep, ed25519-dalek/hex/base64 already in the lock — no new external
  dep tree (only new edges in homecore-plugins).
- WasmtimeRuntime::load_plugin verifies before instantiation; legacy load_wasm
  retained for trusted/test modules.

P5 (authority/capability isolation, SECURITY):
- New `permissions` module: PermissionSet distilled from homecore_permissions
  (state:write:<glob> or bare entity glob). hc_state_set now consults it and
  returns a typed -3 to the guest on an undeclared write (no host panic).

Tests (fail on old code, which had no load_plugin/verify and an unchecked
hc_state_set): tampered module rejected; valid sig from trusted key loads;
valid sig from untrusted key rejected; unsigned rejected by default and loads
only under AllowUnsigned; light.* plugin writes light.kitchen but is denied
lock.front_door; no-permission plugin can write nothing. Real deterministic
keypair signs real bytes.

Manifest doc updated: P4/P5 now ENFORCED (was "not yet enforced").

homecore-plugins --features wasmtime: 32 passed (lib 23, integration 9), 0 failed.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-12 01:33:52 -04:00
66 changed files with 6200 additions and 170 deletions
+4
View File
@@ -42,6 +42,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Live trust path: sensing-server routes real frames through the governed `StreamingEngine` (parallel governed path with partial output gating).** Previously the live server ran only the *bare* `MultistaticFuser` (fused amplitudes, no trust control plane), while the privacy/provenance/witness engine (ADR-135..146) ran only on synthetic in-test frames — the gap called out in ADR-136 §8 and the beyond-SOTA system review. New `engine_bridge` module drives `StreamingEngine::process_cycle` from the server's live `NodeState` map (reusing the existing `NodeState → MultiBandCsiFrame` conversion), lazily wiring each node as a WorldGraph sensor and bounding belief growth via the retention cap; every *governed belief* carries evidence + model + calibration + privacy decision and a deterministic witness. **Honest scope:** the engine runs alongside (not instead of) the bare fusion path that feeds the live `SensingUpdate`. What its decision gates on the wire today: a cycle emitted at class `Restricted` (base mode or contradiction/mesh-risk demotion) suppresses the per-node raw amplitude vectors from the live publish — the same field mapping `wifi-densepose-bfld`'s privacy gate applies at `Restricted`; gating the remaining derived outputs (person count, classification, signal field) is tracked as a follow-up. Trust state is no longer write-only: the latest witness, effective privacy class, demotion flag, recalibration recommendation, and an engine-error counter are readable on `GET /api/v1/status`, and engine errors are counted + rate-limit logged instead of silently swallowed (`EngineBridge::observe_cycle`). Adds `wifi-densepose-engine/-worldgraph/-bfld/-geo` deps. Bridge tests cover witnessed belief with provenance, determinism, idempotent node registration, retention bound, privacy-mode propagation, trust-state recording, the error-counter path, and Restricted-class raw-output suppression.
### Fixed
- **Real HE20 CSI no longer silently dropped or replaced with simulated data (fixes #1009, #1004).** Two ingest bugs caused real ESP32-C6 HE20 frames to be discarded or never received — the exact "real data silently lost" failure class the project fights. Each fix is pinned by a test that fails on the old code.
- **#1009 §1b — HE20 baseline recorder trimmed 256 → 242 bins by sequential index (`wifi-densepose-signal/src/ruvsense/calibration.rs`).** ESP-IDF v5.5.2 delivers all 256 FFT bins for an HE20 frame; `CalibrationConfig::he20()` carried `num_active: 242`, so the recorder (which has no HE20 tone map — `extract_first_stream` takes the first `num_active` columns *sequentially*) kept bins 0..242 of the 256-bin grid. Those are the lower guard band + DC, **not** the 242 active tones, silently corrupting the empty-room baseline. Now `num_active: 256` records every delivered bin, staying aligned 1:1 with the live `deviation()` path. The exact-242 tone map deliberately stays only in `cir.rs` (`HE20_ACTIVE`), where the Φ sensing matrix genuinely needs it. Test `he20_records_all_256_bins_not_trimmed_to_242` asserts the finalized baseline covers all 256 bins (was 242). HE20 synthetic/bench fixtures updated to feed 256-bin frames (the real wire format).
- **#1009 §1a/§1c — already-fixed u8→u16 `n_subcarriers` truncation, now regression-pinned.** The ADR-018 wire format carries `n_subcarriers` as u16 LE at bytes 67. A 256-bin HE20 frame (byte6=0x00, byte7=0x01) read as a single byte decodes to **0 subcarriers** → every frame skipped (invisible until HE20: ESP32-S3's ≤192 bins fit in one byte). The CLI parser (`wifi-densepose-cli/calibrate.rs`) and the sensing-server template parser (`wifi-densepose-sensing-server` `parse_esp32_frame`) were already corrected to u16 under #1005/ADR-110; added regression tests (`parse_esp32_frame_he20_256_bins_not_truncated`, CLI `test_parse_csi_packet_he_su_256_bins`) that fail on the old single-byte read so the truncation cannot silently return.
- **#1004`--source auto` latched on `simulate` forever, never binding UDP :5005 (`wifi-densepose-sensing-server/src/main.rs`).** A one-shot boot probe resolved the source once; with no CSI flowing at boot (the normal firmware/server startup race) it served simulated poses for the whole process and ignored real CSI that arrived seconds later (the prior #937 fix hard-exited instead — equally wrong, the server could never pick up late-starting CSI). New `plan_source()` state machine: in `auto` mode **always bind the UDP receiver** and serve simulated data only until the first real frame, at which point `udp_receiver_task` promotes `source``esp32` (mirroring the existing `esp32 → esp32:offline` reversion in `effective_source()`); `simulated_data_task` self-suspends once promoted so it never clobbers live CSI. Explicit `--source simulated` stays a hard, UDP-free override for offline demos. 6 unit tests pin the resolution/promotion machine (`auto_with_no_boot_source_still_binds_udp_and_simulates`, etc.); the auto-binds-UDP assertion fails on the old behavior.
- **`wifi-densepose-mat` standalone `--no-default-features` build (101 errors → 0).** `pub mod api` was unconditional while its only dependency, serde, is optional behind the `api` feature — so any build without default features failed with unresolved serde imports (masked in `--workspace` runs by feature unification). The `api` module and its `create_router`/`AppState` re-export are now `#[cfg(feature = "api")]`-gated (with docsrs annotations). All feature combos compile: bare `--no-default-features`, `--no-default-features --features api`, and full default (177 tests pass).
- **WorldGraph no longer grows unboundedly under the live loop.** `StreamingEngine::process_cycle` appended one `SemanticState` belief per cycle with no eviction — ~1.7M nodes/day at 20 Hz (identified in `docs/research/ruview-beyond-sota/04-optimization-roadmap.md`). Added `WorldGraph::prune_semantic_states(max)` — deterministic eviction of the oldest beliefs by `(valid_from_unix_ms, id)`, structural nodes (rooms/zones/sensors/anchors/tracks/events) never eligible — and wired it into the engine after each belief append (`StreamingEngine::DEFAULT_SEMANTIC_RETENTION` = 7,200 ≈ 6 min at 20 Hz; tunable via `set_semantic_retention`). The WorldGraph holds *current* beliefs; durable history is the recorder's job, so no audit data is lost. 3 new tests (bounded growth end-to-end, oldest-only eviction, deterministic tie-break).
- **ESP32 edge heart rate no longer stuck at ~45 BPM / dropping wildly — #987.** The on-device HR estimator (`edge_processing.c`, `0xC5110002`) reported ~45 BPM regardless of true heart rate (Apple-Watch ground truth 87 BPM read as ~45) and swung frame-to-frame. Two root causes: (1) a hardcoded `sample_rate = 10.0f` that became wrong after #985's self-ping raised the CSI callback rate to a variable ~1319 Hz — BPM scales as `assumed/actual × true`, so 87 read ~45 and the reading swung as CSI yield fluctuated; (2) the zero-crossing estimator locked onto a breathing harmonic (a 0.25 Hz breathing fundamental puts its 3rd harmonic at ~0.74 Hz ≈ 44 BPM inside the HR band). Fix: measure the real sample rate from inter-frame timestamps (used for BPM conversion + biquad re-tuning on >15% drift); replace the HR zero-crossing with an autocorrelation estimator that rejects breathing harmonics (driven by a robust autocorr breathing period); median-13 smooth the output. Hardware A/B (fixed vs unmodified control board, both `edge_tier=2`): control pegged 4049 BPM; fixed reaches the true 8891 BPM (vs 87 GT) and holds a stable physiological value (spread 59→0 for a steady subject). Known limitation: heavy subject motion still degrades the estimate (motion gating is a follow-up).
+5 -2
View File
@@ -55,6 +55,8 @@ trained checkpoint) so you can reproduce them yourself.
| zero-copy ORT input ~1.48× (ADR-155) | **MEASURED** | `cd v2 && cargo bench -p wifi-densepose-nn --features onnx --bench onnx_bench` |
| pointcloud splats 9→2 passes ~1.24× (ADR-160 research) | **MEASURED** | `cd v2 && cargo bench -p wifi-densepose-pointcloud --bench splats_bench` |
| native wlanapi multi-BSSID scan 9.74 Hz (vs netsh ~2 Hz) | **MEASURED (Windows)** | `cd v2 && cargo test -p wifi-densepose-wifiscan -- --ignored measure_native_scan_rate` |
| wasm-edge `process_frame` hot-path latency (host proxy, ADR-163) | **MEASURED-on-host** (NOT the ESP32/WASM3 budget — needs hardware) | `cd v2/crates/wifi-densepose-wasm-edge && cargo bench --features std` |
| cog steady-state CPU infer latency ~305 µs (ADR-163; NOT the manifest cold-start) | **MEASURED-on-host** | `cd v2 && cargo bench -p cog-person-count -p cog-pose-estimation --no-default-features --bench infer_bench` |
## What we do NOT claim (the honest negatives — the strongest anti-slop signal)
@@ -68,8 +70,9 @@ trained checkpoint) so you can reproduce them yourself.
## Provenance
Every claim above traces to a committed ADR (`docs/adr/ADR-154``ADR-160`), a
test, a criterion bench, or `benchmarks/wiflow-std/RESULTS.md`. The history
Every claim above traces to a committed ADR (`docs/adr/ADR-154``ADR-163`), a
test, a criterion bench, `benchmarks/wiflow-std/RESULTS.md`, or
`benchmarks/edge-latency/RESULTS.md`. The history
includes published **retractions** (the 92.9% PCK retraction; the WiFlow-STD
shipped-checkpoint refutation; the NV-diamond BOM reality check) — a faker hides
failures; we commit them.
+137
View File
@@ -0,0 +1,137 @@
# Edge-Latency Benchmark Results — ADR-163
Converting **CLAIMED** edge latency budgets into **MEASURED-on-host** numbers,
closing the measurement debt flagged by Milestones 5/6 (ADR-159 / ADR-160).
Benches + docs only — **no production-code behavior changed**.
## The honest caveat, up front (read before citing any number)
Two distinct gaps separate every number below from the figure it is converting:
1. **Host ≠ ESP32.** The wasm-edge skill modules document budgets *"on ESP32-S3
WASM3"* (e.g. `exo_time_crystal`: "H (<10 ms)"). These benches run **native
x86_64 on a development laptop**, not the Xtensa/WASM3 target. A native host
median is an **upper bound on the algorithm's work**, not the ESP32 number.
WASM3 interpretation on a ~240 MHz Xtensa core is typically 12 orders of
magnitude slower than native `-O` host code, so a host median far under the
budget **does NOT prove the ESP32 meets it.** *The ESP32 figure is NOT
reproduced here — it needs hardware.*
2. **Bench ≠ the doc-claimed measurement.** For the cogs, the manifest cites a
**cold-start** number (`cold_start_ms_avg`, weight-load included); these
benches measure **steady-state** per-frame `infer` (warm, weights resident).
Different measurements; we report both, labelled.
Grades (per `benchmarks/wiflow-std/RESULTS.md` / ADR-152 vocabulary):
- **MEASURED-on-host** — reproduced in this repo on the machine below, exact
command recorded. NOT the ESP32 / NOT the cold-start figure.
- **CLAIMED (ESP32)** — the doc budget; UNMEASURED on hardware here.
## Machine
| | |
|---|---|
| Host | `ruvzen` (Windows 11, this dev box) |
| CPU | Intel Core Ultra 9 285H |
| Toolchain | `cargo 1.91.1`, `--release` (opt-level per crate profile) |
| Bench harness | criterion 0.5 (`time: [low **median** high]` reported below) |
| Date | 2026-06-12 |
Run-to-run spread on this box is non-trivial (criterion's low/high bracket the
median by a few %); the medians below are single-session captures with the smoke
settings `--warm-up-time 1 --measurement-time 2` (wasm-edge) / `3` (cogs). Re-run
for your own machine — the absolute numbers are host-specific.
---
## T1 — wasm-edge `process_frame` hot paths (ADR-160 deferred item → DONE host)
The crate is **excluded from the v2 workspace**; bench from the crate dir.
```bash
cd v2/crates/wifi-densepose-wasm-edge
cargo bench --features std -- --warm-up-time 1 --measurement-time 2
# med_seizure_detect is medical-experimental-gated:
cargo bench --features std,medical-experimental -- --warm-up-time 1 --measurement-time 2 med_seizure
```
| Hot path (M6-audit-named) | Bench id | Host median | Grade | Doc budget (CLAIMED, ESP32) |
|---|---|---|---|---|
| `exo_time_crystal` 256-pt × 128-lag autocorrelation (full buffer) | `exo_time_crystal::process_frame[autocorr_256x128]` | **17.3 µs** | MEASURED-on-host | "H (<10 ms) on ESP32-S3 WASM3" — **NOT reproduced here (needs hardware)** |
| `exo_ghost_hunter` empty-room periodicity + hidden-breathing | `exo_ghost_hunter::process_frame[empty_room_periodicity]` | **1.44 µs** | MEASURED-on-host | research/exotic; no firm ESP32 figure — host proxy only |
| `sec_weapon_detect` per-subcarrier Welford (MAX_SC=32) | `sec_weapon_detect::process_frame[per_sc_welford]` | **0.42 µs** (420 ns) | MEASURED-on-host | research-grade; calibration-gated — host proxy only |
| `med_seizure_detect` clonic-phase rhythm path (steady-state frame) | `med_seizure_detect::process_frame[clonic_rhythm]` | **0.10 µs** (105 ns) | MEASURED-on-host (feature-gated) | doc budget "S (<5 ms) on ESP32"; **NOT reproduced here** |
Reading these honestly:
- `exo_time_crystal` at **17.3 µs host** is the only one whose host cost is even
in the same *thousandths* of its 10 ms ESP32 budget — it does the most work
(~32K MACs/frame). 17.3 µs native says the algorithm is cheap; it says
**nothing** about whether WASM3-on-Xtensa lands under 10 ms. A naïve
host→ESP32 extrapolation (assume 100× interpreter+clock penalty) would put it
near ~1.7 ms, comfortably under — **but that is an extrapolation, not a
measurement**, and is recorded here only to show the host number is not
obviously in tension with the budget. ESP32 figure: **UNMEASURED**.
- `med_seizure_detect`'s 105 ns is the **steady-state** per-frame cost; the
expensive clonic autocorrelation only fires when the state machine is in the
clonic phase, so this is a lower-bound on the heavy path, not the worst case.
It is still a real, committed host datapoint.
- The pre-existing `tests/budget_compliance.rs` already asserts the L/S/H
wall-clock tiers (25 passing tests); these criterion benches add the
regression-grade, reproducible median that ADR-160 deferred.
---
## T2 — cog steady-state inference latency (ADR-159/160 deferred item → DONE)
Cog crates are normal workspace members; bench from `v2/`. Real weights
(`count_v1.safetensors` / `pose_v1.safetensors`) ship in-repo under each cog's
`cog/artifacts/`, so the bench measures the **real Candle CPU forward**, not the
stub (the bench `assert!`s `backend().starts_with("candle-")`).
```bash
cd v2
cargo bench -p cog-person-count --no-default-features --bench infer_bench -- --warm-up-time 1 --measurement-time 3
cargo bench -p cog-pose-estimation --no-default-features --bench infer_bench -- --warm-up-time 1 --measurement-time 3
```
| Cog | Bench id | Host median (steady-state infer, CPU) | Grade | Manifest cold-start (CLAIMED, different measurement + machine) |
|---|---|---|---|---|
| cog-person-count | `cog_person_count::infer[cpu_real_weights_steady_state]` | **305 µs** (idle box) | MEASURED-on-host | — (person-count manifest carries comparable provenance) |
| cog-pose-estimation | `cog_pose_estimation::infer[cpu_real_weights_steady_state]` | **305 µs** (idle box) | MEASURED-on-host | `cold_start_ms_avg: 5.4` (30 invocations, **ruvultra/RTX 5080 host**, candle 0.9 cpu) — **cold-start, NOT steady-state; NOT this machine** |
> Spread caveat (observed, honest): both medians above were captured with the box
> otherwise idle. A re-run of the validate-form command *while a second cargo job
> was loading the same cores* gave 385 µs (person-count) / 973 µs (pose) —
> the criterion low/high bracket widens to ~0.341.18 ms under contention. The
> 305 µs figures are the idle-box datapoints; the absolute number is host- and
> load-dependent (the ~10× pose swing is core contention, not a code change).
Reading these honestly:
- **Steady-state ≠ cold-start.** The pose manifest's `5.4 ms` folds in one-time
weight load / mmap / first-forward allocation. This bench warms the engine
first and times only the recurring per-frame forward, on a *different
machine*. The two numbers are not comparable and we do not claim this bench
reproduces the 5.4 ms manifest figure.
- Both cogs share the same conv encoder; person-count adds a count head +
confidence head, pose adds a 256-wide MLP head. The host steady-state cost is
dominated by the three dilated Conv1d layers (56→64→128→128) shared by both —
which is why both land at ~305 µs.
- **Empirical confirmation of the steady-state/cold-start gap:** pose
steady-state (305 µs host) is ~18× *under* the manifest's 5.4 ms cold-start.
Even accounting for the different machine, this is the expected shape — the
bulk of cold-start is one-time setup, not the forward pass — and it is exactly
why conflating the two would be dishonest.
---
## Status vs the deferred items
| Deferred item | Was | Now |
|---|---|---|
| ADR-160 "Criterion benches for `process_frame` budget claims" | ACCEPTED-FUTURE | **DONE (host)**; ESP32-on-hardware still **PENDING** (needs the wasm32 target + a flashed ESP32-S3) |
| ADR-159/160 cog inference latency (`cold_start_ms_avg` uncommitted-benched) | CLAIMED | **MEASURED-on-host (steady-state)**; cold-start-on-ruvultra remains the manifest's separate claim |
Nothing here changes runtime behavior — these are benches + this results file
only. No crate needs republishing.
+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,13 +178,42 @@ 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.
- **Criterion benches for `process_frame` budget claims****ACCEPTED-FUTURE**.
`tests/budget_compliance.rs` asserts L/S/H tier wall-clock budgets (25 tests,
passing), but a regression-grade criterion bench is not yet wired.
- **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`
periodicity, `sec_weapon_detect` per-subcarrier Welford, `med_seizure_detect`
clonic rhythm) and reports committed **host** medians
(`benchmarks/edge-latency/RESULTS.md`). `tests/budget_compliance.rs` continues
to assert the L/S/H tier wall-clock budgets (25 tests, passing). **ESP32-on-
hardware (Xtensa/WASM3) latency remains PENDING** — the host bench is an
upper-bound algorithm-cost proxy, NOT the ESP32 figure (needs hardware).
- **`wasm32-unknown-unknown` `static_mut_refs` confirmation** — **ACCEPTED-FUTURE**
(toolchain): the source pattern is eliminated; a CI job on the wasm target should
assert zero `static_mut_refs` once the target is added to the build image.
@@ -196,7 +196,8 @@ fields are **never read** for verification (only ever set to `None` in tests).
re-doc'd **"(P4 — not yet enforced, ADR-161/B5)"** — parsed and round-tripped,
but no integrity/signature check happens before a plugin runs. No verification
code was added (that is P4); the doc now matches the code.
**Grade: doc-honesty (no behavior change).**
**Grade: doc-honesty (no behavior change).** *(Superseded by ADR-162 §P4:
the hash/signature gate is now implemented and enforced.)*
## Negative Results (NO-ACTION positives — audited, found correct, cited not edited)
@@ -213,17 +214,23 @@ touched:
## Deferred Backlog (Nothing Dropped)
- **Plugin authority-isolation (P5)**`homecore_permissions` claims are parsed
but not enforced at the host-call boundary. **ACCEPTED-FUTURE.**
- **Plugin signature/hash verification (P4)** — implement the
- **Plugin authority-isolation (P5)**~~`homecore_permissions` claims are parsed
but not enforced at the host-call boundary.~~ **DONE — ADR-162 §P5.**
`hc_state_set` now consults a `PermissionSet` distilled from the manifest;
an undeclared write returns a typed `-3` to the guest.
- **Plugin signature/hash verification (P4)** — ~~implement the
`wasm_module_hash`/`wasm_module_sig`/`publisher_key` gate that B5 now honestly
says is absent. **ACCEPTED-FUTURE.**
says is absent.~~ **DONE — ADR-162 §P4.** `WasmtimeRuntime::load_plugin` now
SHA-256-checks the module, Ed25519-verifies the signature against
`publisher_key`, and enforces a `PluginPolicy` trust allowlist
(secure-default rejects unsigned/untrusted/tampered modules).
- **HAP real pairing (P2)** — SRP/HKDF pairing + encrypted sessions; current
bridge is an accessory-mapping surface. **ACCEPTED-FUTURE (honestly stubbed).**
- **`RunMode::Queued`/`Restart`/`max` ordering** — `Single`/`Parallel` are
- **`RunMode::Queued`/`Restart`/`max` ordering** — ~~`Single`/`Parallel` are
honored; bounded queueing, restart-kill, and `max` concurrency are not yet
wired (every non-Single mode is parallel). **ACCEPTED-FUTURE** — the
`engine.rs` doc states exactly this, no over-claim.
wired (every non-Single mode is parallel).~~ **DONE — ADR-162 §A5.** Restart
aborts the in-flight task, Queued serializes via a per-automation async mutex,
and `max: N` caps concurrency via a per-automation semaphore.
- **Automation YAML load-at-boot** — the engine starts empty; a YAML loader is
P-next. The bin log states "0 automations registered" honestly.
@@ -0,0 +1,186 @@
# ADR-162: HOMECORE Plugin Security (Signature + Capability Isolation) & Bounded Automation RunModes — Making ADR-161's Deferred Claims TRUE
- **Status**: accepted
- **Date**: 2026-06-12
- **Deciders**: ruv
- **Tags**: homecore, homecore-plugins, homecore-automation, plugin-security, wasm-signature-verification, ed25519, capability-isolation, runmode, prove-everything, soundness, honest-labeling
- **Amends**: ADR-161 (relabelled P4/P5 + §A5 deferrals → now enforced), ADR-128 (plugin manifest), ADR-129 (automation engine)
## Context
Beyond-SOTA sweep **Milestone 8**, scoped to `homecore-plugins` and
`homecore-automation` only, under the project's **prove-everything /
anti-"AI-slop"** directive.
ADR-161 (Milestone 7) did the honest thing with three plugin/automation
items it could not finish in that window: rather than fake them, it **relabelled
them as deferred** —
- **P4** (plugin signature verification): the manifest's `wasm_module_hash` /
`wasm_module_sig` / `publisher_key` were re-doc'd "(P4 — not yet enforced,
ADR-161/B5)" — parsed and round-tripped, but **never checked** before a
plugin runs.
- **P5** (plugin authority isolation): `homecore_permissions` claims were
parsed but **never consulted**; `hc_state_set` let any plugin write any
entity, including `lock.*` / `alarm_control_panel.*`.
- **§A5** (`RunMode`): `Single`/`Parallel` were honored; `Restart`/`Queued`/
`max: N` were honestly documented as still **unbounded-parallel**.
### Headline — the deferred security items are now ENFORCED + TESTED
M8 turns those honest deferrals into real, tested behavior. The plugin trust
boundary is now sound (a tampered module, an untrusted publisher, or an
unsigned module is rejected by the secure default), an over-privileged plugin
write is denied with a typed error, and the bounded run-modes actually bound.
**Every fix is pinned by a test that FAILS on the pre-M8 code** — each of the
three RunMode tests was additionally run against a simulated unbounded-parallel
dispatch and confirmed to panic.
The Ed25519 crypto reuses the in-repo `cog-ha-matter::witness_signing` pattern
(same `ed25519-dalek` 2.x API, same deterministic-test-key convention). SHA-256
matches the `sha256:` prefix the manifest already declared and the
`cog-ha-matter` cog manifest's `binary_sha256` hex convention. No new external
dependency tree was introduced — `ed25519-dalek` / `sha2` / `hex` / `base64`
were already in the workspace `Cargo.lock` (cog-ha-matter / bfld pull them in);
only new dependency *edges* were added to `homecore-plugins`.
Grading vocabulary (ADR-152 / ADR-158 / ADR-160 / ADR-161):
- **MEASURED** — reproduced in this worktree, command + failing-on-old test recorded.
- **ACCEPTED-FUTURE** — deliberately deferred, nothing dropped.
## Decision — Fixes Landed
### §P4 — Plugin signature & integrity verification (SECURITY) — MEASURED
`homecore-plugins/src/manifest.rs` declared `wasm_module_hash` /
`wasm_module_sig` / `publisher_key` but they were **never read** for
verification; the load path (`wasmtime_runtime.rs`) instantiated any `.wasm`
bytes handed to it.
**Real fix** (`src/verify.rs`, wired into `WasmtimeRuntime::load_plugin`):
before instantiation the runtime now —
1. computes the **SHA-256** of the actual `.wasm` bytes and rejects if it ≠ the
manifest's `wasm_module_hash` (`sha256:<hex>`) — tamper detection;
2. verifies the **Ed25519** `wasm_module_sig` (`ed25519:<base64>`, 64-byte raw)
over the 32-byte digest against `publisher_key` (`ed25519:<base64>`, 32-byte
raw) and rejects on failure;
3. enforces a configurable **trust policy**`PluginPolicy::trusted(&[keys])`
is an allowlist of publisher verifying keys; `PluginPolicy::AllowUnsigned`
is an explicit dev escape hatch that LOGS a loud `warn` on every load it
waves through. The **secure default rejects unsigned and unknown-publisher
modules.** `PluginPolicy::deny_all()` trusts no publisher.
A typed `PluginError::SignatureRejected` is returned (no host panic). The
legacy permission-free `load_wasm` is retained for first-party/trusted/test
modules; production loading goes through `load_plugin`.
**Failing-on-old tests** (`tests/integration.rs`, `--features wasmtime`) — all
drive `load_plugin`, which **did not exist** on the old code (so the gate is
genuinely new):
- `p4_tampered_module_is_rejected` — a byte-flipped `.wasm` → hash mismatch → rejected.
- `p4_valid_sig_from_trusted_key_loads` — a valid sig from an allowlisted key loads.
- `p4_valid_sig_from_untrusted_key_is_rejected` — a correctly-signed module from a key NOT on the allowlist is rejected.
- `p4_unsigned_module_rejected_by_default_loads_only_under_allow_unsigned` — unsigned rejected under `deny_all`, loads (with warn) only under `AllowUnsigned`.
- Unit (`src/verify.rs`): `valid_sig_from_trusted_key_passes`, `tampered_module_is_rejected`, `valid_sig_from_untrusted_key_is_rejected`, `forged_signature_is_rejected`, `unsigned_module_rejected_under_default_policy`.
A real deterministic keypair signs real `.wasm` bytes in the tests.
The manifest doc now reads **"(P4 — ENFORCED, ADR-162)"**. **Grade: MEASURED. Milestone headline.**
### §P5 — Plugin authority / capability isolation (SECURITY) — MEASURED
`wasmtime_runtime.rs::hc_state_set` applied any write a plugin requested,
ignoring the manifest's `homecore_permissions`.
**Real fix** (`src/permissions.rs` + `hc_state_set`): the manifest's
`homecore_permissions` (the `state:write:<glob>` form, or a bare entity glob
like `light.*`) are distilled into a `PermissionSet` installed in the plugin's
Wasmtime store. The `hc_state_set` host import consults
`permissions.may_write(entity_id)` before applying a write and returns a typed
`-3` (permission denied) to the guest on a violation — **the host is not
panicked.** Wasmtime already gives memory isolation; this adds **authority**
isolation. A plugin with **no** write grants can write nothing (secure default).
**Failing-on-old tests** (`tests/integration.rs`, `--features wasmtime`):
- `p5_declared_light_plugin_may_write_light_but_not_lock` — a `light.*` plugin writes `light.kitchen` (succeeds) but is REJECTED (`-3`, and the entity is not written) when it tries `lock.front_door`.
- `p5_plugin_with_no_permissions_can_write_nothing` — a plugin with empty `homecore_permissions` cannot write `light.kitchen`.
- Unit (`src/permissions.rs`): domain-glob, exact-grant, wildcard, read-grants-don't-confer-write, no-permissions, and explicit `state:write:` form.
The manifest doc now reads **"(P5 — ENFORCED, ADR-162)"**. **Grade: MEASURED.**
### §A5 — Bounded automation RunModes (Restart / Queued / max) — MEASURED
`homecore-automation/src/engine.rs` (per ADR-161) honored `Single`/`Parallel`
but spawned an unbounded parallel task for `Restart`/`Queued`/`max`.
**Real fix** (`src/runmode.rs`, a per-automation `RunState` the engine owns and
dispatches through at all three trigger sites — event loop, timer, test hook):
- **Restart** — aborts the in-flight action task via `tokio::task::AbortHandle`, then starts a fresh one.
- **Queued** — serializes runs in arrival order via a per-automation async `Mutex`: sequential, never concurrent, nothing dropped.
- **max: N** — caps concurrency at N via a per-automation `Semaphore`; triggers beyond N **queue** (await a permit) rather than running concurrently. (HA bounded `parallel`/`queued` semantics — chosen and documented as *queue beyond N*, not drop.)
- `Single`/`IgnoreFirst` re-entrancy guard and `Parallel` preserved.
`engine.rs` trimmed to **433 lines**; the run-mode machinery lives in the new
`runmode.rs` (153 lines) to keep both under the 500-line guideline.
**Failing-on-old tests** (`tests/engine_behaviors.rs`) — each was run against a
simulated unbounded-parallel dispatch and confirmed to panic:
- `restart_mode_cancels_prior_run` — prior run is aborted: exactly **1** completion (old: both ran → 2).
- `queued_mode_runs_sequentially_not_concurrently` — 3 rapid triggers all run, **max observed concurrency = 1** (old: 3).
- `max_two_caps_concurrency_at_two` — 4 rapid triggers all run, **max observed concurrency ≤ 2** (old: 4).
**Grade: MEASURED. Restart, Queued, and `max: N` all implemented — no remaining RunMode deferral.**
## Threat model closed
| Threat | Before (ADR-161) | After (ADR-162) |
|--------|------------------|-----------------|
| **Tampered module** — attacker swaps `.wasm` bytes after signing | loaded unconditionally (hash never checked) | rejected: SHA-256 mismatch |
| **Untrusted publisher** — valid sig from a key the host doesn't trust | loaded (sig/key never read) | rejected: publisher_key not on allowlist |
| **Unsigned module** — no integrity material at all | loaded | rejected by secure default; loads only under explicit `AllowUnsigned` (loud warn) |
| **Over-privileged plugin write** — a `light.*` plugin writes `lock.front_door` / `alarm_control_panel.*` | applied (permissions never consulted) | denied: typed `-3` to guest, write not applied |
| **Run-mode resource exhaustion**`max`/`Queued` spawn unbounded tasks | unbounded parallel | bounded: Restart cancels, Queued serializes, `max: N` caps at N |
## Remaining honest deferral (Nothing Dropped)
- **Plugin-key provisioning / rotation** — the host's trust allowlist
(`PluginPolicy::trusted`) is supplied by the caller; sourcing it from the
Cognitum control-plane key store (as `cog-ha-matter` does for Seed keys) and
key rotation are **ACCEPTED-FUTURE** (out of M8 scope — same boundary
`witness_signing` draws).
- **`InProcessRuntime` (native first-party plugins)** — has no `.wasm` bytes to
hash, so P4/P5 apply only to the WASM (`wasmtime`) path; native plugins remain
trusted-by-compilation. Honestly noted, not over-claimed.
- **HAP real pairing (P2)** — unchanged from ADR-161; out of M8 scope.
## Reproduction (MEASURED)
```bash
cd v2
# P4/P5 (wasmtime feature needs rustc 1.91+; workspace pins 1.89 for the rest):
cargo +1.91.1 test -p homecore-plugins --features wasmtime
# Bounded RunModes:
cargo test -p homecore-automation --no-default-features
# Full workspace still builds (1.89 toolchain, no wasmtime):
cargo build --workspace --no-default-features
```
Result at time of writing (all 0 failed):
- **homecore-plugins** `--features wasmtime`**32 passed** (lib 23; integration 9). (ADR-161 baseline was 15.)
- **homecore-automation** `--no-default-features`**45 passed** (lib 37; `engine_behaviors` 8). (ADR-161 baseline was 42.)
- Full workspace `cargo build --workspace --no-default-features` succeeds.
## Consequences
- A HOMECORE WASM plugin can no longer be loaded with a tampered binary, an
untrusted publisher, or (by default) no signature at all — the trust boundary
ADR-161/B5 honestly said was absent is now real (P4).
- A plugin can no longer write entities outside its declared
`homecore_permissions`; the lock/alarm escalation path is closed (P5).
- The automation engine's `Restart`, `Queued`, and `max: N` run-modes are now
bounded as documented — no run-mode claims a capability the code lacks.
- No new external dependency tree (reuses the cog-ha-matter Ed25519 stack
already in the lock); source files kept under the 500-line guideline
(`engine.rs` 433, `runmode.rs` 153, `verify.rs` 397, `permissions.rs` 168;
`wasmtime_runtime.rs` non-test source < 500, inline WAT tests as ADR-161 left
them).
@@ -0,0 +1,123 @@
# ADR-163: Edge-Latency Measurement — CLAIMED budgets → MEASURED-on-host
- **Status**: accepted
- **Date**: 2026-06-12
- **Deciders**: ruv
- **Tags**: edge-latency, wasm-edge, esp32, cog-inference, criterion, prove-everything, measurement-debt
- **Amends**: ADR-160 (deferred "criterion benches for process_frame budget claims" line now DONE-on-host); ADR-159 (cog inference latency)
## Context — Milestone 9 of the beyond-SOTA sweep
Prior milestones (M5/M6, ADR-159/ADR-160) flagged **measurement debt**: edge
latency budgets asserted in doc-comments and manifests but **never reproduced by
a committed benchmark**. Specifically:
- Many `wifi-densepose-wasm-edge` skill modules document a timing budget *"on
ESP32-S3 WASM3"* (e.g. `exo_time_crystal`: "H (heavy, <10 ms)"). These were
**CLAIMED**, not benchmarked. ADR-160's deferred backlog named exactly this:
*"Criterion benches for `process_frame` budget claims — ACCEPTED-FUTURE."*
- `cog-pose-estimation`'s manifest cites `cold_start_ms_avg: 5.4`, but neither
cog had a `benches/` directory or any committed inference-latency number.
Under the project's **prove-everything / anti-"AI-slop"** directive, a CLAIMED
latency budget that a skeptic cannot reproduce is debt. M9 pays it down — benches
and docs only, **no production-code behavior change** (so nothing republishes).
## Headline
**Converted the CLAIMED edge-latency budgets into MEASURED-on-host numbers, with
the honest host-vs-ESP32 caveat stated everywhere.** Added committed criterion
benches over the heaviest hot paths and a results file a skeptic can re-run. The
ESP32-on-hardware figure remains explicitly **UNMEASURED** — this milestone does
not pretend a laptop reproduces an Xtensa/WASM3 budget.
## Decision — benches landed
### T1 — wasm-edge `process_frame` budget benches
`v2/crates/wifi-densepose-wasm-edge/benches/process_frame_bench.rs` (criterion,
`harness = false`, `required-features = ["std"]`). The crate is **excluded from
the v2 workspace**, so it runs from the crate dir. Benches the M6-audit-named
heaviest hot paths over a **fixed synthetic CSI frame**, each driven through the
public `process_frame` after warming the relevant ring/phase buffers so the
expensive path actually executes:
- `exo_time_crystal::process_frame` — full 256-pt × 128-lag autocorrelation.
- `exo_ghost_hunter::process_frame` — empty-room periodicity / hidden-breathing.
- `sec_weapon_detect::process_frame` — per-subcarrier (MAX_SC=32) Welford.
- `med_seizure_detect::process_frame` — clonic-rhythm path (`#[cfg(feature =
"medical-experimental")]`, only built/run with that gate).
The lib's `bench = false` was set so the libtest harness does not intercept
criterion CLI flags; the `ghost_hunter` bin is already `standalone-bin`-gated and
not built under `--features std`.
**Measured host medians** (Intel Core Ultra 9 285H, native `--release`):
`exo_time_crystal` **17.3 µs** · `exo_ghost_hunter` **1.44 µs** ·
`sec_weapon_detect` **0.42 µs** · `med_seizure_detect` **0.10 µs**.
### T2 — cog inference latency benches
`v2/crates/cog-person-count/benches/infer_bench.rs` and
`v2/crates/cog-pose-estimation/benches/infer_bench.rs` (criterion,
`harness = false`). Each loads the **real** shipped weights from the in-repo
`cog/artifacts/`, asserts the Candle CPU backend (so the stub can never be
silently benched), warms one forward, then times steady-state
`InferenceEngine::infer` over a fixed CSI window on `Device::Cpu`.
**Measured host medians:** cog-person-count **305 µs** · cog-pose-estimation
**305 µs** (steady-state, CPU, real weights).
### T3 — results file
`benchmarks/edge-latency/RESULTS.md`, in the `benchmarks/wiflow-std/RESULTS.md`
style: each number with its exact reproduce command, the machine, the
MEASURED-on-host grade, and the honest caveat.
## The honest caveat (recorded, non-negotiable)
1. **Host ≠ ESP32.** The wasm-edge benches run native x86_64, not Xtensa/WASM3.
A host median is an **upper bound on algorithm work**, not the ESP32 number;
WASM3 interpretation on a ~240 MHz core is 12 orders of magnitude slower than
native `-O`. A host median under budget does **not** prove the ESP32 meets it.
**The ESP32 figure is NOT reproduced here — it needs hardware.**
2. **Bench ≠ the doc-claimed measurement.** The cogs' manifest cites a
**cold-start** number (weight-load included); these benches measure
**steady-state** per-frame `infer`. We report both, labelled, and do not
conflate them. Empirically, pose steady-state (305 µs host) is ~18× under the
5.4 ms cold-start — the expected shape, and exactly why conflating would lie.
## Deferred / still-pending (nothing dropped)
- **ESP32-on-hardware `process_frame` latency****PENDING (hardware)**. Needs
the `wasm32-unknown-unknown` target built + flashed to an ESP32-S3 and timed
under WASM3. The host bench is the algorithm-cost proxy until then.
- **Per-skill *accuracy*** remains **DATA-GATED** (unchanged from ADR-160) —
this ADR measures latency only, never claims detection accuracy.
## Reproduction (MEASURED)
```bash
# T1 — wasm-edge (workspace-excluded → run from the crate dir)
cd v2/crates/wifi-densepose-wasm-edge
cargo bench --features std -- --warm-up-time 1 --measurement-time 2
cargo bench --features std,medical-experimental -- --warm-up-time 1 --measurement-time 2 med_seizure
# T2 — cogs (workspace members)
cd v2
cargo bench -p cog-person-count --no-default-features --bench infer_bench
cargo bench -p cog-pose-estimation --no-default-features --bench infer_bench
# existing tests still green (behavior unchanged)
cargo test -p cog-person-count -p cog-pose-estimation --no-default-features
```
## Consequences
- ADR-160's deferred *"Criterion benches for `process_frame` budget claims"* line
is now **DONE (host)**; the ESP32-on-hardware confirmation is explicitly the
one remaining pending item.
- The cogs now ship committed, reproducible steady-state inference-latency
numbers, cleanly distinguished from the manifest's cold-start claim.
- No runtime behavior changed; no crate republishes. `PROOF.md`'s performance
table and `scripts/prove.sh`'s gated section reference the new benches.
+125
View File
@@ -0,0 +1,125 @@
# ADR-164: ADR Corpus Gap Analysis & Remediation Backlog
- **Status:** proposed
- **Date:** 2026-06-12
- **Deciders:** ruv
- **Tags:** governance, meta
## Context
The corpus has grown to **162 ADR entries across 156 distinct files** (ADR-001 through ADR-163, plus 6 duplicate-number collisions). It now spans nine subsystems — signal/DSP, NN/training, ESP32 firmware, RuvSense multistatic, RuView desktop, Cognitum cogs, HOMECORE (HA reimplementation), BFLD privacy, and the streaming engine — written over roughly a year by many agent-driven sessions.
Two forces motivate a corpus-wide gap analysis *now*:
1. **The beyond-SOTA / anti-AI-slop sweep (ADR-154163) just landed.** That sweep is itself a structured retraction layer: each ADR exists *because* an earlier accepted-or-shipped claim was found false (a dead CIR coherence gate, a fake-gradient TTA path, a self-certifying proof, a WebSocket auth bypass, an inflated survivor count). The sweep hardened five subsystems but was narrowly scoped — it never touched the two largest capability gaps (camera-teacher training validation; federation/BFLD privacy chains). A ledger is needed to record what the sweep retracted and what it left open.
2. **The status field can no longer be trusted as a source of truth.** A five-lens audit (status-distribution, supersession-chains, contradictions, coverage-gaps, data-hardware-gated) found ~24 ADRs mislabeled `Proposed` while their own commit-pinned Implementation-Status notes report them built and tested; 6 ADR numbers collide; 3 files have no Status header at all. An auditor reading headers would conclude "not built" for landed code, and "built/Accepted" for unvalidated capability.
The detailed lens outputs and the full per-ADR census live in `docs/adr/gap-analysis/` (`lens-findings.md`, `census.md`). This ADR is the authoritative summary and remediation backlog.
## Decision
**This ADR is the authoritative gap ledger and remediation backlog for the ADR corpus as of 2026-06-12.** It does not change any subsystem behavior. It records, with cited ADR ids:
- the status/impl distribution and the bookkeeping-drift problem;
- a prioritized Gap Register with a recommended action per gap;
- supersession-integrity defects;
- the contradiction/retraction list (the anti-slop centerpiece);
- shipped capabilities with no governing ADR;
- the genuinely open data/hardware-gated backlog.
Until the Gap Register items are worked, **treat the ADR Status header as advisory, not authoritative**, and treat any accuracy number authored before ADR-155 landed as CLAIMED (not MEASURED) until re-derived through the post-155 leak-free validation split.
## Status Distribution
Counts are approximate (`~`) where a status string is non-canonical or dual-valued; the per-ADR breakdown is in `census.md`.
| Status bucket | Count | impl_state | Count |
|---|---|---|---|
| Accepted (incl. partial/in-progress/Phase-1 variants) | ~56 | implemented | ~36 |
| Proposed (incl. conditional/research-only) | ~88 | partial | ~50 |
| Superseded | 1 (ADR-002) | proposed-only | ~64 |
| Rejected | 1 (ADR-098) | stale-or-contradicted | 3 (029/030/031) |
| Missing / no Status header | 3 (ADR-147-proof, ADR-052-ddd, ADR-134) | unknown | 5 (034/044/052-ddd/147-proof/…) |
| Mixed/dual status in one ADR | 3 (115, 149×2, 133) | superseded | 1 (ADR-002) |
**Headline:** ~114 of 162 ADRs (≈70%) are decisions that never fully landed (proposed-only + partial + stale + unknown). The dominant failure mode is **stale Status headers**, not abandoned work.
## Gap Register
Severity: CRITICAL (corpus integrity / tooling-breaking / life-safety / security) · HIGH · MEDIUM · LOW. Action vocabulary: *implement · supersede · mark-stale · write-missing-ADR · close-as-gated · renumber · reconcile-docs*.
| ID | Gap | Severity | Affected ADRs | Recommended action |
|----|-----|----------|---------------|--------------------|
| G1 | 6 duplicate ADR numbers (two ADRs answer to one number; breaks index/`/adr` tooling) | 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) — **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~~ **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" |
| G9 | Streaming-engine integrator crate has no governing ADR (composition/back-pressure/live-path seam) | HIGH | wifi-densepose-engine (composes 135146) | write-missing-ADR |
| G10 | CLAUDE.md doc-vs-header drift (doc says one status, header another) | HIGH | 017, 024, 027, 072, 152 | reconcile-docs |
| G11 | Open security HIGH findings, gate FAILED, never marked done | HIGH | 080 (XFF bypass, leaked stack traces, JWT-in-URL CWE-598) | implement (sensing-server boundary — NOT covered by HOMECORE sweep 161/162) |
| G12 | ADR-052→054 edge unacknowledged by successor; likely mis-modeled (impl, not replacement) | MEDIUM | 052-tauri, 054 | reconcile-docs (054 is the impl plan *for* 052, not a replacement) |
| G13 | Capability governed only by remediation/deploy ADR, no creation/architecture ADR | MEDIUM | wasm-edge (only 160/163); occworld-candle (147 blessed Python path only); pointcloud (094 = viewer deploy only) | write-missing-ADR (taxonomy/ABI for wasm-edge; Candle backend swap; pointcloud data contract) |
| G14 | Conflicting decisions on one topic, none superseding the others | MEDIUM | person-count 037/075/103; PQ-sign 007/109; fed key-exchange 107/108; provisioning 050/060/052; audit 010/028; RVF-WASM 009-vs-shipped | reconcile (pick one, supersede the rest) |
| G15 | ~50 Proposed-forever chains pollute every gap analysis | MEDIUM | 003/007010, 105109, 118125, HOMECORE 124133, 033/046/049/067/074/085 | close-as-gated or mark Deferred/Rejected + open tracking issues |
| G16 | De-facto supersessions never recorded (lifecycle graph incomplete) | MEDIUM | 098/099, 063/064, 042/153, 050/060, 035/023, 100/109, 117 retracts PyPI v1.1.0 | reconcile (add supersedes/superseded_by fields) |
| G17 | Accepted but no implementation evidence ("unverified done") | MEDIUM | 034 (FieldView app — no crate); 044 (wifi-densepose-geo — bare Accepted, no Date/Deciders) | implement or downgrade to Proposed |
| G18 | Workspace has ~38 crates; CLAUDE.md publishing list (12-step) and crate table (15) are stale | MEDIUM | corpus-wide (crate-graph topology) | write-missing-ADR (crate-graph / publish boundaries) + reconcile CLAUDE.md |
## Supersession Integrity
Only **3 formal supersession edges** exist; all three are defective (see G8/G12; full detail in `lens-findings.md` Lens 2):
- **ADR-002 → ADR-016 / ADR-017** is one-directional. ADR-016 never mentions ADR-002 (its References list only 014/015); ADR-017 only *corrects* ADR-002's "fictional crate names" and never says "supersede." The census `supersedes:["ADR-002"]` on 016/017 is **file-unsupported** — the superseded ADR points up at two successors that do not point back.
- **ADR-002 is an umbrella** whose children 003/007/008/009/010 are still `Proposed`. ADR-016/017 realize only the training/signal/MAT integration points; the RVF-container (003), PQ-crypto (007), Raft (008), WASM-edge-runtime (009), and witness-chains (010) decisions are **neither implemented nor formally superseded**. Marking the parent fully "Superseded" silently buries 5 live-but-abandoned child decisions. Recommended: split ADR-002 to "partially superseded."
- **ADR-052-tauri → ADR-054** is declared by the predecessor but ADR-054 contains zero references to ADR-052. ADR-054 ("Full Implementation", in progress) is the impl plan *for* 052, not a replacement — likely a mis-modeled edge.
- **No cycles** detected. The graph is clean structurally; the defect is missing reciprocity and ~7 unrecorded de-facto supersessions (G16).
## Contradictions & Retractions (anti-slop centerpiece)
The four CRITICAL items are the corpus's load-bearing AI-slop admissions — each an accepted-or-shipped surface whose stated accuracy/security/function was provably false until the sweep landed. **Every accuracy number predating ADR-155 should be treated as CLAIMED until re-derived through the post-155 leak-free split.** Source-cited evidence is in `lens-findings.md` Lens 3.
- **[CRITICAL] ADR-155** retracts every prior NN accuracy/TTA/proof claim: real MM-Fi training validated against a *synthetic* val set with stride-1 (~99%) window leakage (§2.2); a *fake gradient* `grad += v*0.01` in the TTA path (§2.3); a *self-certifying* proof that blessed whatever the pipeline emitted and PASSed on 1e-9 float noise (§2.4).
- **[CRITICAL] ADR-154** proves the ADR-134 CIR coherence gate was **dead in production for every canonical 56-tone frame** (`SubcarrierMismatch`, 0 Ok / 8 mismatch), silently degrading coherence to freq-only. Any "CIR-enhanced coherence/ToF" claim before this fix overstated reality.
- **[CRITICAL] ADR-079** carries three mutually inconsistent values for its own central metric: proxy PCK@20 = 2.5% (prose) vs 35.3% (baseline table — equal to the *target*) vs 0% upper-body joints; #640 measured 0% on real local data. An Accepted ADR whose headline 1020x improvement is self-refuting.
- **[CRITICAL] ADR-161** fixes a HOMECORE WebSocket **auth bypass** (any non-empty token accepted) + reply-theater + no-op automation; **ADR-162** then enforces plugin Ed25519 signature verification, capability isolation, and bounded RunModes — retracting ADR-128/129/130's implied security guarantees.
- **[HIGH]** ADR-152 self-refutes 1 of 25 claims (ESP WiFi-6 "drop-in" REFUTED 0-3); CLAUDE.md's "WiFlow-STD MEASURED-EQUIVALENT ~96% PCK" contradicts §F1's own gating (97.25% is CLAIMED until measurements (a)(c) run). ADR-150 retracts the implied cross-subject capability (81.63% in-domain vs ~11.6% leakage-free cross-subject; DANN ~0 gain). ADR-159 ships real models but discloses person-count `training_class1_accuracy = 0.343` and renames "learned multi-person counter" → "presence detector," gutting ADR-103/104's claim.
- **[MEDIUM]** ADR-163 leaves the ESP32/Xtensa on-hardware latency figure UNMEASURED; ADR-098↔099 partial reversal on midstream; ADR-147 self-retracts Cosmos for OccWorld.
## Coverage Gaps (shipped capability, no/broken governing ADR)
- ~~**CRITICAL — `homecore-recorder`** (SQLite state history + semantic search) cites "ADR-132", which **does not exist**. The durable-state backbone is ungoverned. → write HOMECORE-RECORDER ADR.~~ **RESOLVED in `docs/adr-gap-remediation-1`:** ADR-132 written (`ADR-132-homecore-recorder-history-semantic-search.md`, Status: Accepted — reverse-documented from the shipped crate).
- ~~**CRITICAL — `homecore-migrate`** (reads untrusted Python-HA `.storage/*.json`) cites "ADR-134", but on-disk ADR-134 is CIR. A data-integrity-sensitive importer governed by a phantom identity. → resolve 134 collision + write HOMECORE-MIGRATE ADR (trust boundary).~~ **RESOLVED in `docs/adr-gap-remediation-1`:** ADR-165 written (`ADR-165-homecore-migrate-from-home-assistant.md`, Status: Accepted — P1 scaffold); crate's `ADR-134` refs repointed → ADR-165; on-disk ADR-134 (CIR) left intact. ADR-126's series-map row (which labels the *role* "ADR-134 HOMECORE-MIGRATE") is owner-gated and unchanged.
- **HIGH — `wifi-densepose-engine`** composes ADR-135..146 onto the live 20 Hz path but **no ADR governs the integrator contract** (ordering, back-pressure, "one pipeline cycle" boundary).
- **MEDIUM — `wasm-edge`** (~70 skills) governed only by remediation ADRs 160/163 — no creation/taxonomy/ABI ADR. **`occworld-candle`** is a Rust-native backend swap ADR-147 explicitly deferred. **`pointcloud`** has only a viewer-deploy ADR (094), no data-format contract.
- **MEDIUM — workspace topology:** ~38 crates exist; the CLAUDE.md 15-crate table and 12-step publishing order are stale, and no ADR governs crate-graph/publish boundaries at this scale.
- Verified-governed (scoped out): worldmodel→147, worldgraph→139, cog-*→101/103/116, ruview-swarm→148, nvsim→089/092, bfld→118-123/141, calibration→151, homecore-hap→125, geo→044, desktop→052/054.
## Open / Gated Backlog (genuinely unresolved, honestly labeled)
The ADR-154163 sweep was narrowly scoped. The two largest **capability** gaps it did not touch:
- **CRITICAL — Camera-teacher training validation (ADR-079 / 072 / 150).** P7P9 Pending; blocker is a real synchronized camera+ESP32 paired-capture session + GPU training on the fleet (ruvultra RTX 5080). Cross-subject collapse (11.6%) is data-gated on a heterogeneous multi-subject CSI dataset, per ADR-150 §F3 / ADR-152 F3 (the lever is *more data*, not capacity). Accepted-on-paper, not proven.
- **HIGH — Federation + BFLD privacy chains (ADR-105109, 118125).** All Proposed-only, ACs unchecked. Blockers: KIT BFId dataset (121), Pi5/Nexmon CBFR capture hardware (123 — ESP32 structurally cannot sniff CBFR), Soul-Signature + cog-ha-matter (122/125). The privacy control *plane* (ADR-141) is built; the *capture/scoring* chain it gates is not.
- **HIGH — Sensing-server security (ADR-080).** Distinct from the HOMECORE boundary the sweep fixed; XFF bypass / stack-trace leakage / JWT-in-URL remain open.
- **MEDIUM — gold-standard deferrals (model to follow):** ADR-163 (ESP32 on-hardware latency UNMEASURED), ADR-160 (medical/affect/weapon NOT validated, relabelled), ADR-158 (RF-through-rubble + learned counter DATA-GATED). Code is real, the claim is withheld pending absent hardware/labelled data — labels are honest.
- **MEDIUM — purely hardware/data-gated Proposed decisions (no overreach):** ADR-023, 027, 042, 063/064, 065/066, 070, 073/078, 083, 086, 091, 103, 110 (HE-CSI needs ESP-IDF ≥5.5), 113, 114, 134/135, 143-v2, 144. *needs verification* where flags rely on downstream prose rather than direct file inspection.
## Consequences
**Positive.** One authoritative ledger replaces scattered, drifting status fields. The anti-slop retractions are recorded in a citable place, so the "AI slop" accusation is met with a structured admission + fix-trail rather than denial. The Gap Register is a concrete, severity-ordered work queue. Batch-fixing G5 (10 streaming-engine headers) and G1/G2 (numbering + missing headers) is high-ROI and unblocks ADR tooling.
**Negative.** This ADR is a snapshot; it goes stale the moment the next ADR lands. Counts marked `~` are approximate and a few impl_state values are *needs verification* (downstream-prose-derived, not file-confirmed). Acting on the register (renumbering, status flips, supersession edits) touches ~30 files and risks transient cross-reference breakage if not done atomically.
**Neutral.** No subsystem behavior changes. Renumbering decisions (which of the colliding files keeps each number) are deferred to the follow-up remediation PR — this ADR records the collision, not the resolution. Whether to close abandoned chains as `Rejected` vs `Deferred` is a judgment call left to the deciders per chain.
## Links
- `docs/adr/gap-analysis/census.md` — full per-ADR census (162 entries).
- `docs/adr/gap-analysis/lens-findings.md` — five-lens findings (status-distribution, supersession-chains, contradictions, coverage-gaps, data-hardware-gated), verbatim.
- Anti-slop sweep: ADR-154, ADR-155, ADR-156, ADR-157, ADR-158, ADR-159, ADR-160, ADR-161, ADR-162, ADR-163.
- Most-cited defects: ADR-079, ADR-134, ADR-002, ADR-136145, ADR-152.
- Governance: CLAUDE.md (crate table + publishing order — stale per G18); ADR-038 (prior roadmap census, now stale).
@@ -0,0 +1,129 @@
# ADR-165: HOMECORE-MIGRATE — Migration Tooling from Python Home Assistant
| Field | Value |
|-------|-------|
| **Status** | Accepted — P1 scaffold (full conversion deferred to P2) |
| **Date** | 2026-05-25 |
| **Deciders** | ruv |
| **Codename** | **HOMECORE-MIGRATE** |
| **Crate** | `v2/crates/homecore-migrate` |
| **Relates to** | [ADR-126](ADR-126-ruview-native-ha-port-master.md) (HOMECORE master — series map row "ADR-134 HOMECORE-MIGRATE"), [ADR-127](ADR-127-homecore-state-machine-rust.md) (HOMECORE-CORE), [ADR-132](ADR-132-homecore-recorder-history-semantic-search.md) (HOMECORE-RECORDER — P2 side-by-side export target) |
| **Tracking issue** | [#800](https://github.com/ruvnet/RuView/pull/800) (HOMECORE intake) |
> **Number-collision resolution (2026-06-12).** The HOMECORE series in ADR-126 §4 planned
> "ADR-134 = HOMECORE-MIGRATE", and the `homecore-migrate` crate cites "ADR-134" throughout.
> But the on-disk `ADR-134-csi-to-cir-time-domain-multipath.md` is a **different, unrelated
> decision** (First-Class CIR Support, a signal-processing tier). The migrate crate was
> therefore governed by a phantom identity (ADR-164 Gap G3 / Coverage-Gaps Lens §A). This
> ADR takes the next free number (**165**) and becomes the real governing record for
> HOMECORE-MIGRATE; the `ADR-134` references inside `v2/crates/homecore-migrate/` are
> repointed to ADR-165. The real ADR-134 (CIR) is untouched. ADR-126's series-map row still
> labels the *role* "ADR-134 HOMECORE-MIGRATE" for historical traceability; that registry
> renumber is owner-gated and left for the follow-up. This ADR reverse-documents the shipped
> P1 scaffold; it introduces no new design.
---
## 1. Context
ADR-126 decided to reimplement Home Assistant (HA) natively in Rust. A user adopting
HOMECORE has an existing HA install whose configuration lives in two places on disk:
- `.storage/*.json` — versioned JSON envelopes (`{ version, minor_version, data }`) holding
the entity registry, device registry, and config entries;
- top-level YAML — `secrets.yaml`, `automations.yaml`.
To migrate, HOMECORE must read this foreign, **untrusted** on-disk state. It is untrusted in
the security sense: the schema can drift between HA releases, and silently mis-parsing a
registry would corrupt the imported home. ADR-164 flagged this as a CRITICAL coverage gap —
a data-integrity-sensitive importer governed by a non-existent ADR identity.
The decision an ADR must pin here is the **trust boundary and import contract**: which HA
files are read, how schema versions are validated, and what happens on an unknown version.
## 2. Decision
Ship `homecore-migrate` as a CLI + library that reads an existing HA filesystem and imports
its configuration into HOMECORE. P1 is a **scaffold**: it parses and inspects everything and
converts the entity registry; full conversion of the remaining artifacts is deferred to P2.
### 2.1 Storage reader + versioned format gate (P1, shipped)
- `HaStorageDir` / `HaStorageEnvelope` read HA's `.storage/` directory; `read_envelope(path)`
deserializes a `.storage/*.json` envelope (`src/storage.rs`).
- Versioned parsers live under `storage_format::v<N>` (e.g. `v13` for the entity registry)
(`src/storage_format/`).
- **Schema-version validation is the load-bearing safety rule (§6 Q5 of this ADR):** an
unknown `minor_version` is a **hard error** (`MigrateError::UnsupportedSchemaVersion`),
never a silent best-effort parse. Better to refuse than to corrupt.
### 2.2 Per-artifact parsers (P1, shipped)
- `entity_registry::load()``core.entity_registry``Vec<homecore::EntityEntry>`
(ready for import).
- `device_registry::load()``core.device_registry``Vec<DeviceImport>` (P1 diagnostic;
full conversion P2).
- `config_entries::load()``core.config_entries` → domain counts + integration names
(the format is undocumented per §6 Q5; treated diagnostically).
- `secrets::load_secrets()``secrets.yaml``HashMap<String, String>` (resolution P2).
- `automations::load()``automations.yaml` → count + ID/alias list (conversion P2).
### 2.3 CLI (P1, shipped)
- `homecore-migrate inspect <ha-dir>` previews what will be migrated (entity/device/config
counts, redacted secret/automation lists) (`src/cli.rs`, `src/main.rs`).
- `import-entities` and `export-for-sidecar` are declared but their full behaviour is P2.
### 2.4 Structured errors (P1, shipped)
- `MigrateError` carries context (`path`, line/field) for I/O, JSON, YAML, missing-field,
unsupported-schema-version, and entity-id parse failures (`src/lib.rs`).
### 2.5 Deferred to P2+ (NOT built — honestly labelled)
- Convert `config_entries` → HOMECORE plugin manifests.
- Convert `automations.yaml``homecore-automation` YAML.
- Side-by-side runtime mode (requires `homecore-recorder`, ADR-132; behind the `recorder`
Cargo feature, currently a no-op stub).
- `!secret` reference resolution in non-secrets YAML files.
### 2.6 Test evidence (as shipped)
- 19 tests (`cargo test -p homecore-migrate`), per the crate README badge.
## 3. Consequences
**Positive.**
- The trust boundary is explicit: unknown HA schema versions are rejected, not guessed, so a
schema drift fails loudly instead of corrupting an imported home.
- Reusing HA's own `.storage` and YAML formats means no intermediate export step; the tool
reads a live HA install directly.
- P1 `inspect` gives users a no-risk dry run before any write.
**Negative / honest limits.**
- P1 is a **scaffold**: only the entity registry is conversion-ready. Device registry,
config-entry→plugin, automation, and secret-resolution conversions are P2 and **not yet
built** — the Status field and crate docs say so.
- The side-by-side recorder export depends on ADR-132 and is currently a feature-gated
no-op.
- Performance figures in the README (envelope parse < 5 ms, 1 000-entity load < 50 ms) are
estimates, **needs verification** with a benchmark.
**Neutral.**
- This resolves only the *identity* of the migrate decision (134→165). The broader 6-way
duplicate-number cleanup (incl. ADR-126's series-map registry row) is owner-gated.
## 4. Links
- Crate: `v2/crates/homecore-migrate/``Cargo.toml`, `README.md`, `src/lib.rs`,
`src/storage.rs`, `src/storage_format/`, `src/entity_registry.rs`,
`src/device_registry.rs`, `src/config_entries.rs`, `src/secrets.rs`,
`src/automations.rs`, `src/cli.rs`, `src/main.rs`.
- [ADR-126](ADR-126-ruview-native-ha-port-master.md) — HOMECORE master (series map: HOMECORE-MIGRATE).
- [ADR-132](ADR-132-homecore-recorder-history-semantic-search.md) — HOMECORE-RECORDER (P2 side-by-side export target).
- [ADR-134](ADR-134-csi-to-cir-time-domain-multipath.md) — First-Class CIR Support (the *unrelated* decision the crate was mistakenly citing).
- [ADR-164](ADR-164-adr-corpus-gap-analysis.md) — gap analysis that surfaced this collision (Gap G3).
- [Home Assistant `.storage` format](https://developers.home-assistant.io/docs/storage/).
+168
View File
@@ -0,0 +1,168 @@
# ADR Corpus Census
Full per-ADR census underpinning ADR-164. **162 ADR entries across 156 distinct files** (6 duplicate-number collisions). Source of truth for the gap-analysis lenses. Where the census is uncertain it is marked *needs verification*.
| ADR | Title | Status | impl_state | Flags |
|-----|-------|--------|-----------|-------|
| ADR-001 | WiFi-Mat Disaster Detection Architecture | Accepted | implemented | data/hardware-gated (rubble-penetration unproven without field hardware) |
| ADR-002 | RuVector RVF Integration Strategy | Superseded by ADR-016 + ADR-017 | superseded | umbrella ADR; child ADRs 003/007/008/009/010 still Proposed |
| ADR-003 | RVF Cognitive Containers for CSI Data | Proposed | proposed-only | proposed-but-looks-abandoned (parent 002 superseded, never advanced) |
| ADR-004 | HNSW Vector Search for Signal Fingerprinting | Partially realized by ADR-024; extended by ADR-027 | partial | realized indirectly via downstream ADRs, not directly |
| ADR-005 | SONA Self-Learning Pose Estimation | Partially realized in ADR-023; extended by ADR-027 | partial | realized indirectly via ADR-023 (MicroLoRA/EWC++) |
| ADR-006 | GNN-Enhanced CSI Pattern Recognition | Partially realized in ADR-023; extended by ADR-027 | partial | realized indirectly via ADR-023 (2-layer GCN), scope narrowed |
| ADR-007 | Post-Quantum Cryptography for Secure Sensing | Proposed | proposed-only | proposed-but-looks-abandoned (parent 002 superseded) |
| ADR-008 | Distributed Consensus for Multi-AP | Proposed | proposed-only | proposed-but-looks-abandoned (parent 002 superseded) |
| ADR-009 | RVF WASM Runtime for Edge Deployment | Proposed | proposed-only | contradicts shipped wifi-densepose-wasm crate it proposes to replace |
| ADR-010 | Witness Chains for Audit-Trail Integrity | Proposed | proposed-only | witness-bundle (ADR-028) fills this role instead |
| ADR-011 | Python Proof-of-Reality / Mock Elimination | Proposed (URGENT) | partial | proof pipeline (verify.py/ADR-028) live despite Proposed status; credibility-gated |
| ADR-012 | ESP32 CSI Sensor Mesh | Accepted — Partially Implemented | partial | hardware-gated; mesh partial, single-node firmware working per ADR-018 |
| ADR-013 | Feature-Level Sensing on Commodity Gear | Accepted — Implemented (36/36 tests) | implemented | — |
| ADR-014 | SOTA Signal Processing | Accepted | implemented | — |
| ADR-015 | Public Dataset Training Strategy | Accepted | implemented | data-gated (MM-Fi/Wi-Pose availability/licensing) |
| ADR-016 | RuVector Training-Pipeline Integration | Accepted | implemented | supersedes ADR-002 (but file never mentions 002 — unsupported claim) |
| ADR-017 | RuVector Signal + MAT Integration | Accepted | implemented | CLAUDE.md still lists as Proposed; supersedes 002 only via "Correction" prose |
| ADR-018 | ESP32 Dev Implementation | Proposed | partial | status stale — ADR-012 cites it as working firmware/aggregator |
| ADR-019 | Sensing-Only UI Mode with Gaussian Splat Viz | Accepted | implemented | status in table format not ## header |
| ADR-020 | Migrate AI/Model Inference to Rust (RuVector + ONNX) | Accepted | partial | table-format status; overlaps ADR-019 backend-decoupling scope |
| ADR-021 | Vital Sign Detection via rvdna Pipeline | Partially Implemented | partial | wifi-densepose-vitals crate exists |
| ADR-022 | Enhanced Windows WiFi Fidelity via Multi-BSSID | Partially Implemented | partial | wifi-densepose-wifiscan crate exists |
| ADR-023 | Trained DensePose Model w/ RuVector Signal Intelligence | Proposed | proposed-only | data/hardware-gated; scaffold w/ random weights |
| ADR-024 | Project AETHER — Contrastive CSI Embedding | Proposed | proposed-only | CLAUDE.md lists Accepted; pose_tracker.rs uses AETHER re-ID — contradiction |
| ADR-025 | macOS CoreWLAN WiFi Sensing (ORCA) | Proposed | proposed-only | hardware-gated (Mac Mini M2 Pro); RSSI-only |
| ADR-026 | Survivor Track Lifecycle Management (MAT) | Accepted | implemented | explicit Supersedes: None |
| ADR-027 | Project MERIDIAN — Cross-Env Domain Generalization | Proposed | proposed-only | CLAUDE.md lists Accepted — contradiction; data-gated |
| ADR-028 | ESP32 Capability Audit & Witness Record | Accepted | implemented | audit/witness record; pins commit 96b01008 |
| ADR-029 | RuvSense — Sensing-First RF Multistatic Mode | Proposed | stale-or-contradicted | repo has ruvsense/ (16 modules); ADR-032 hardens it |
| ADR-030 | RuvSense Persistent Field Model | Proposed | stale-or-contradicted | field_model/longitudinal/cross_room modules exist; ADR-032 secures |
| ADR-031 | RuView — Cross-Viewpoint Fusion | Proposed | stale-or-contradicted | ruvector/src/viewpoint/ exists; near-duplicate of ADR-029 |
| ADR-032 | Multistatic Mesh Security Hardening | Accepted | implemented | hardens Proposed 029/030/031 — status-graph inversion |
| ADR-033 | CRV Signal Line Sensing (Coordinate Remote Viewing) | Proposed | proposed-only | speculative/metaphor-driven; abandonment risk |
| ADR-034 | Expo React Native Mobile App (FieldView) | Accepted | unknown | no mobile-app crate/dir in CLAUDE.md — unverified |
| ADR-035 | Live Sensing UI Accuracy & Data Source Transparency | Accepted | implemented | bug-fix; heuristic pose superseded in spirit by 023/036 |
| ADR-036 | RVF Model Training Pipeline & UI Integration | Proposed | proposed-only | overlaps ADR-023 scope |
| ADR-037 | Multi-Person Pose from Single ESP32 CSI Stream | Proposed | proposed-only | explicit Supersedes: None; HW limitation noted |
| ADR-038 | Sublinear GOAP for Roadmap Optimization | Proposed | proposed-only | meta/process ADR; own corpus census may be stale |
| ADR-039 | ESP32-S3 Edge Intelligence Pipeline | Accepted (hardware-validated) | implemented | hardware-validated |
| ADR-040 | WASM Programmable Sensing (Tier 3) | Accepted | implemented | depends on ADR-039; WASM3 optional |
| ADR-041 | WASM Module Collection — Sensing Registry | Accepted (Phase 1) | partial | ~57 modules catalog/proposed; exotic modules speculative |
| ADR-042 | Coherent Human Channel Imaging (CHCI) | Proposed | proposed-only | hardware-gated (custom PCB/TCXO); superseded-in-intent by ADR-153 |
| ADR-043 | Sensing Server UI API Completion | Accepted | implemented | internal route count contradiction (14 vs 17) |
| ADR-044 | Geospatial Satellite Integration | Accepted | unknown | no Date/Deciders; wifi-densepose-geo crate not in CLAUDE.md table |
| ADR-045 | AMOLED Display Support for ESP32-S3 | Proposed | proposed-only | hardware-gated (LilyGO T-Display-S3); ADR-048 depends on it |
| ADR-046 | Android TV Box / Armbian Deployment Target | Proposed | proposed-only | proposed-but-looks-abandoned; Phase 2 speculative |
| ADR-047 | RuView Observatory — Three.js Visualization | Accepted (Implemented) | implemented | — |
| ADR-048 | Adaptive CSI Activity Classifier | Accepted | implemented | depends on Proposed ADR-045 |
| ADR-049 | Cross-Platform WiFi Detection & Graceful Degradation | Proposed | proposed-only | targets Python v1 legacy; abandonment risk |
| ADR-050 | Provisioning Tool Enhancements | Proposed | partial | DUPLICATE NUMBER; partially fulfilled by ADR-060 |
| ADR-050 | Quality Engineering Response — Security Hardening | Accepted | partial | DUPLICATE NUMBER; unverified claims (54K fps); findings #6-8 unconfirmed |
| ADR-052 | DDD Bounded Contexts (appendix) | (none — appendix, no Status) | unknown | missing-status; DUPLICATE NUMBER; cross-ref errors (cites 044 for provisioning) |
| ADR-052 | Tauri Desktop Frontend — Hardware Mgmt & Viz | Proposed | partial | DUPLICATE NUMBER; superseded_by ADR-054; status drift |
| ADR-053 | UI Design System — Dark Professional | Accepted | implemented | depends on Proposed ADR-052 |
| ADR-054 | RuView Desktop Full Implementation | Accepted — in progress | partial | command matrix mostly Stub; espflash version drift vs 052 |
| ADR-055 | Integrated Sensing Server in Desktop App | Accepted | implemented | — |
| ADR-056 | RuView Desktop Complete Capabilities Reference | Accepted | partial | reference doc; "complete" overstates impl state |
| ADR-057 | Firmware CSI Build Guard & sdkconfig.defaults | Accepted | implemented | minor C6 CSI matrix tension vs CLAUDE.md |
| ADR-058 | Dual-Modal WASM Browser Pose (Video + CSI) | Proposed | partial | data-gated; ships placeholder weights |
| ADR-059 | Live ESP32 CSI Pipeline Integration | Accepted | implemented | hardware-gated (physical ESP32-S3 + UDP:5005) |
| ADR-060 | Provision Channel Override & MAC Filtering | Accepted | implemented | fulfills part of Proposed ADR-050(prov) without superseding |
| ADR-061 | QEMU ESP32-S3 Emulation for Firmware Testing | Accepted | implemented | RF-PHY paths untestable in QEMU |
| ADR-062 | QEMU ESP32-S3 Swarm Configurator | Accepted | implemented | — |
| ADR-063 | 60 GHz mmWave Sensor Fusion with WiFi CSI | Proposed | proposed-only | hardware-gated (ESP32-C6+MR60BHA2); superseded-in-scope by 064 |
| ADR-064 | Multimodal Ambient Intelligence (CSI+mmWave+env) | Proposed | proposed-only | hardware-gated; mixes build-now + speculative tiers |
| ADR-065 | Hotel Guest Happiness Scoring | Proposed | proposed-only | hardware-gated (Cognitum Seed Pi Zero 2 W) |
| ADR-066 | ESP32 CSI Swarm with Cognitum Seed Coordinator | Proposed | proposed-only | hardware-gated; overlaps 068/069 |
| ADR-067 | RuVector v2.0.4→v2.0.5 Upgrade | Proposed | proposed-only | CLAUDE.md still v2.0.4 — not adopted |
| ADR-068 | Per-Node State Pipeline for Multi-Node Sensing | Accepted | implemented | — |
| ADR-069 | ESP32 CSI → Cognitum Seed RVF Ingest Pipeline | Accepted | implemented | hardware-gated (live Cognitum Seed fw v0.8.1) |
| ADR-070 | Self-Supervised Pretraining from Live CSI + Seed | Accepted | partial | hardware-gated (live 2-node + Seed capture) |
| ADR-071 | ruvllm Training Pipeline for CSI Models | Proposed | proposed-only | overlaps 072/079 + libtorch pipeline |
| ADR-072 | WiFlow Pose Estimation Architecture | Proposed | partial | data-gated; referenced as implemented in CLAUDE.md (WiFlow-STD) — stale header |
| ADR-073 | Multi-Frequency Mesh Scanning | Proposed | proposed-only | hardware-gated (2-node multi-AP) |
| ADR-074 | Spiking Neural Network for CSI Sensing | Proposed | proposed-only | proposed-but-looks-abandoned (no in-repo SNN signal) |
| ADR-075 | Min-Cut Person Separation from Subcarrier Corr | Proposed | proposed-only | fixes #348; 077/078 depend on it though Proposed |
| ADR-076 | CSI Spectrogram Embeddings via CNN + Graph Transformer | Proposed | proposed-only | — |
| ADR-077 | Novel RF Sensing Applications | Accepted | partial | depends on Proposed 075/076; data-gated |
| ADR-078 | Multi-Frequency Mesh Sensing Applications | Proposed | proposed-only | hardware-gated; depends on Proposed 073 |
| ADR-079 | Camera Ground-Truth Training Pipeline | Accepted | partial | P7-P9 Pending; internal PCK contradiction (2.5% vs 35.3% vs 0%); #640 = 0% |
| ADR-080 | QE Analysis Remediation Plan | Proposed | proposed-only | unfixed security HIGH findings (XFF bypass, stack traces, JWT-in-URL) |
| ADR-081 | Adaptive CSI Mesh Firmware Kernel | Accepted — L1-5 host-tested | partial | mesh RX + Ed25519 signing deferred to Phase 3.5 |
| ADR-082 | Pose Tracker Confirmed-Track Output Filter | Accepted — implemented | implemented | fixes #420 |
| ADR-083 | Per-Cluster Pi Compute Hop | Proposed — pending field evidence | proposed-only | hardware-gated (status explicitly pending field evidence) |
| ADR-084 | RaBitQ Similarity Sensor (4 pipeline points) | Accepted — merged PR #435 | implemented | acceptance on synthetic data; <1pp regression deferred to soak |
| ADR-085 | RaBitQ Similarity Sensor — Pipeline Expansion (7 sites) | Proposed | proposed-only | proposed-but-looks-abandoned (refines 084, never advanced) |
| ADR-086 | Edge Novelty Gate — RaBitQ on Sensor MCU | Proposed | proposed-only | hardware-gated (no_std port + real-deployment suppression rates) |
| ADR-089 | nvsim — NV-Diamond Magnetometer Simulator | Accepted — Passes 1-5 merged | partial | Pass 6 (proof bundle + bench) pending |
| ADR-090 | nvsim — Full Hamiltonian/Lindblad Solver | Proposed — conditional | proposed-only | explicitly deferred decision-to-defer |
| ADR-091 | Stand-off Radar — 77 GHz / sub-THz Research | Proposed — research only | proposed-only | hardware-gated (COTS sub-THz); ITAR/dual-use |
| ADR-092 | nvsim Dashboard — Vite + Dual-Transport | Implemented (2026-04-27) | implemented | 4/12 gates need external infra; PR #436 open |
| ADR-093 | nvsim Dashboard Gap Analysis | Implemented (2026-04-27) | implemented | P2.7/P2.8 polish deferred |
| ADR-094 | Live 3D Point Cloud Viewer — GH Pages | Proposed (2026-04-29) | proposed-only | governs viewer deploy only, not crate data contract |
| ADR-095 | rvCSI — Edge RF Sensing Runtime Platform | Proposed | implemented | header stale — ADR-097 confirms built, published 0.3.1 |
| ADR-096 | rvCSI — Crate Topology, napi-c Shim, napi-rs | Proposed | implemented | header stale — 9 crates published 0.3.1 |
| ADR-097 | Adopt rvCSI as RuView's primary CSI runtime | Proposed | proposed-only | RuView vendors but does not yet consume — adoption open |
| ADR-098 | Evaluate ruvnet/midstream | Rejected (with carve-outs) | proposed-only | rejection; carve-outs revived by ADR-099 |
| ADR-099 | Adopt midstream — introspection + low-latency tap | Proposed | proposed-only | tension with ADR-098 (which rejected midstream) |
| ADR-100 | Cognitum Cog Packaging Specification | Accepted | implemented | first cog shipped 2026-05-19 (ADR-101) |
| ADR-101 | Pose Estimation Cog (WiFi-DensePose side) | Accepted — v0.0.1 shipped | implemented | hardware-gated; signed binaries on GCS |
| ADR-102 | Edge Module Registry Integration | Accepted | implemented | serves 105-cog catalog |
| ADR-103 | Learned Multi-Person Counter (cog-person-count) | Proposed | proposed-only | data/hardware-gated; claim gutted by ADR-159 |
| ADR-104 | RuView MCP Server + CLI Distribution | Accepted | partial | depends on Proposed ADR-103 for count tool |
| ADR-105 | Federated learning for RuView CSI personalization | Proposed | proposed-only | head of 105-108 chain, none implemented |
| ADR-106 | Differential privacy + biometric isolation | Proposed | proposed-only | extends Proposed 105 |
| ADR-107 | Cross-installation federation w/ secure aggregation | Proposed | proposed-only | classical DH later superseded by 108 |
| ADR-108 | Kyber PQ key exchange for federation | Proposed | proposed-only | extends Proposed 107 (parent unimplemented) |
| ADR-109 | Dilithium PQ signatures for cog distribution | Proposed | proposed-only | extends ADR-100; sister of 108 |
| ADR-110 | ESP32-C6 firmware extension (Wi-Fi 6 CSI, 802.15.4, TWT, LP) | Accepted — P1-P10 complete v0.7.0 | implemented | HE-CSI needs ESP-IDF ≥5.5 (v5.4 downconverts to HT) |
| ADR-113 | Multistatic anchor placement strategy | Proposed | proposed-only | amends ADR-029; simulation-derived not HW-validated |
| ADR-114 | cog-quantum-vitals | Proposed | proposed-only | hardware-gated (nvsim today, real NV-diamond in prod); R13 NEGATIVE |
| ADR-115 | Home Assistant via MQTT auto-discovery + Matter bridge | Accepted (MQTT) / Proposed (Matter) | partial | mixed status; Matter deferred to v0.7.1 |
| ADR-116 | HA + Matter as a Cognitum Seed cog (cog-ha-matter) | Proposed — P2 scaffold compiles | partial | provisional; Matter deferred to v0.8 |
| ADR-117 | pip wifi-densepose via PyO3 + maturin | Proposed | proposed-only | current PyPI v1.1.0 stale; tracking issue TBD |
| ADR-118 | BFLD — Beamforming Feedback Layer for Detection | Proposed | proposed-only | umbrella; sub-ADRs 119-123 |
| ADR-119 | BFLD Frame Format and Wire Protocol | Proposed | proposed-only | child of Proposed 118 |
| ADR-120 | BFLD Privacy Class and Hash Rotation | Proposed | proposed-only | child of Proposed 118 |
| ADR-121 | BFLD Identity Risk Scoring and Coherence Gate | Proposed | proposed-only | abandonment risk; data-gated (KIT BFId dataset) |
| ADR-122 | BFLD RuView Surface — HA/Matter/MQTT | Proposed | proposed-only | abandonment risk; depends on Soul Signature + cog-ha-matter |
| ADR-123 | BFLD Capture Path — Pi5/Nexmon, ESP32 feasibility | Proposed | proposed-only | hardware-gated (ESP32 cannot sniff CBFR) |
| ADR-124 | rvagent — MCP + ruvector npm lib (SENSE-BRIDGE) | Proposed | proposed-only | abandonment risk; not published; open questions |
| ADR-125 | RuView ↔ Apple Home native HAP bridge | Proposed | proposed-only | abandonment risk; hardware-gated (same-L2 pairing) |
| ADR-126 | HOMECORE — Rust+WASM+TS port of Home Assistant | Proposed | proposed-only | multi-quarter; series map cites missing 131/132 + mis-numbered 134 |
| ADR-127 | HOMECORE-CORE — state machine, registries, event bus | Proposed | proposed-only | future-dated Q3 2026 |
| ADR-128 | HOMECORE-PLUGINS — WASM integration plugin system | Proposed | proposed-only | future-dated; depends on 127 ABI freeze |
| ADR-129 | HOMECORE-AUTO — automation engine + template eval | Proposed | proposed-only | future-dated; broken cross-ref to ADR-134 |
| ADR-130 | HOMECORE-API — wire-compatible REST + WS | Proposed | proposed-only | future-dated; wire-compat needs HA companion-app suite |
| ADR-133 | HOMECORE-ASSIST — voice/intent + Ruflo bridge | Proposed | partial | missing tracking issue; P1 partial build, P2 deferred |
| ADR-134 | First-Class Channel Impulse Response (CIR) Support | Proposed | proposed-only | DUPLICATE IDENTITY (126/129 cite 134 as HOMECORE-MIGRATE); hardware-gated |
| ADR-135 | Empty-Room Baseline Calibration | Proposed | proposed-only | hardware-gated (COM9/COM12 + 802.15.4 sync) |
| ADR-136 | RuView Rust Streaming Engine — Architecture/Contracts | Proposed | partial | status-contradiction: §8 says Built (commit 11f89727f, 9 tests) |
| ADR-137 | Fusion Engine Quality Scoring | Proposed | partial | status-contradiction: Built (commit 4fa3847ac, 6 tests) |
| ADR-138 | WiFi-7 MLO LinkGroup + ArrayCoordinator gating | Proposed | partial | status-contradiction: Built (commit fc7674bde, 8 tests) |
| ADR-139 | WorldGraph — Environmental Digital Twin | Proposed | partial | status-contradiction: Built (commit 521a012d8, 7 tests) |
| ADR-140 | Semantic State Record + Ruflo Agent Bridge | Proposed | partial | status-contradiction: Built (commit 169a355bd, 4 tests); Rest kind not built |
| ADR-141 | BFLD Privacy Control Plane | Proposed | partial | header stale vs Implementation note (commit 7d88eb84c, 6 tests) |
| ADR-142 | Evolution Tracker + Temporal VoxelMap | Proposed | partial | header stale vs note (commit 1f8e180d6, 6 tests) |
| ADR-143 | RF SLAM v2 — Reflector Discovery + Anchor Learning | Proposed | partial | header stale (commit 2d4f3dea5); v2 dormant behind 7-day validation |
| ADR-144 | UWB Range-Constraint Fusion | Proposed | partial | header stale (commit b10bc2e9a); no UWB radio in fleet |
| ADR-145 | Ablation Evaluation Harness | Proposed | partial | referenced as existing by 149/150/151; F4/UWB variant HW-gated |
| ADR-146 | RF Encoder Multi-Task Heads + Uncertainty | Proposed | proposed-only | no Impl note (unlike 141-144); depends on tch/libtorch |
| ADR-147 | adam-mode — light theme toggle | Proposed | proposed-only | DUPLICATE NUMBER (3 files); referenced as landed by 148-yoga |
| ADR-147 | Occupancy World Model (OccWorld/RoboOccWorld) | Accepted | partial | DUPLICATE NUMBER; self-revised from Cosmos; Phase B gated |
| ADR-147 | Benchmark Proof — OccWorld on RTX 5080 | (none) | unknown | MISSING STATUS; DUPLICATE NUMBER; baseline-without-fine-tuning (random weights) |
| ADR-148 | Drone Swarm Control System | In Progress | partial | DUPLICATE NUMBER; re-routes 147 Cosmos item to 149 |
| ADR-148 | yoga-mode — pose detection/scoring demo | Proposed | proposed-only | DUPLICATE NUMBER; no tracking issue |
| ADR-149 | AetherArena — Spatial-Intelligence Benchmark (HF) | Accepted | partial | DUPLICATE NUMBER; external repo out-of-tree; Wi-Pose dropped |
| ADR-149 | Drone Swarm Benchmarking Methodology | Accepted (peer-reviewed) | partial | DUPLICATE NUMBER; critiques 148's own numbers |
| ADR-150 | RuView RF Foundation Encoder | Proposed | partial | status Proposed but cites measured 81.63% in-domain vs ~11.6% cross-subject |
| ADR-151 | Per-Room Calibration & Specialized Model Training | Accepted — Stages 1-5 impl | partial | HF-backbone distillation pending |
| ADR-152 | WiFi-Pose SOTA 2026 Intake | Proposed | partial | header stale; §2.1-2.3/2.6 impl, WiFlow-STD ~96% PCK; 1/25 claim REFUTED |
| ADR-153 | IEEE 802.11bf-2025 Forward-Compat Protocol Model | accepted | implemented | amends ADR-152 §2.4; OTA/silicon binding deferred |
| ADR-154 | Signal/DSP Beyond-SOTA Sweep — M0 | Proposed | partial | header likely stale; discloses dead CIR coherence gate; ~45 deferred |
| ADR-155 | NN/Training Beyond-SOTA Sweep — M1 | Proposed | partial | header likely stale; retracts synthetic-val/fake-gradient/self-cert proof |
| ADR-156 | RuVector/Cross-Viewpoint Fusion Sweep — M2 | Proposed | partial | header likely stale; one staged finding is numeric no-op |
| ADR-157 | Hardware/Sensing-Acquisition Sweep — M3 | Proposed | partial | header likely stale; headline negative result (layer already hardened) |
| ADR-158 | MAT/World-Model Cluster Sweep — Anti-AI-Slop | accepted | implemented | life-safety; fixes triage inflation; some paths DATA-GATED |
| ADR-159 | Cognitum Appliance Cluster Sweep — Anti-AI-Slop | accepted | implemented | person-count training_class1_accuracy = 0.343; description renamed |
| ADR-160 | Edge Skill Library (wasm-edge) — Honest Labeling | accepted | implemented | medical/affect/weapon NOT validated — relabelled |
| ADR-161 | HOMECORE Server — WS Auth Bypass, Reply-Theater | accepted | implemented | CRITICAL WS auth bypass fix; amends 130/129/128 |
| ADR-162 | HOMECORE Plugin Security + Bounded RunModes | accepted | implemented | security-critical; enforces ADR-161 deferrals |
| ADR-163 | Edge-Latency Measurement — CLAIMED→MEASURED | accepted | implemented | ESP32/Xtensa figure remains UNMEASURED (hardware-gated) |
+316
View File
@@ -0,0 +1,316 @@
# ADR Corpus Gap Analysis — Five-Lens Findings
Research notes backing ADR-164. Each lens output is reproduced verbatim. Census: 162 ADR entries across 156 distinct files (6 duplicate-number collisions verified on disk).
---
## Lens 1: status-distribution
Confirmed: ADR-147-benchmark-proof.md and ADR-134-csi-to-cir have no `Status` line in their headers (the 052-ddd hits are Rust code in the body, not a header; the ADR-052 appendix lacks a real Status header per its first lines). Findings are evidence-grounded. Final analysis below.
### ADR Corpus — Status & Implementation Distribution
Census: **162 ADR entries** across **156 distinct files** (6 duplicate-number collisions verified on disk).
#### Status tally
| Status bucket | Count |
|---|---|
| Accepted (incl. "Accepted — partial/in-progress/Phase 1" variants) | ~56 |
| Proposed (incl. "Proposed — conditional/research-only") | ~88 |
| Superseded | 1 (ADR-002) |
| Rejected | 1 (ADR-098) |
| Missing / no Status header | 3 (ADR-147-benchmark-proof, ADR-052-ddd appendix, ADR-134-CIR) |
| Mixed/dual status in one ADR | 3 (ADR-115, ADR-149-AetherArena vs swarm, ADR-133) |
#### impl_state tally
| impl_state | Count |
|---|---|
| implemented | ~36 |
| partial | ~50 |
| proposed-only | ~64 |
| stale-or-contradicted | 3 (ADR-029, 030, 031) |
| unknown | 5 (ADR-034, 044, 052-ddd, 147-proof, …) |
| superseded | 1 (ADR-002) |
**Headline:** ~114 of 162 ADRs (70%) are decisions that never fully landed (proposed-only + partial + stale + unknown). The dominant failure mode is **stale Status headers** — Accepted/implemented work still labeled "Proposed."
#### SEVERITY: CRITICAL — Status header missing or structurally absent (cannot triage)
- **ADR-147-benchmark-proof.md***No `Status` header at all* (grep confirmed). Not a true ADR; it's a benchmark artifact (OccWorld @ ~213ms on RTX 5080, random weights) misfiled under the ADR-147 number. **Action: relocate to `docs/proof/` or `benchmarks/`, remove ADR number.**
- **ADR-134-csi-to-cir-time-domain-multipath.md***No `Status` header* (grep confirmed) in the header region. Body says Proposed but the field is not in canonical position. Compounded by a **number collision**: ADR-126/129 reference "ADR-134" as HOMECORE-MIGRATE, but the on-disk file is CIR. **Action: add canonical `## Status` line; resolve the 134 identity split.**
- **ADR-052-ddd-bounded-contexts.md** — Appendix doc with no Status/Date header (grep found only Rust code, no header field). **Action: mark explicitly "Appendix to ADR-052 (no independent status)".**
#### SEVERITY: CRITICAL — Duplicate ADR numbers (6 collisions, all verified on disk)
| Number | Colliding files | Action |
|---|---|---|
| **147** | adam-mode-light-theme · nvidia-cosmos/OccWorld · benchmark-proof | Renumber 2 of 3 |
| **148** | drone-swarm-control-system · yoga-mode-pose-system | Renumber 1 |
| **149** | AetherArena-leaderboard · swarm-benchmarking | Renumber 1 |
| **050** | provisioning-tool-enhancements · quality-engineering-security-hardening | Renumber 1 |
| **052** | tauri-desktop-frontend · ddd-bounded-contexts (appendix) | Demote appendix |
| **134** | csi-to-cir (on disk) · HOMECORE-MIGRATE (referenced, no file) | Resolve identity |
These break the ADR index and `/adr` tooling — two ADRs answering to one number is a corpus-integrity defect, not cosmetics.
#### SEVERITY: HIGH — Status header stale vs. shipped reality (Proposed header on landed code)
These are the most dangerous: an auditor reading the header concludes "not built" when code + tests exist. Ranked by blast radius:
1. **ADR-136 → ADR-145** (streaming-engine series, 10 ADRs) — every header says `Proposed` but each `§ Implementation Status` reports **"Built" with pinned commits + passing tests** (136: 11f89727f; 137: 4fa3847ac; 138: fc7674bde; 139: 521a012d8; 140: 169a355bd; 141: 7d88eb84c; 142: 1f8e180d6; 143: 2d4f3dea5; 144: b10bc2e9a; 145 referenced as landed by 149/150/151). **Bulk action: flip headers to "Accepted — partial (integration glue pending)".**
2. **ADR-029 / 030 / 031** (RuvSense/field-model/cross-viewpoint) — `Proposed` but repo has `signal/src/ruvsense/` (16 modules) and `ruvector/src/viewpoint/`, and **Accepted ADR-032 hardens them** — an Accepted ADR depending on Proposed parents (status-graph inversion).
3. **ADR-095 / 096** (rvCSI) — `Proposed` but ADR-097 confirms built, extracted to own repo, published 0.3.1 to crates.io/npm.
4. **ADR-152**`Proposed` but CLAUDE.md + recent commits report §2.12.3/2.6 implemented, WiFlow-STD MEASURED-EQUIVALENT ~96% PCK.
5. **ADR-154/155/156/157** (beyond-SOTA sweeps) — `Proposed` but each describes fixes **already landed with revert-verified regression tests**.
6. **ADR-024 (AETHER) / 027 (MERIDIAN) / 072 (WiFlow)**`Proposed` but CLAUDE.md lists them Accepted and code references them as implemented.
7. **ADR-017** — header Accepted but CLAUDE.md still calls it "Proposed" (inverse drift).
8. **ADR-018**`Proposed` but ADR-012 cites it as the working firmware/aggregator impl.
#### SEVERITY: HIGH — Status ahead of its dependencies (Accepted depends on Proposed)
- **ADR-032** Accepted → depends on Proposed 029/030/031.
- **ADR-053** Accepted → depends on Proposed ADR-052.
- **ADR-048** Accepted → depends on Proposed ADR-045.
- **ADR-077** Accepted → depends on Proposed ADR-075/076.
#### SEVERITY: MEDIUM — Proposed-but-looks-abandoned (decisions that will likely never land)
Cluster heads where the whole chain is Proposed with zero implementation evidence:
- **ADR-003/007/008/009/010** — RuVector child ADRs orphaned after parent ADR-002 was superseded by 016/017.
- **ADR-105/106/107/108** — entire federation chain, none implemented.
- **ADR-118/119/120/121/122/123** — entire BFLD chain, all ACs unchecked, tracking issues TBD.
- **ADR-124/125/126/127/128/129/130/133** — HOMECORE/bridge chain, multi-quarter future-dated, all TBD.
- **ADR-033** (remote-viewing), **ADR-042** (CHCI, superseded-in-intent by 153), **ADR-046** (Android TV), **ADR-049** (Python v1 legacy), **ADR-067** (RuVector v2.0.5 upgrade not adopted), **ADR-074** (SNN), **ADR-085** (RaBitQ expansion), **ADR-011** (Proposed-URGENT despite proof pipeline live).
#### SEVERITY: MEDIUM — Accepted but no implementation evidence (unverified "done")
- **ADR-034** (FieldView mobile app) — Accepted, no crate/dir in CLAUDE.md.
- **ADR-044** (wifi-densepose-geo) — bare Accepted, no Date/Deciders, crate not in CLAUDE.md table.
#### Ranked actionable backlog (do in this order)
1. **Resolve 6 duplicate ADR numbers + 3 missing-header files** (CRITICAL — breaks the index/tooling). Renumber 147×2, 148, 149, 050; demote 052-ddd appendix; resolve the 134 identity split; add Status headers to 147-proof, 134, 052-ddd.
2. **Bulk-flip the 10 streaming-engine headers (ADR-136145)** from Proposed → "Accepted — partial" — they have commit-pinned, test-backed Implementation Status notes. Highest ROI: one batch fixes the largest stale-status cluster.
3. **Fix the status-graph inversions** (032/053/048/077 depend on Proposed parents; promote parents 029/030/031/045/052/075/076 to match their built reality, or downgrade the dependents).
4. **Reconcile CLAUDE.md vs ADR headers** for 017, 024, 027, 072, 152 (doc says one thing, header another).
5. **Triage the abandoned chains** (003/007010, 105108, 118123, HOMECORE 124133) — either mark `Rejected`/`Deferred` or open tracking issues; leaving ~50 Proposed-forever ADRs pollutes every gap analysis.
6. **Verify or downgrade unsubstantiated Accepteds** (034, 044).
**Bottom line:** the corpus's #1 defect is not abandoned work — it's **bookkeeping drift**. ~24 ADRs are mislabeled Proposed while code+tests exist, 6 numbers collide, and 3 files lack Status headers entirely. The status field cannot currently be trusted as a source of truth for what is built.
---
## Lens 2: supersession-chains
I have all evidence needed. ADR-016 doesn't mention ADR-002 at all (only 014/015); ADR-017 discusses ADR-002 as a "correction" but never uses the word "supersede." This confirms the broken reciprocity. Final analysis below.
### Supersession & Lifecycle Integrity — ADR Corpus
Total declared supersession edges in census: **3** (ADR-002→016, ADR-002→017, ADR-052-tauri→054). All three were file-verified. Findings below; severity graded **CRITICAL / HIGH / MEDIUM / LOW**.
#### Reconstructed chains
- **ADR-002** (RuVector RVF Integration Strategy) → superseded-by **ADR-016 + ADR-017** (dual realization). Self-declared `supersedes` on 016/017.
- **ADR-052-tauri** (Tauri Desktop Frontend) → superseded-by **ADR-054** (declared in 052's `superseded_by`).
- No other formal `supersedes`/`superseded_by` links exist. No cycles detected (the only multi-node graph, ADR-002→{016,017}, is a DAG; ADR-052→054 is a single edge). **No cycles — clean.**
#### Broken / asymmetric links
**1. ADR-002 → ADR-016 / ADR-017: one-directional, never reciprocated. (HIGH)**
ADR-002 header declares "Superseded by [ADR-016] and [ADR-017]" (`docs/adr/ADR-002-ruvector-rvf-integration-strategy.md:4`). But neither successor claims it:
- **ADR-016** (`ADR-016-ruvector-integration.md`) never mentions ADR-002 anywhere — its `## References` lists only ADR-014/015. It does not assert supersession; the census `supersedes:["ADR-002"]` for ADR-016 is **unsupported by the file**.
- **ADR-017** (`ADR-017-ruvector-signal-mat-integration.md`) discusses ADR-002 only as a `## Correction to ADR-002 Dependency Strategy` (line 532) — corrects "fictional crate names" — but **never uses the word "supersede."** Census `supersedes:["ADR-002"]` is again file-unsupported.
- Net: ADR-002 points up at two ADRs that don't point back. The supersession is asserted by the superseded ADR alone — backwards from convention, and unverifiable from the successors.
**2. ADR-002 partial-supersession leaves 5 orphaned children stranded. (HIGH)**
ADR-002 is an umbrella whose children ADR-003, 007, 008, 009, 010 are still `Proposed`. ADR-016/017 only realize the *training/signal/MAT* integration points (mincut, attention, solver, etc.). The RVF-container (003), PQ-crypto (007), Raft consensus (008), WASM edge runtime (009), and witness-chains (010) decisions are **neither implemented nor formally superseded** — ADR-017:555 explicitly acknowledges 008/009 "described in ADR-002" are not carried forward. Marking the parent fully "Superseded" silently buries 5 live-but-abandoned child decisions. ADR-010's role is additionally filled de facto by ADR-028's witness-bundle without any supersession link.
**3. ADR-052-tauri → ADR-054: declared by predecessor, not acknowledged by successor. (HIGH)**
Census records ADR-052-tauri `superseded_by:["ADR-054"]`. **ADR-054 (`ADR-054-desktop-full-implementation.md`) contains zero references to ADR-052** (grep for `ADR-052|replac|supersed` returns nothing). ADR-054 is titled "RuView Desktop **Full Implementation**" and is "in progress" — functionally it's the implementation plan *for* 052, not a replacement. The supersession edge is unconfirmed by the successor and arguably mis-modeled (an in-progress impl doesn't supersede its own design ADR).
#### Orphaned superseded ADRs still marked accepted/active
**4. No classic orphan (superseded ADR still `Accepted`), but two soft variants: (MEDIUM)**
- **ADR-052-tauri** is `Proposed` *and* `superseded_by ADR-054`, yet downstream ADR-053/055/056 (all `Accepted`) build on it and treat the desktop app as shipped (v0.3.0). A Proposed-and-superseded ADR anchoring three Accepted descendants is a lifecycle inconsistency: the live decision-of-record is ambiguous (052? 054? 056?).
- **ADR-002** is correctly `Superseded`, so not an orphan — but ADR-038's roadmap census still counts it among 37 active ADRs, so stale references persist downstream.
#### De-facto supersessions never recorded (missing links) — MEDIUM
These pairs behave as supersession in the corpus but carry **no** `supersedes`/`superseded_by` fields, so the chain graph understates reality:
- **ADR-098 ⇄ ADR-099** (`MEDIUM`): ADR-098 **Rejected** midstream; ADR-099 revives its carve-outs ("Adopt midstream…"). A rejection partially reversed by a later Proposed ADR — neither links the other via supersession fields (only prose tension).
- **ADR-063 → ADR-064**, **ADR-035 → ADR-023/036**, **ADR-042 → ADR-153**, **ADR-050-provisioning → ADR-060**, **ADR-117 retracts PyPI v1.1.0**, PQ chain **ADR-100 → ADR-109 / ADR-107→108**: each is "superseded in scope/intent" per flags but has empty supersession fields. Not broken links (none falsely asserted), but **missing** ones — the lifecycle graph is incomplete.
#### Cycles
**None.** ADR-002→{016,017} and ADR-052→054 are acyclic; no ADR is reachable from its own successor.
#### Severity summary
| # | Issue | Severity |
|---|-------|----------|
| 1 | ADR-002 supersession not reciprocated by ADR-016/017 (016 never mentions 002; 017 only "corrects") | HIGH |
| 2 | ADR-002 children 003/007/008/009/010 stranded under a "fully superseded" parent | HIGH |
| 3 | ADR-052→054 edge unacknowledged by ADR-054 (no reference); likely mis-modeled (impl, not replacement) | HIGH |
| 4 | ADR-052-tauri Proposed+superseded yet anchors Accepted 053/055/056 | MEDIUM |
| 5 | De-facto supersessions (098/099, 063/064, 042/153, 050/060, 035/023, 100/109) unrecorded | MEDIUM |
| — | Cycles | none (clean) |
Highest-priority fix: add reciprocal `supersedes: ADR-002` language to ADR-016/017 (or downgrade the claim), and split ADR-002's status to "partially superseded" so children 003/007/008/009/010 aren't silently closed.
---
## Lens 3: contradictions
All findings confirmed against source. Here is the contradictions lens.
### Contradictions & Retractions Lens — ADR Corpus
The sweep (ADR-154163) is itself a structured retraction layer: each "Beyond-SOTA / anti-AI-slop" ADR exists *because* an earlier accepted claim was found false. Findings graded **CRITICAL** (life-safety, security, or a published accuracy number that was meaningless) / **HIGH** (a capability/number retracted or directly contradicted) / **MEDIUM** (status or scope conflict) / **LOW** (cosmetic/doc drift).
#### A. Accepted/published claims later RETRACTED or REFUTED
**[CRITICAL] ADR-155 retracts every prior NN accuracy/TTA/proof claim.** ADR-155 §2.2 discloses `bin/train.rs` validated a *real* MM-Fi training run against a **synthetic** val set, and windows leak at stride-1 (~99% overlap) — *"any PCK it printed was meaningless on two counts."* §2.3: `rapid_adapt.rs` `contrastive_step`/`entropy_step` wrote a **fake gradient** (`grad += v * 0.01`) unrelated to the objective — every "TTA improves the metric" result was unsupported. §2.4: the deterministic proof **self-certified** (`generate_expected_hash` blessed whatever the pipeline emitted; PASS counted any loss decrease incl. 1e-9 float noise; missing hash defaulted to PASS). This retroactively voids accuracy claims made anywhere in the corpus that depended on the training/proof path prior to commit landing ADR-155.
**[CRITICAL] ADR-154 retracts the ADR-134 CIR coherence gate as live.** ADR-152/CLAUDE.md present CIR (ADR-134) as a contributing signal in the multistatic coherence gate. ADR-154 §2 proves it was **DEAD in production for every canonical frame**: the HT20 CIR estimator returns `SubcarrierMismatch` on all 56-tone canonical frames (`cir_gate_ht20_is_dead_on_canonical56`: 0 Ok / 8 mismatch), so `coherence = 0.7·freq + 0.3·dominant_tap_ratio` silently degraded to freq-only (`cir_gate_dead_ht20_equals_gate_off`, |Δ|<1e-9). Any ADR claiming CIR-enhanced coherence/ToF before this fix overstated reality.
**[CRITICAL] ADR-079 internal accuracy contradiction (self-flagged in census, confirmed).** Context states proxy PCK@20 = **2.5%** (lines 11, 25) and "10-20x improvement: 2.5% → 35%+". The baseline table (line 497) reports proxy PCK@20 = **35.3%** — i.e. the *baseline already equals the stated target* — while per-joint upper body (nose/shoulders/wrists) is **0%** (line 503). The headline 1020x improvement number is therefore self-refuting against its own baseline table. CLAUDE.local.md adds the local-Windows attempt (#640) measured **0% PCK**. An Accepted ADR with three mutually inconsistent values for its own central metric.
**[HIGH] ADR-152 self-refutes one verified research claim (F4).** ADR-152 grades 25 claims 3-vote; §F4 records the "Espressif `esp_wifi_sensing` is **drop-in compatible with RuView nodes**" claim **REFUTED 0-3** (WiFi-6 parts use a different CSI acquisition config struct). ADR-110 ("ESP32-C6 Wi-Fi 6 CSI") and the CLAUDE.md hardware table treat C6/Wi-Fi-6 CSI as a smooth extension; ADR-152 also notes HE-CSI needs ESP-IDF ≥5.5 (v5.4 silently downconverts to HT). The "WiFlow-STD MEASURED-EQUIVALENT ~96% PCK@20" line in CLAUDE.md is *not* yet supported: §2.2/§F1 mark external pose numbers (incl. the 97.25% WiFlow-STD figure) **CLAIMED**, and §F1 explicitly forbids citing 97.25% as comparable until measurements (a)(c) are run. CLAUDE.md asserting "MEASURED-EQUIVALENT" contradicts the ADR's own gating.
**[HIGH] ADR-150 retracts the implied cross-subject capability of the encoder line.** AETHER/MERIDIAN ADRs (024/027) and the foundation-encoder framing imply subject-invariant embeddings work. ADR-150 measures **81.63% in-domain vs ~11.6% leakage-free cross-subject** torso-PCK, and reports DANN **failed** (27.26%→27.54%, empirically ~0 gain) and bigger capacity *hurt* (transformer 24.8% < conv 27.3%). §1.1/§4 conclude the cross-subject acceptance gate "is **unlikely to be met without new multi-subject** data" — a direct retraction of the "more capacity / adversarial alignment solves cross-environment loss" premise underlying ADR-027.
**[HIGH] ADR-159 refutes the "never identified anyone" accusation but simultaneously retracts cog-person-count's marketing.** ADR-159 ships real SHA-pinned Candle models, but discloses person-count `training_class1_accuracy = 0.343` (presence-only, classes 0/1), and **renames** the Cargo description from "learned multi-person counter" → "presence detector + (data-gated) person count," clamping/`low_confidence`-flagging multi-occupant counts. This retracts ADR-103's "learned multi-person counter (SOTA WiFi CSI counting)" claim and ADR-104's count tool, which depended on it.
**[HIGH] ADR-161 retracts HOMECORE server security + functionality claims.** ADR-130 (HOMECORE-API, wire-compatible, Ed25519-JWT) implied a secured server. ADR-161 fixes a **CRITICAL WebSocket auth bypass** (any non-empty token accepted), "reply-theater" (WS responses computed then discarded), and documented-but-no-op automation — then ADR-162 enforces the ADR-161 deferrals (plugin Ed25519 sig verification, capability isolation, bounded RunModes that were "parsed-but-unenforced/unbounded-parallel"), retracting ADR-128/129's implied plugin-signing and automation guarantees.
**[MEDIUM] ADR-163 converts CLAIMED latency budgets to MEASURED — retracting prior budget citations.** ADR-160/159 cited wasm-edge/cog latency *budgets*. ADR-163 adds host benches and explicitly states the **ESP32/Xtensa-on-hardware figure remains UNMEASURED** — so any doc citing the device latency budget as achieved is unsupported.
**[MEDIUM] ADR-098 → ADR-099 partial reversal.** ADR-098 **Rejected** midstream as a system component; ADR-099 (Proposed) **adopts** midstream's temporal-compare (DTW) + temporal-attractor-studio as a parallel tap. Framed as "complementary," but it revives the exact carve-outs ADR-098 declined to integrate — a live decision conflict pending resolution.
**[MEDIUM] ADR-147 (OccWorld) self-retracts Cosmos.** The accepted ADR-147 title/decision was revised from "NVIDIA Cosmos WFM Integration" to OccWorld after a hardware finding (Cosmos needs 32.5 GB VRAM); Cosmos is retracted as primary. The companion ADR-147-benchmark-proof reports 213 ms/inference on **random weights, no checkpoint** — a baseline-without-fine-tuning number that must not be cited as a quality/target metric.
#### B. Pairs making CONFLICTING decisions on the same topic
**[HIGH] RVF-WASM edge runtime — ADR-009 vs shipped `wifi-densepose-wasm`.** ADR-009 (Proposed) decides to **replace** the existing wifi-densepose-wasm approach with an `.rvf.edge` container runtime. The crate it proposes to replace is shipped and in the CLAUDE.md crate table (and is the dependency base for ADR-058/059 browser pose). ADR-009 is an unrealized decision directly contradicting shipped architecture.
**[HIGH] Witness/audit mechanism — ADR-010 vs ADR-028.** ADR-010 (Proposed) decides RuVector witness *chains* as "the primary tamper-evident audit mechanism." ADR-028 (Accepted, implemented) established a different **witness-bundle** mechanism (verify.py / SHA-256 / VERIFY.sh) that fills this role. Two competing "primary audit" decisions; ADR-010 is stranded.
**[HIGH] Multistatic "sensing-first RF mode" — ADR-029 vs ADR-031 near-duplicate scope.** Both decide a "sensing-first RF mode for multistatic fidelity": ADR-029 (RuvSense, signal/src/ruvsense/) and ADR-031 (RuView cross-viewpoint fusion, ruvector/src/viewpoint/). Overlapping problem statements (occlusion/depth/multi-person via multistatic attention+geometry), separate crate homes, both still nominally "Proposed" while both are implemented. Unreconciled dual ownership of the multistatic-fusion decision.
**[MEDIUM] Person-counting decision conflict — ADR-037 vs ADR-075 vs ADR-103.** Three different decisions to replace the same fixed-threshold counter: ADR-037 (4-phase neural decomposition), ADR-075 (spectral min-cut over subcarrier-correlation graph, fixes #348), ADR-103 (learned Cog `cog-person-count`). ADR-075's bug (#348) overlaps ADR-069's driver. None supersedes the others; ADR-159 then guts ADR-103's claim (above).
**[MEDIUM] PQ-crypto signing — ADR-007 vs ADR-109.** ADR-007 (Proposed) decides Ed25519 + ML-DSA-65 hybrid for sensing-data signing; ADR-109 (Proposed) decides Ed25519 + **Dilithium-3** hybrid for cog signing (Dilithium = ML-DSA family but a different parameter pick/scope). Two PQ-signature decisions over adjacent surfaces with non-identical algorithm choices, neither reconciled.
**[MEDIUM] Federation key-exchange self-supersession — ADR-107 vs ADR-108.** ADR-107 adopts classical Diffie-Hellman in secure-aggregation Layer 4; ADR-108 replaces it with Kyber-768 because the DH choice is "quantum-vulnerable." ADR-108 supersedes a core element of ADR-107 while ADR-107 is still only Proposed — a decision corrected before it was ever accepted.
**[MEDIUM] Provisioning path forked three ways — ADR-050(prov) vs ADR-060 vs ADR-052/054.** ADR-050 (provisioning-tool-enhancements, Proposed) scopes channel+MAC-filter flags; ADR-060 (Accepted) actually implements them; ADR-052/054 move provisioning into a Rust-native Tauri desktop path. Three live decisions for "how RuView provisions nodes," with ADR-060 partially fulfilling ADR-050 without superseding it.
#### C. Status-graph contradictions (Accepted depending on / contradicting Proposed)
**[MEDIUM] Accepted ADRs hardening/depending on Proposed ones.** ADR-032 (Accepted, security hardening) hardens ADR-029/030/031 which remain "Proposed" — an accepted decision presupposing un-accepted ones exist. Same pattern: ADR-048 (Accepted) depends on ADR-045 (Proposed); ADR-053 (Accepted) depends on ADR-052 (Proposed); ADR-077 (Accepted) depends on ADR-075/076 (Proposed); ADR-104 (Accepted) depends on ADR-103 (Proposed). These are status contradictions, not capability retractions, but they signal the same "header lags reality" hygiene problem the sweep is correcting.
**[LOW] Header-stale-vs-implementation (pervasive).** ADR-029/030/031, 072, 095/096, 136145, 150, 152, 154157 all carry `Status: Proposed` while their own appended Implementation-Status notes (or downstream ADRs / CLAUDE.md) report them built+tested with commits. ADR-024/027 say Proposed; CLAUDE.md lists them Accepted; pose_tracker.rs already uses AETHER re-ID. Cosmetic but corpus-wide; it is the mechanism by which retracted/overstated claims survive (a green "built" note under a "Proposed" header is exactly where ADR-155's self-certifying proof hid).
#### Cited source files (absolute)
- C:\Users\ruv\Projects\wifi-densepose\docs\adr\ADR-079-camera-ground-truth-training.md (lines 11/25/497/503 — 2.5% vs 35.3% vs 0%)
- C:\Users\ruv\Projects\wifi-densepose\docs\adr\ADR-150-rf-foundation-encoder.md (81.63% vs 11.6%; DANN ~0)
- C:\Users\ruv\Projects\wifi-densepose\docs\adr\ADR-152-wifi-pose-sota-2026-intake.md (F4 REFUTED 0-3; 97.25% CLAIMED-not-MEASURED)
- C:\Users\ruv\Projects\wifi-densepose\docs\adr\ADR-154-signal-dsp-beyond-sota.md (§2 dead CIR gate)
- C:\Users\ruv\Projects\wifi-densepose\docs\adr\ADR-155-nn-training-beyond-sota.md (§2.22.4 synthetic-val / fake gradient / self-certifying proof)
- C:\Users\ruv\Projects\wifi-densepose\docs\adr\ADR-159-cognitum-appliance-beyond-sota.md (person-count 0.343; description renamed)
**Top-severity summary:** the four CRITICAL items (ADR-155 fake-gradient+synthetic-val+self-certifying proof; ADR-154 dead CIR gate; ADR-079 self-inconsistent PCK; ADR-161 WS auth bypass) are the corpus's load-bearing "AI-slop" admissions — each is an *accepted-or-shipped* surface whose stated accuracy/security/function was provably false until the sweep landed. Every accuracy number predating ADR-155 in any other ADR should be treated as CLAIMED, not MEASURED, until re-derived through the post-155 leak-free split.
---
## Lens 4: coverage-gaps
Confirmed — ADR-094 governs the pointcloud *viewer deployment* (proposed-only), not the crate's sensing-data-production contract. I have all evidence needed.
### Coverage Gaps — Crates/Capabilities vs Governing ADRs
Severity: **CRITICAL** (shipped code with no/broken governing ADR), **HIGH** (architect would expect an ADR, none exists), **MEDIUM** (governed only by a remediation/deploy ADR, no creation/architecture ADR), **LOW** (minor).
#### A. Shipped crates whose cited ADR does not exist (CRITICAL)
Two crates are built and in-tree but reference ADR numbers that point to *different* on-disk ADRs or to files that never existed (confirmed: no `ADR-131*.md` or `ADR-132*.md` exists; `ADR-134` on disk is CIR, not HOMECORE-MIGRATE):
- **`v2/crates/homecore-recorder`** — Cargo.toml header: *"SQLite state history + semantic search (ADR-132)"*. **No ADR-132 exists.** The HOMECORE series map (ADR-126 §4) lists ADR-132 HOMECORE-RECORDER as planned, but it was never written. A shipped persistence/history crate has zero governing decision record. **CRITICAL** — this is the recorder, the durable-state surface, ungoverned.
- **`v2/crates/homecore-migrate`** — Cargo.toml header: *"Implements ADR-134 (HOMECORE-MIGRATE)"*. **On-disk ADR-134 is "First-Class CIR Support"** (census + glob confirm). ADR-129/126 also cite ADR-134 as HOMECORE-MIGRATE. The crate implements a migration tool from Python HA reading `.storage/*.json` — a data-integrity-sensitive importer — governed by a phantom ADR identity. **CRITICAL** (compounds the documented ADR-134 duplicate-number collision).
These are not stale-header issues like the ADR-136..146 cluster (where the ADR exists and is just marked Proposed); here the cited governing ADR **is absent or is a different decision**.
#### B. Shipped crates with NO governing ADR at all (HIGH)
- **`v2/crates/wifi-densepose-engine`** — *"streaming-engine integration layer — composes the ADR-135..146 building blocks into one trust-traceable pipeline cycle."* It composes ~12 ADRs' outputs into the live pipeline-cycle aggregate, but **no ADR governs the composition/orchestration contract itself** (ordering, back-pressure, the "one pipeline cycle" boundary). ADR-136 defines frame contracts/stages but not the integrator crate. An architect would expect an ADR for the seam that wires 135146 onto the live 20 Hz path — exactly the "integration glue not yet on live path" caveat repeated across ADR-136..146. **HIGH.**
#### C. Capabilities governed only by a remediation/deploy ADR — no creation/architecture ADR (MEDIUM)
- **`v2/crates/wifi-densepose-wasm-edge` (~70 edge skills)** — The only ADRs touching it are **ADR-160** (honest *relabeling*/soundness cleanup) and **ADR-163** (latency *measurement*). Both are anti-slop remediation ADRs that presuppose ~70 skills already shipped. There is **no creation/architecture ADR** defining the skill taxonomy, ABI, event-ID allocation, or budget tiers for this crate. (Contrast ADR-041, which *does* catalog the 60-module registry — but for the ESP32/WASM3 on-device path of ADR-040, a different artifact.) A whole ~70-module crate's design rationale lives nowhere. **MEDIUM-HIGH.**
- **`v2/crates/wifi-densepose-occworld-candle`** — *"OccWorld TransVQVAE inference ported to Candle (Rust-native, no Python IPC)."* ADR-147 (OccWorld) decided a **Python-subprocess** thin client and explicitly deferred a Rust backend swap to "Phase B / RoboOccWorld." A native Candle reimplementation is a material architecture change (new dep surface, no IPC, weight-loading path) that **no ADR records the decision to build now**. **MEDIUM.**
- **`v2/crates/wifi-densepose-pointcloud`** — ADR-094 governs only the *GitHub-Pages viewer deployment* (Proposed). The crate as a **point-cloud data-production/format contract** (what it emits, schema, real-data-stream toggle wiring) has no governing decision beyond the demo-deploy doc. **MEDIUM.**
- **`v2/crates/homecore-hap`** — header cites ADR-125 P1 scaffold; ADR-125 (Apple Home HAP bridge) exists and covers it. **Governed — no gap.** (Listed to scope out the false positive.)
- **`v2/crates/wifi-densepose-geo`** — governed by ADR-044 (geospatial). Governed, but ADR-044 is a bare "Accepted" with no implementation evidence and is cross-referenced incorrectly by ADR-052 (cites ADR-044 for provisioning). **LOW** (governed but the ADR itself is thin).
#### D. Decision areas an architect would expect an ADR for, but none exists (HIGH)
1. **Persistence/storage strategy for HOMECORE state history**`homecore-recorder` ships SQLite with an "HA-compat schema," but no ADR decides SQLite-vs-alternatives, retention, or the semantic-search index. Recorder is the durability backbone; an unrecorded storage choice is a classic missing-ADR. **HIGH** (ties to gap A).
2. **Python-HA → HOMECORE migration/import contract**`homecore-migrate` reads foreign `.storage` JSON (untrusted input, schema-drift risk) with no governing ADR (the cited one is CIR). Migration correctness and trust boundary are exactly what an ADR should pin. **HIGH** (ties to gap A).
3. **The streaming-engine *integrator* contract** (`wifi-densepose-engine`) — see B. **HIGH.**
4. **Cross-crate workspace dependency/publishing ADR** — CLAUDE.md lists a hand-maintained 12-step publishing order and a 15-crate table, but the workspace now has **38 crates** (glob count) including ungoverned ones (engine, worldmodel, worldgraph, occworld-candle, geo, wasm-edge, homecore-*, cog-*, ruview-swarm, pointcloud, nvsim-server, desktop). No ADR governs crate-graph topology / publish boundaries at this scale — the publishing list in CLAUDE.md is already stale against reality. **MEDIUM-HIGH.**
5. **No ADR ties the streaming-engine (`engine`) to the cog/appliance deploy surface** — ADR-101/102/159 govern cogs; ADR-136..146 govern the engine; nothing decides how the trust-traceable engine output becomes a deployed cog. The seam between the two largest subsystems is ungoverned. **MEDIUM.**
#### E. Scoped-out false positives (verified governed)
- `wifi-densepose-worldmodel` → ADR-147 (OccWorld bridge). Governed.
- `wifi-densepose-worldgraph` → ADR-139. Governed.
- `cog-ha-matter` → ADR-116; `cog-person-count` → ADR-103; `cog-pose-estimation` → ADR-101. Governed.
- `ruview-swarm` → ADR-148. `nvsim`/`nvsim-server` → ADR-089/092. `wifi-densepose-bfld` → ADR-118123/141. `wifi-densepose-calibration` → ADR-151. All governed.
- `wifi-densepose-desktop` → ADR-052/054 (contested status, but an ADR exists). Not a coverage gap (it's a status-drift issue, out of this lens's scope).
#### Top-priority remediation (concrete)
1. Write **ADR-132 HOMECORE-RECORDER** (or renumber) so the shipped `homecore-recorder` SQLite/history crate has a real governing decision — **CRITICAL**.
2. Resolve the **ADR-134 identity collision** and create a real **HOMECORE-MIGRATE** ADR for `homecore-migrate` (untrusted-import trust boundary) — **CRITICAL**.
3. Write a **streaming-engine integrator ADR** for `wifi-densepose-engine` (the 135146 composition contract / live-path seam) — **HIGH**.
4. Write a **creation/architecture ADR for `wifi-densepose-wasm-edge`** (taxonomy/ABI/event-IDs) — currently only remediation ADRs (160/163) exist — **MEDIUM-HIGH**.
5. Add an ADR for the **`occworld-candle` Rust-native backend swap** (ADR-147 only blessed the Python-subprocess path) — **MEDIUM**.
Evidence files: `v2/crates/homecore-recorder/Cargo.toml`, `v2/crates/homecore-migrate/Cargo.toml`, `v2/crates/wifi-densepose-engine/Cargo.toml`, `v2/crates/wifi-densepose-occworld-candle/Cargo.toml`, `docs/adr/ADR-134-csi-to-cir-time-domain-multipath.md` (CIR, not MIGRATE), `docs/adr/ADR-160-edge-skill-library-honest-labeling.md`, `docs/adr/ADR-094-pointcloud-github-pages-deployment.md`; absence confirmed via glob: no `ADR-131*`, no `ADR-132*`.
---
## Lens 5: data-hardware-gated
Evidence confirmed. I have enough to write the section.
### Open / Gated Decisions — The Real Backlog (data/hardware-gated, in-progress, or unresolved)
Severity = how load-bearing the unresolved decision is to a shipped claim. Sweep coverage = whether ADR-154163 touched it.
#### CRITICAL — life-safety or shipped-claim surface, still gated
**ADR-079 — Camera Ground-Truth Training Pipeline.** *Accepted, but core decision unvalidated.* P7P9 (real paired-data collection, training, cross-room LoRA) are **Pending** (file lines 476478). Blocker: a real synchronized camera+ESP32 paired-capture session and GPU training run — neither done. The ADR's own baseline table is self-contradictory: text says proxy PCK@20=2.5% (lines 11, 25) yet line 497 reports 35.3% (the *target*) with line 503 confessing **upper-body joints at 0%** — the proxy has no real spatial signal. CLAUDE.local.md records the local-Windows attempt (#640) at 0% PCK. The fleet (ruvultra RTX 5080, cognitum-seed-1) is the unblock, but the decision is accepted-on-paper, not proven. **Sweep: NOT addressed** — 154163 never touch the camera-teacher path. Real open backlog item.
**ADR-158 — MAT/World-Model sweep (life-safety).** *Accepted/implemented for the correctness fixes, but capability remains DATA-GATED.* The sweep honestly fixed the dangerous bugs (unified the two divergent triage engines so survivor count can't inflate from repeat detection — lines 4656, 184186), but explicitly grades the actual capabilities as unproven: **RF-through-rubble survivor detection = DATA-GATED** (needs instrumented rubble trials, line 37); **learned multi-person counter = DATA-GATED** on labelled multi-occupant CSI (lines 41, 173); PicoScenes/Intel-5300/Atheros live capture DATA-GATED on NIC/driver hardware (lines 177179). **Sweep: addressed the slop, honestly deferred the capability.** This is the model the rest should follow — code is real, accuracy claim is withheld pending absent hardware. Severity CRITICAL because it is the life-safety surface; the residual gate is acceptable and labeled.
#### HIGH — shipped/benchmarked claim with an explicit residual gate
**ADR-152 — WiFi-Pose SOTA 2026 Intake.** Status header stale (says Proposed; commits + line 58 report §2.12.3/2.6 implemented and WiFlow-STD **MEASURED-EQUIVALENT 96.09% PCK@20** on RTX 5080). Residual gates are real and disclosed: (1) **1 of 25 verified claims REFUTED 0-3** — "ESP WiFi-6 drop-in compatible with RuView nodes" is false (WiFi-6 parts use a different CSI acquisition struct, lines 31, 123); (2) external pose numbers (PerceptAlign 60% cross-domain; UNSW MAE pose transfer) remain **CLAIMED until reproduced on our hardware** (lines 21, 27, 119122); (3) measurement (b)/(c) open — line 111 confirms pretrained init gives optimization transfer but **no feature transfer**, and no run beat a mean-pose baseline on single-subject data, so **no CSI→pose capability is citable** until multi-subject/multi-position data exists. Blocker: heterogeneous multi-subject CSI dataset (data-gated, per ADR-150 §F3). **Sweep: this ADR *is* the prove-everything discipline applied to research intake** — gates labeled, not buried.
**ADR-072 / ADR-150 — WiFlow pose + RF foundation encoder.** ADR-072 >80% PCK@20 target unverifiable without camera labels (resolved-path via ADR-079, itself gated above). ADR-150 cites measured 81.63% in-domain vs **~11.6% leakage-free cross-subject** — the cross-subject collapse is real and the stated lever (ADR-152 F3) is *more heterogeneous data*, not capacity. Blocker: multi-subject/room dataset + libtorch GPU training. **Sweep: NOT directly addressed** (155 fixed PCK/OKS metric-integrity plumbing, which makes these numbers *trustworthy* but doesn't close the data gap).
#### HIGH — security/privacy decisions still Proposed-only (no sweep touched the gate itself)
**ADR-080 — QE Remediation.** Tracks unfixed security HIGH findings (X-Forwarded-For bypass, leaked stack traces, JWT-in-URL CWE-598), gate FAILED, status Proposed, no done-marking. The HOMECORE sweep (ADR-161/162) fixed *HOMECORE*'s WS-auth bypass and plugin signing — a **different** server boundary. **Sweep: did NOT cover ADR-080's sensing-server findings.** Genuine open security backlog.
**ADR-105→109, ADR-118125 (BFLD/federation/fabric chains).** Entire federation chain (105109) and BFLD surface (118125) are Proposed-only, all ACs unchecked, several "tracking issue TBD." Blockers: KIT BFId dataset (ADR-121 calibration), Pi5/Nexmon CBFR capture hardware (ADR-123 — ESP32 *structurally cannot* sniff CBFR), Soul-Signature + cog-ha-matter dependencies (ADR-122/125). **Sweep: NOT addressed** — 154163 stop at HOMECORE/MAT/cog/edge; the privacy control *plane* (ADR-141, built) exists but the BFLD *capture/scoring* chain it would gate does not. Backlog, honestly gated by absent hardware.
#### MEDIUM — hardware-gated, honestly deferred BY the sweep (lowest risk)
**ADR-163 — Edge-latency measurement.** *Accepted/implemented* for host benches, but the **ESP32/Xtensa on-hardware `process_frame` figure is explicitly UNMEASURED / PENDING (hardware)** (lines 3132, 7983, 9293). Blocker: `wasm32-unknown-unknown` built + flashed to ESP32-S3 and timed on-device; host x86_64 median is "an upper bound on algorithm work, not the ESP32 number." This is the **gold-standard deferral**: the gate is stated everywhere, no claim overreaches. **Sweep: this *is* a sweep ADR honestly deferring its own residual.**
**ADR-160 — wasm-edge skill labeling.** Medical/affect/weapon capabilities explicitly **NOT validated** — relabelled/disclaimed/feature-gated rather than implemented, reference-standard-gated. **Sweep: addressed by relabeling, capability honestly deferred.**
**ADR-110 — ESP32-C6 firmware.** Implemented, but HE-CSI requires ESP-IDF ≥5.5 (v5.4 silently downconverts to HT) — capability hardware/toolchain-gated per WITNESS §B1. Not a sweep target; gate is a noted hardware constraint, not slop.
**Other purely hardware/data-gated Proposed decisions (no sweep involvement, no overreach):** ADR-023 (paired data+GPU), ADR-027/MERIDIAN (multi-env data), ADR-042 CHCI (custom PCB/TCXO — largely superseded by 153), ADR-063/064 (ESP32-C6+MR60BHA2 mmWave), ADR-065/066 (live Cognitum Seed deploy), ADR-070 (live 2-node+Seed capture), ADR-073/078 (multi-AP mesh deployment), ADR-083 (pending field evidence), ADR-086 (real-deployment suppression rates), ADR-091 (COTS sub-THz + ITAR-clear use case), ADR-103 (labelled count data), ADR-113 (Fresnel-sim, not hardware-validated), ADR-114 (real NV-diamond device), ADR-134/135 (COM9/COM12 hardware-test feature), ADR-143 v2 (7-day fleet validation campaign, dead-code until then), ADR-144 (no UWB radio in fleet).
#### Cross-cutting finding
The sweep (ADR-154163) is **narrowly scoped**: it hardened MAT (158), Cognitum cogs (159), wasm-edge (160), HOMECORE server+plugins (161/162), and latency debt (163) — converting CLAIMED→MEASURED or DATA-GATED with honest labels. It **did not** touch the two largest *capability* gaps: the **camera-teacher training validation (ADR-079/072/150)** and the **federation/BFLD privacy chains (105109, 118125)** — both remain data/hardware-gated and Proposed-only. The single hard contradiction worth flagging to a human: **ADR-079's baseline table reports the target (35.3%) as if achieved while the prose and #640 evidence say 2.5%/0%** — that is the one place a reader could mistake an aspiration for a measurement.
+1
View File
@@ -131,6 +131,7 @@ else
SKIP "named person-identity — DATA-GATED: needs a real enrollment feeding the AETHER/body-resonance channel (see docs/research/soul/)"
SKIP "OccWorld trained accuracy — needs a trained checkpoint (predict() carries weights_trained=false until then)"
SKIP "native wlanapi 9.74 Hz scan — Windows-only; run: cargo test -p wifi-densepose-wifiscan -- --ignored measure_native_scan_rate"
SKIP "edge-latency benches (ADR-163) — host medians, not asserted here: (cd v2/crates/wifi-densepose-wasm-edge && cargo bench --features std) and (cd v2 && cargo bench -p cog-person-count -p cog-pose-estimation --no-default-features --bench infer_bench). HOST proxy only — the ESP32/WASM3 budget is NOT reproduced on a laptop; see benchmarks/edge-latency/RESULTS.md"
echo " (re-run with --full to attempt the feature-gated subset where prereqs exist)"
fi
hr
Generated
+6
View File
@@ -1015,6 +1015,7 @@ dependencies = [
"candle-core 0.9.2",
"candle-nn 0.9.2",
"clap",
"criterion",
"safetensors 0.4.5",
"serde",
"serde_json",
@@ -1034,6 +1035,7 @@ dependencies = [
"candle-core 0.9.2",
"candle-nn 0.9.2",
"clap",
"criterion",
"hex",
"safetensors 0.4.5",
"serde",
@@ -3554,9 +3556,13 @@ name = "homecore-plugins"
version = "0.1.0-alpha.0"
dependencies = [
"async-trait",
"base64 0.22.1",
"ed25519-dalek",
"hex",
"homecore",
"serde",
"serde_json",
"sha2",
"thiserror 1.0.69",
"tokio",
"uuid",
+6
View File
@@ -34,6 +34,12 @@ safetensors = "0.4"
[dev-dependencies]
tempfile = "3"
approx = "0.5"
# ADR-163: steady-state infer latency bench (real count_v1 weights, Device::Cpu).
criterion = { version = "0.5", features = ["html_reports"] }
[[bench]]
name = "infer_bench"
harness = false
[features]
default = []
@@ -0,0 +1,95 @@
//! Criterion bench for `cog-person-count` steady-state inference latency
//! (ADR-163, closing the ADR-159/160 deferred "cog inference latency bench" item).
//!
//! ## What this measures — and what the manifest's `cold_start_ms` does NOT
//!
//! This benches **steady-state** `InferenceEngine::infer` over a FIXED CSI
//! window on `Device::Cpu` with the **real** shipped `count_v1.safetensors`
//! weights — i.e. the per-frame cost once the model is loaded and warm.
//!
//! The cog manifest's `build_metadata.cold_start_ms_avg` (in the pose cog;
//! person-count's manifest carries comparable provenance) is a **DIFFERENT
//! measurement**: it includes one-time weight load / mmap / first-forward
//! allocation. Cold-start is a startup cost paid once; steady-state infer is the
//! recurring per-frame cost. They are not comparable and we do not conflate them.
//! `cold_start` was measured on ruvultra (RTX 5080 host, candle 0.9 cpu); this
//! bench runs on whatever machine you run it on — see `benchmarks/edge-latency/RESULTS.md`
//! for the host the committed numbers were taken on.
//!
//! If the weights file is absent the engine falls back to the zero-confidence
//! stub; we skip the bench in that case rather than benchmark the stub (which
//! would be a meaningless number) — the bench prints a notice and measures a
//! no-op so criterion still produces a (clearly-labelled) datapoint.
//!
//! Run (cog crates are normal workspace members):
//! cd v2 && cargo bench -p cog-person-count --no-default-features
//! cd v2 && cargo bench -p cog-person-count --no-default-features -- --warm-up-time 1 --measurement-time 2
use std::hint::black_box;
use std::path::Path;
use criterion::{criterion_group, criterion_main, Criterion};
use cog_person_count::inference::{CsiWindow, InferenceEngine, INPUT_SUBCARRIERS, INPUT_TIMESTEPS};
/// Deterministic fixed CSI window (seed-stable LCG), normalised-ish amplitudes.
fn fixed_window() -> CsiWindow {
let mut s = 0x00C0_FFEEu32;
let data: Vec<f32> = (0..INPUT_SUBCARRIERS * INPUT_TIMESTEPS)
.map(|_| {
s = s.wrapping_mul(1103515245).wrapping_add(12345);
(s >> 16) as f32 / 32768.0 // [0, 1)
})
.collect();
CsiWindow { data }
}
/// Locate the real weights from the crate dir or the repo root.
fn real_weights() -> Option<std::path::PathBuf> {
let candidates = [
"cog/artifacts/count_v1.safetensors",
"v2/crates/cog-person-count/cog/artifacts/count_v1.safetensors",
"crates/cog-person-count/cog/artifacts/count_v1.safetensors",
];
candidates
.iter()
.map(Path::new)
.find(|p| p.exists())
.map(|p| p.to_path_buf())
}
fn bench_infer(c: &mut Criterion) {
let window = fixed_window();
match real_weights() {
Some(path) => {
let engine =
InferenceEngine::with_weights(Some(&path)).expect("load real count_v1 weights");
assert!(
engine.backend().starts_with("candle-"),
"expected real Candle backend, got {} — bench would measure the stub",
engine.backend()
);
// Sanity: one real inference before timing.
let _ = engine.infer(&window).expect("warmup infer");
c.bench_function("cog_person_count::infer[cpu_real_weights_steady_state]", |b| {
b.iter(|| {
black_box(engine.infer(black_box(&window)).expect("infer"));
});
});
}
None => {
eprintln!(
"NOTE: count_v1.safetensors not found — skipping the real-weights infer bench. \
(The committed RESULTS.md numbers require the in-repo weights.)"
);
c.bench_function("cog_person_count::infer[SKIPPED_no_weights]", |b| {
b.iter(|| black_box(1 + 1));
});
}
}
}
criterion_group!(benches, bench_infer);
criterion_main!(benches);
+6
View File
@@ -39,6 +39,12 @@ wifi-densepose-train = { version = "0.3.1", path = "../wifi-densepose-train", de
[dev-dependencies]
tempfile = "3"
# ADR-163: steady-state infer latency bench (real pose_v1 weights, Device::Cpu).
criterion = { version = "0.5", features = ["html_reports"] }
[[bench]]
name = "infer_bench"
harness = false
[features]
default = []
@@ -0,0 +1,89 @@
//! Criterion bench for `cog-pose-estimation` steady-state inference latency
//! (ADR-163, closing the ADR-159/160 deferred "cog inference latency bench" item).
//!
//! ## What this measures — and what the manifest's `cold_start_ms_avg` does NOT
//!
//! The pose cog's manifest (`cog/artifacts/manifests/x86_64/manifest.json`)
//! cites `build_metadata.cold_start_ms_avg: 5.4` (30 invocations, measured on
//! ruvultra / RTX 5080 host, candle 0.9 cpu). **That is a cold-start number** —
//! it folds in one-time weight load / mmap / first-forward allocation.
//!
//! This bench measures the **steady-state** per-frame cost instead:
//! `InferenceEngine::infer` over a FIXED CSI window on `Device::Cpu` with the
//! **real** shipped `pose_v1.safetensors`, after a warm-up forward. Steady-state
//! and cold-start are different measurements; we label both honestly and do not
//! claim this reproduces the 5.4 ms manifest figure (different machine, different
//! measurement). See `benchmarks/edge-latency/RESULTS.md`.
//!
//! Run (cog crates are normal workspace members):
//! cd v2 && cargo bench -p cog-pose-estimation --no-default-features
//! cd v2 && cargo bench -p cog-pose-estimation --no-default-features -- --warm-up-time 1 --measurement-time 2
use std::hint::black_box;
use std::path::Path;
use criterion::{criterion_group, criterion_main, Criterion};
use cog_pose_estimation::inference::{
CsiWindow, InferenceEngine, INPUT_SUBCARRIERS, INPUT_TIMESTEPS,
};
/// Deterministic fixed CSI window (seed-stable LCG).
fn fixed_window() -> CsiWindow {
let mut s = 0x00C0_FFEEu32;
let data: Vec<f32> = (0..INPUT_SUBCARRIERS * INPUT_TIMESTEPS)
.map(|_| {
s = s.wrapping_mul(1103515245).wrapping_add(12345);
(s >> 16) as f32 / 32768.0 // [0, 1)
})
.collect();
CsiWindow { data }
}
fn real_weights() -> Option<std::path::PathBuf> {
let candidates = [
"cog/artifacts/pose_v1.safetensors",
"v2/crates/cog-pose-estimation/cog/artifacts/pose_v1.safetensors",
"crates/cog-pose-estimation/cog/artifacts/pose_v1.safetensors",
];
candidates
.iter()
.map(Path::new)
.find(|p| p.exists())
.map(|p| p.to_path_buf())
}
fn bench_infer(c: &mut Criterion) {
let window = fixed_window();
match real_weights() {
Some(path) => {
let engine =
InferenceEngine::with_weights(Some(&path)).expect("load real pose_v1 weights");
assert!(
engine.backend().starts_with("candle-"),
"expected real Candle backend, got {} — bench would measure the stub",
engine.backend()
);
let _ = engine.infer(&window).expect("warmup infer");
c.bench_function("cog_pose_estimation::infer[cpu_real_weights_steady_state]", |b| {
b.iter(|| {
black_box(engine.infer(black_box(&window)).expect("infer"));
});
});
}
None => {
eprintln!(
"NOTE: pose_v1.safetensors not found — skipping the real-weights infer bench. \
(The committed RESULTS.md numbers require the in-repo weights.)"
);
c.bench_function("cog_pose_estimation::infer[SKIPPED_no_weights]", |b| {
b.iter(|| black_box(1 + 1));
});
}
}
}
criterion_group!(benches, bench_infer);
criterion_main!(benches);
+27 -56
View File
@@ -3,14 +3,15 @@
//!
//! ADR-129 §2 design: one Tokio task per running automation instance.
//!
//! ## Run modes (ADR-161, HC-WS-05)
//! ## Run modes (ADR-161 §A5 → completed in ADR-162)
//!
//! `RunMode::Single` is enforced via a per-automation `AtomicBool`
//! guard: while an instance is executing, a second trigger is skipped.
//! `Parallel` (and the as-yet-unbounded `Restart`/`Queued`) spawn a
//! fresh instance on every trigger. (Before this fix the doc claimed
//! AtomicBool enforcement but every trigger spawned unbounded parallel
//! tasks regardless of `mode`.)
//! Each registered automation owns a [`RunState`] that implements its
//! `RunMode`: `Single`/`IgnoreFirst` skip re-entrant triggers, `Restart`
//! aborts the in-flight run and starts a fresh one, `Queued` serializes
//! runs in arrival order (nothing dropped), `Parallel` spawns on every
//! trigger, and `max: N` caps concurrency via a per-automation semaphore.
//! (ADR-161 only honored Single/Parallel; Restart/Queued/max were
//! honestly documented as unbounded-parallel until ADR-162.)
//!
//! ## Time triggers (ADR-161, HC-WS-04)
//!
@@ -26,7 +27,6 @@
//! `EvalContext::with_templates`), so `template:` conditions evaluate
//! against live state instead of always returning false.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use chrono::{Local, Timelike};
@@ -34,18 +34,18 @@ use tokio::sync::broadcast;
use homecore::HomeCore;
use crate::action::ExecutionContext;
use crate::automation::{Automation, RunMode};
use crate::automation::Automation;
use crate::condition::EvalContext;
use crate::runmode::RunState;
use crate::template::TemplateEnvironment;
use crate::trigger::{Trigger, TriggerContext};
/// An automation registered with the engine, plus its runtime run-state.
struct Registered {
auto: Arc<Automation>,
/// `true` while a `Single`-mode instance is executing. Used to
/// skip re-entrant triggers (HC-WS-05).
running: Arc<AtomicBool>,
/// Run-mode machinery (re-entrancy guard / restart abort handle /
/// queue mutex / concurrency semaphore) for this automation.
run_state: RunState,
}
/// The automation engine. Holds a HOMECORE handle and a list of registered
@@ -69,9 +69,10 @@ impl AutomationEngine {
/// Register an automation. Can be called before or after `start()`.
pub fn register(&self, automation: Automation) {
let run_state = RunState::new(&automation);
self.automations.lock().unwrap().push(Registered {
auto: Arc::new(automation),
running: Arc::new(AtomicBool::new(false)),
run_state,
});
}
@@ -118,13 +119,13 @@ impl AutomationEngine {
loop {
match rx.recv().await {
Ok(event) => {
let snapshot: Vec<(Arc<Automation>, Arc<AtomicBool>)> = automations
let snapshot: Vec<(Arc<Automation>, RunState)> = automations
.lock()
.unwrap()
.iter()
.map(|r| (Arc::clone(&r.auto), Arc::clone(&r.running)))
.map(|r| (Arc::clone(&r.auto), r.run_state.clone()))
.collect();
for (automation, running) in snapshot {
for (automation, run_state) in snapshot {
if !automation.enabled {
continue;
}
@@ -148,7 +149,7 @@ impl AutomationEngine {
if !conditions_pass(&automation, &eval_ctx).await {
continue;
}
spawn_run(&hc, automation, running);
run_state.dispatch(&hc, automation);
}
}
Err(broadcast::error::RecvError::Closed) => break,
@@ -183,14 +184,14 @@ impl AutomationEngine {
if last_fired_sec.as_deref() == Some(hhmmss.as_str()) {
continue;
}
let snapshot: Vec<(Arc<Automation>, Arc<AtomicBool>)> = automations
let snapshot: Vec<(Arc<Automation>, RunState)> = automations
.lock()
.unwrap()
.iter()
.map(|r| (Arc::clone(&r.auto), Arc::clone(&r.running)))
.map(|r| (Arc::clone(&r.auto), r.run_state.clone()))
.collect();
let mut fired_any = false;
for (automation, running) in snapshot {
for (automation, run_state) in snapshot {
if !automation.enabled {
continue;
}
@@ -208,7 +209,7 @@ impl AutomationEngine {
if !conditions_pass(&automation, &eval_ctx).await {
continue;
}
spawn_run(&hc, automation, running);
run_state.dispatch(&hc, automation);
fired_any = true;
}
if fired_any {
@@ -231,15 +232,15 @@ impl AutomationEngine {
/// wall-clock second to roll over. Returns the number of automations
/// that fired (passed conditions and were spawned).
pub async fn fire_time_for_test(&self, hhmmss: &str) -> usize {
let snapshot: Vec<(Arc<Automation>, Arc<AtomicBool>)> = self
let snapshot: Vec<(Arc<Automation>, RunState)> = self
.automations
.lock()
.unwrap()
.iter()
.map(|r| (Arc::clone(&r.auto), Arc::clone(&r.running)))
.map(|r| (Arc::clone(&r.auto), r.run_state.clone()))
.collect();
let mut fired = 0usize;
for (automation, running) in snapshot {
for (automation, run_state) in snapshot {
if !automation.enabled {
continue;
}
@@ -254,7 +255,7 @@ impl AutomationEngine {
if !conditions_pass(&automation, &eval_ctx).await {
continue;
}
spawn_run(&self.hc, automation, running);
run_state.dispatch(&self.hc, automation);
fired += 1;
}
fired
@@ -281,36 +282,6 @@ fn time_at_matches(at: &str, hhmmss: &str) -> bool {
normalized == hhmmss
}
/// Spawn an automation run, honoring `RunMode::Single` re-entrancy
/// guard (HC-WS-05). For `Single`/`IgnoreFirst` modes a run already in
/// flight causes the new trigger to be skipped; the `running` flag is
/// cleared when the run finishes.
fn spawn_run(hc: &HomeCore, automation: Arc<Automation>, running: Arc<AtomicBool>) {
let single = matches!(automation.mode, RunMode::Single | RunMode::IgnoreFirst);
if single {
// Try to claim the running slot; if already running, skip.
if running
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
.is_err()
{
return;
}
}
let hc_clone = hc.clone();
tokio::spawn(async move {
let mut exec_ctx = ExecutionContext::new(hc_clone, automation.id.clone());
for action in &automation.action {
if let Err(e) = action.execute(&mut exec_ctx).await {
eprintln!("[homecore-automation] action error in {}: {e}", automation.id);
break;
}
}
if single {
running.store(false, Ordering::SeqCst);
}
});
}
#[cfg(test)]
mod tests {
use super::*;
+1
View File
@@ -19,6 +19,7 @@ pub mod condition;
pub mod action;
pub mod template;
pub mod engine;
pub mod runmode;
pub mod error;
pub use automation::{Automation, RunMode};
@@ -0,0 +1,153 @@
//! Per-automation run-mode machinery (ADR-162, completes ADR-161 §A5).
//!
//! ADR-161 implemented `RunMode::Single` (a per-automation `AtomicBool`
//! re-entrancy guard) and `Parallel`, but honestly left `Restart`, `Queued`
//! and `max: N` as "ACCEPTED-FUTURE / unbounded parallel" — every non-Single
//! mode spawned an unbounded task. This module makes them real:
//!
//! | Mode | Semantics implemented |
//! |------|-----------------------|
//! | `Single` / `IgnoreFirst` | re-entrancy guard: skip while a run is in flight (ADR-161). |
//! | `Restart` | **cancel** the in-flight run (`tokio::task::AbortHandle`) and start a fresh one. |
//! | `Queued` | **serialize**: runs execute sequentially in arrival order via a per-automation async mutex — nothing is dropped. |
//! | `Parallel` | spawn on every trigger (optionally capped, see below). |
//! | `max: N` | cap concurrency at **N** via a per-automation semaphore; triggers beyond N **queue** (await a permit) rather than running concurrently — matching HA's bounded `parallel`/`queued`. |
//!
//! Each registered automation owns one [`RunState`]; the engine calls
//! [`RunState::dispatch`] on every (trigger + conditions-passed) event.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use tokio::sync::{Mutex as AsyncMutex, Semaphore};
use homecore::HomeCore;
use crate::action::ExecutionContext;
use crate::automation::{Automation, RunMode};
/// Per-automation runtime state backing the run-mode dispatch.
///
/// Cheap to clone (all fields are `Arc`); the engine clones it into each
/// spawned run so the machinery (abort handle, queue mutex, semaphore) is
/// shared across all triggers of the same automation.
#[derive(Clone)]
pub struct RunState {
/// `Single`/`IgnoreFirst` re-entrancy guard (ADR-161 §A5).
running: Arc<AtomicBool>,
/// `Restart`: handle to the currently-running action task, so a new
/// trigger can abort it before starting a fresh one.
current: Arc<Mutex<Option<tokio::task::AbortHandle>>>,
/// `Queued`: serializes runs in arrival order (one at a time, FIFO via
/// fair async mutex acquisition).
queue_lock: Arc<AsyncMutex<()>>,
/// `max: N` (and bounded `Parallel`): caps concurrent runs at N.
/// `None` when no cap applies.
semaphore: Option<Arc<Semaphore>>,
}
impl RunState {
/// Build run-state for an automation, sizing the concurrency semaphore
/// from its `max:` field (only meaningful for `Queued`/`Parallel`).
pub fn new(automation: &Automation) -> Self {
let semaphore = automation
.max
.filter(|n| *n > 0)
.map(|n| Arc::new(Semaphore::new(n)));
Self {
running: Arc::new(AtomicBool::new(false)),
current: Arc::new(Mutex::new(None)),
queue_lock: Arc::new(AsyncMutex::new(())),
semaphore,
}
}
/// Dispatch one trigger for `automation` according to its `RunMode`.
/// Honors Single re-entrancy, Restart cancel-and-replace, Queued
/// serialization, and `max:` concurrency capping.
pub fn dispatch(&self, hc: &HomeCore, automation: Arc<Automation>) {
match automation.mode {
RunMode::Single | RunMode::IgnoreFirst => self.dispatch_single(hc, automation),
RunMode::Restart => self.dispatch_restart(hc, automation),
RunMode::Queued => self.dispatch_queued(hc, automation),
RunMode::Parallel => self.dispatch_parallel(hc, automation),
}
}
/// `Single`: skip if a run is already in flight; clear the flag on done.
fn dispatch_single(&self, hc: &HomeCore, automation: Arc<Automation>) {
if self
.running
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
.is_err()
{
return; // already running — skip re-entrant trigger.
}
let hc = hc.clone();
let running = Arc::clone(&self.running);
tokio::spawn(async move {
run_actions(&hc, &automation).await;
running.store(false, Ordering::SeqCst);
});
}
/// `Restart`: abort the in-flight run (if any), then start a fresh one
/// and record its abort handle.
fn dispatch_restart(&self, hc: &HomeCore, automation: Arc<Automation>) {
// Abort any prior run before starting the new one.
if let Some(prev) = self.current.lock().unwrap().take() {
prev.abort();
}
let hc = hc.clone();
let slot = Arc::clone(&self.current);
let handle = tokio::spawn(async move {
run_actions(&hc, &automation).await;
});
*slot.lock().unwrap() = Some(handle.abort_handle());
}
/// `Queued`: serialize via the per-automation async mutex. Each trigger
/// spawns a task that waits its turn, so all triggers run in arrival
/// order, one at a time — nothing is dropped.
fn dispatch_queued(&self, hc: &HomeCore, automation: Arc<Automation>) {
let hc = hc.clone();
let lock = Arc::clone(&self.queue_lock);
let sem = self.semaphore.clone();
tokio::spawn(async move {
// Optional `max:` cap still applies on top of serialization.
let _permit = match &sem {
Some(s) => Some(s.acquire().await.expect("semaphore not closed")),
None => None,
};
let _guard = lock.lock().await; // FIFO turn — sequential execution.
run_actions(&hc, &automation).await;
});
}
/// `Parallel`: spawn on every trigger, capped at `max:` if set.
fn dispatch_parallel(&self, hc: &HomeCore, automation: Arc<Automation>) {
let hc = hc.clone();
let sem = self.semaphore.clone();
tokio::spawn(async move {
let _permit = match &sem {
Some(s) => Some(s.acquire().await.expect("semaphore not closed")),
None => None,
};
run_actions(&hc, &automation).await;
});
}
}
/// Execute an automation's action sequence once.
async fn run_actions(hc: &HomeCore, automation: &Automation) {
let mut exec_ctx = ExecutionContext::new(hc.clone(), automation.id.clone());
for action in &automation.action {
if let Err(e) = action.execute(&mut exec_ctx).await {
eprintln!(
"[homecore-automation] action error in {}: {e}",
automation.id
);
break;
}
}
}
@@ -257,3 +257,162 @@ async fn template_condition_evaluates_false_blocks_action() {
sleep(Duration::from_millis(50)).await;
assert_eq!(log.lock().unwrap().len(), 0, "false template condition should block the action");
}
// ── ADR-162 (completes ADR-161 §A5): bounded RunModes ───────────────
//
// ADR-161 honored only Single/Parallel; Restart/Queued/max were honestly
// documented as unbounded-parallel. These tests drive the real
// Restart/Queued/max machinery and FAIL on the old engine (where every
// non-Single mode spawned an unbounded parallel task).
/// A service that increments a live concurrency gauge on entry, sleeps,
/// then decrements — recording the maximum concurrency ever observed and
/// the total number of completed runs. Returns `(max_concurrency, completed)`.
async fn register_gauge(
hc: &HomeCore,
domain: &str,
service: &str,
work: Duration,
) -> (Arc<AtomicUsize>, Arc<AtomicUsize>) {
let live = Arc::new(AtomicUsize::new(0));
let max_seen = Arc::new(AtomicUsize::new(0));
let completed = Arc::new(AtomicUsize::new(0));
let (l, m, c) = (Arc::clone(&live), Arc::clone(&max_seen), Arc::clone(&completed));
hc.services()
.register(
ServiceName::new(domain, service),
FnHandler(move |_call: ServiceCall| {
let (l, m, c) = (Arc::clone(&l), Arc::clone(&m), Arc::clone(&c));
async move {
let now = l.fetch_add(1, Ordering::SeqCst) + 1;
m.fetch_max(now, Ordering::SeqCst);
sleep(work).await;
l.fetch_sub(1, Ordering::SeqCst);
c.fetch_add(1, Ordering::SeqCst);
Ok(serde_json::Value::Null)
}
}),
)
.await;
(max_seen, completed)
}
fn state_auto(id: &str, entity: &str, domain: &str, service: &str) -> Automation {
Automation::new(
id,
vec![Trigger::State {
entity_id: EntityId::parse(entity).unwrap(),
from: None,
to: None,
}],
vec![Action::ServiceCall {
domain: domain.into(),
service: service.into(),
data: serde_json::json!({}),
}],
)
}
// ── Restart: cancels the in-flight run ─────────────────────────────
#[tokio::test]
async fn restart_mode_cancels_prior_run() {
let hc = HomeCore::new();
// Each run sleeps 300ms before recording completion.
let (_max, completed) =
register_gauge(&hc, "light", "slow", Duration::from_millis(300)).await;
let engine = AutomationEngine::new(hc.clone());
let mut auto = state_auto("restart_auto", "switch.r", "light", "slow");
auto.mode = RunMode::Restart;
engine.register(auto);
let _handle = engine.start();
// Trigger 1 starts the slow run.
hc.states().set(EntityId::parse("switch.r").unwrap(), "a", serde_json::json!({}), Context::new());
sleep(Duration::from_millis(80)).await;
// Trigger 2 arrives mid-run → must ABORT run 1 and start run 2.
hc.states().set(EntityId::parse("switch.r").unwrap(), "b", serde_json::json!({}), Context::new());
// Wait long enough for run 2 (started ~80ms in) to finish, but run 1
// (aborted at ~80ms, would have finished at ~300ms) must NOT complete.
sleep(Duration::from_millis(400)).await;
assert_eq!(
completed.load(Ordering::SeqCst),
1,
"Restart must cancel the in-flight run: exactly the restarted run completes (not both). \
On the old engine both ran to completion 2."
);
}
// ── Queued: serialize N rapid triggers, all run, never concurrent ──
#[tokio::test]
async fn queued_mode_runs_sequentially_not_concurrently() {
let hc = HomeCore::new();
let (max_seen, completed) =
register_gauge(&hc, "light", "slow", Duration::from_millis(120)).await;
let engine = AutomationEngine::new(hc.clone());
let mut auto = state_auto("queued_auto", "switch.q", "light", "slow");
auto.mode = RunMode::Queued;
engine.register(auto);
let _handle = engine.start();
// Three rapid triggers.
for v in ["a", "b", "c"] {
hc.states().set(EntityId::parse("switch.q").unwrap(), v, serde_json::json!({}), Context::new());
sleep(Duration::from_millis(10)).await;
}
// 3 runs × 120ms serialized ≈ 360ms; wait generously.
sleep(Duration::from_millis(600)).await;
assert_eq!(
completed.load(Ordering::SeqCst),
3,
"Queued must run every trigger (nothing dropped)"
);
assert_eq!(
max_seen.load(Ordering::SeqCst),
1,
"Queued must never run two instances concurrently. On the old engine all 3 ran in \
parallel max concurrency 3."
);
}
// ── max: 2 → never more than 2 concurrent ──────────────────────────
#[tokio::test]
async fn max_two_caps_concurrency_at_two() {
let hc = HomeCore::new();
let (max_seen, completed) =
register_gauge(&hc, "light", "slow", Duration::from_millis(150)).await;
let engine = AutomationEngine::new(hc.clone());
let mut auto = state_auto("max_auto", "switch.m", "light", "slow");
auto.mode = RunMode::Parallel;
auto.max = Some(2);
engine.register(auto);
let _handle = engine.start();
// Four rapid triggers — without the cap all 4 would run at once.
for v in ["a", "b", "c", "d"] {
hc.states().set(EntityId::parse("switch.m").unwrap(), v, serde_json::json!({}), Context::new());
sleep(Duration::from_millis(10)).await;
}
sleep(Duration::from_millis(600)).await;
assert_eq!(
completed.load(Ordering::SeqCst),
4,
"max:2 must still run all 4 triggers (queued beyond the cap, not dropped)"
);
assert!(
max_seen.load(Ordering::SeqCst) <= 2,
"max:2 must never exceed 2 concurrent runs (observed {}). On the old engine all 4 ran \
concurrently 4.",
max_seen.load(Ordering::SeqCst)
);
assert!(
max_seen.load(Ordering::SeqCst) >= 2,
"max:2 should reach the cap of 2 with 4 rapid triggers (observed {})",
max_seen.load(Ordering::SeqCst)
);
}
+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).
+9
View File
@@ -50,6 +50,15 @@ serde_json = "1"
# UUIDs for config entry IDs in host_abi.rs.
uuid = { version = "1", features = ["v4"] }
# ── ADR-162 P4: plugin signature + integrity verification ──────────────────
# Reuses the same in-repo crypto stack as cog-ha-matter (witness_signing.rs):
# Ed25519 over a SHA-256 module digest. All four are already in the workspace
# Cargo.lock (cog-ha-matter / bfld pull them in) — no new external dep tree.
ed25519-dalek = "2.1"
sha2 = { workspace = true }
hex = "0.4"
base64 = "0.22"
# Optional Wasmtime runtime (P2, default-off — 30 MB dep).
# Bumped from 25.0.3 → 42 to remediate RUSTSEC-2026-0095 and RUSTSEC-2026-0096
# (Cranelift/Winch sandbox-escape CVEs, CVSS 9.0 — iter-11 security sprint HC-03/04).
+12
View File
@@ -25,6 +25,18 @@ pub enum PluginError {
#[error("plugin setup failed: {0}")]
SetupFailed(String),
/// The plugin failed signature/integrity verification (ADR-162 P4):
/// hash mismatch, bad signature, untrusted publisher, or unsigned
/// module under a non-dev trust policy.
#[error("plugin signature rejected: {0}")]
SignatureRejected(String),
/// A plugin attempted a host call (e.g. `hc_state_set`) on an entity
/// it did not declare in `homecore_permissions` (ADR-162 P5 authority
/// isolation).
#[error("plugin permission denied: {0}")]
PermissionDenied(String),
/// The plugin's `unload` hook returned an error.
#[error("plugin unload failed: {0}")]
UnloadFailed(String),
+14 -2
View File
@@ -22,8 +22,16 @@
//! - Host ABI wiring: `hc_state_get`, `hc_state_set`, `hc_event_fire`, etc.
//! (P2 — requires ADR-127 state machine API freeze first).
//! - Config entry lifecycle + hot-load (P3).
//! - Cog registry distribution + Ed25519 signature verification (P4).
//! - Permission enforcement (P5).
//!
//! ## Now enforced (ADR-162)
//!
//! - **Ed25519 signature + SHA-256 integrity verification (P4)** — see
//! [`verify`]: the plugin load path hashes the real `.wasm` bytes, checks
//! the manifest `wasm_module_hash`, verifies `wasm_module_sig` against
//! `publisher_key`, and enforces a [`verify::PluginPolicy`] allowlist.
//! - **Permission / authority isolation (P5)** — see [`permissions`]: a
//! plugin's `hc_state_set` writes are gated against the entity domains/
//! globs it declared in `homecore_permissions`.
//!
//! ## Feature flags
//!
@@ -35,9 +43,11 @@
pub mod error;
pub mod host_abi;
pub mod manifest;
pub mod permissions;
pub mod plugin;
pub mod registry;
pub mod runtime;
pub mod verify;
#[cfg(feature = "wasmtime")]
pub mod wasmtime_runtime;
@@ -45,9 +55,11 @@ pub mod wasmtime_runtime;
pub use error::PluginError;
pub use host_abi::{ConfigEntryJson, StateChangedEventJson};
pub use manifest::{IotClass, IntegrationType, PluginManifest};
pub use permissions::PermissionSet;
pub use plugin::{HomeCorePlugin, PluginId};
pub use registry::PluginRegistry;
pub use runtime::{InProcessRuntime, LoadedPlugin, PluginRuntime};
pub use verify::{verify_module, PluginPolicy};
#[cfg(feature = "wasmtime")]
pub use wasmtime_runtime::{WasmPlugin, WasmtimeRuntime};
+16 -8
View File
@@ -85,24 +85,26 @@ pub struct PluginManifest {
/// [HOMECORE] `sha256:<hex>` hash of the wasm binary.
///
/// **(P4 — not yet enforced, ADR-161/B5):** this field is parsed and
/// round-tripped but is NOT verified before execution. The hash/sig
/// gate lands in P4; until then the presence of this field implies no
/// integrity guarantee.
/// **(P4 — ENFORCED, ADR-162):** `verify::verify_module` computes the
/// SHA-256 of the real `.wasm` bytes on load and rejects the module if
/// it does not equal this hash (tamper detection). See [`crate::verify`].
#[serde(default, skip_serializing_if = "Option::is_none")]
pub wasm_module_hash: Option<String>,
/// [HOMECORE] Ed25519 signature of the wasm binary hash (`ed25519:<base64>`).
///
/// **(P4 — not yet enforced, ADR-161/B5):** parsed but never checked.
/// No signature verification happens before a plugin runs.
/// **(P4 — ENFORCED, ADR-162):** verified against `publisher_key` over
/// the SHA-256 module digest before instantiation. A bad/forged/absent
/// signature is rejected under the secure trust policy (the
/// `cog-ha-matter::witness_signing` Ed25519 pattern is reused).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub wasm_module_sig: Option<String>,
/// [HOMECORE] Ed25519 public key of the plugin publisher.
///
/// **(P4 — not yet enforced, ADR-161/B5):** parsed but never used to
/// verify `wasm_module_sig`.
/// **(P4 — ENFORCED, ADR-162):** used to verify `wasm_module_sig`, and
/// checked against the host's [`crate::verify::PluginPolicy`] trust
/// allowlist — an unknown publisher is rejected by the secure default.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub publisher_key: Option<String>,
@@ -115,6 +117,12 @@ pub struct PluginManifest {
pub host_imports_required: Vec<String>,
/// [HOMECORE] Coarse-grained permission claims (glob patterns).
///
/// **(P5 — ENFORCED, ADR-162):** `state:write:<glob>` (or a bare entity
/// glob like `light.*`) grants are parsed into a
/// [`crate::permissions::PermissionSet` ] and consulted by the
/// `hc_state_set` host import. A plugin can no longer write an entity it
/// did not declare; a plugin with no write grants can write nothing.
#[serde(default)]
pub homecore_permissions: Vec<PermissionClaim>,
@@ -0,0 +1,168 @@
//! Plugin authority / capability isolation (ADR-162, P5).
//!
//! Wasmtime already gives a plugin **memory** isolation — it cannot read
//! another plugin's linear memory. It does NOT, by itself, stop a plugin
//! from using a host import to write any entity it likes. Before this fix
//! `hc_state_set` happily let any plugin write `lock.front_door` or
//! `alarm_control_panel.*`, and the manifest's `homecore_permissions`
//! claims were parsed but **never consulted** (ADR-161 deferred P5).
//!
//! This module adds **authority isolation**: a plugin may only write
//! entities its manifest declared. The host import consults a
//! [`PermissionSet`] before applying any state write and returns a typed
//! error to the guest (it does **not** panic the host) on a violation.
//!
//! ## Permission grammar
//!
//! Each entry in `homecore_permissions` is one of:
//!
//! * a bare entity glob — `"light.*"`, `"light.kitchen"`, `"*"`;
//! * the explicit capability form `"state:write:<glob>"` (the form the
//! ADR-128 manifest doc shows), e.g. `"state:write:sensor.*"`.
//!
//! A glob supports a single trailing `*` (HA-style domain wildcards:
//! `light.*` matches every `light` entity) and a leading-or-bare `*`
//! (`*` = everything). Exact strings match exactly. A plugin with **no**
//! `state:write` entries can write **nothing** — the secure default.
use crate::manifest::PluginManifest;
/// The set of entity-write permissions a plugin holds, distilled from its
/// manifest `homecore_permissions` at load time.
#[derive(Debug, Clone, Default)]
pub struct PermissionSet {
/// Glob patterns the plugin may write (state:write authority). Empty =
/// the plugin may write nothing.
write_globs: Vec<String>,
}
impl PermissionSet {
/// Build a permission set from a manifest's `homecore_permissions`.
///
/// Only `state:write` authority is modelled here (the host import this
/// gates is `hc_state_set`). A bare glob (`"light.*"`) is treated as a
/// write grant; the explicit `"state:write:<glob>"` form is also
/// accepted. Other capability strings (`state:read:*`, future verbs)
/// are ignored for write-gating purposes.
pub fn from_manifest(manifest: &PluginManifest) -> Self {
let mut write_globs = Vec::new();
for claim in &manifest.homecore_permissions {
let claim = claim.trim();
if let Some(glob) = claim.strip_prefix("state:write:") {
write_globs.push(glob.trim().to_string());
} else if claim.starts_with("state:read:") {
// read authority — not relevant to write gating.
} else if !claim.is_empty() {
// Bare glob — treat as a write grant.
write_globs.push(claim.to_string());
}
}
Self { write_globs }
}
/// An all-allowing set (equivalent to a `"*"` grant). Used by the
/// legacy permission-free `WasmtimeRuntime::load_wasm` path so existing
/// callers/tests that do not supply a manifest keep working; the
/// permission-gated path uses [`Self::from_manifest`].
pub fn allow_all() -> Self {
Self {
write_globs: vec!["*".to_string()],
}
}
/// May this plugin write the given entity id (e.g. `"light.kitchen"`)?
pub fn may_write(&self, entity_id: &str) -> bool {
self.write_globs.iter().any(|g| glob_matches(g, entity_id))
}
/// Number of write-grant globs (0 = can write nothing).
pub fn write_grant_count(&self) -> usize {
self.write_globs.len()
}
}
/// Match `entity_id` against a single glob pattern.
///
/// Supported forms:
/// * `"*"` → matches anything.
/// * `"light.*"` → trailing wildcard: any id with the `light.` prefix.
/// * `"light.kitchen"` → exact match.
fn glob_matches(pattern: &str, entity_id: &str) -> bool {
if pattern == "*" {
return true;
}
if let Some(prefix) = pattern.strip_suffix('*') {
return entity_id.starts_with(prefix);
}
pattern == entity_id
}
#[cfg(test)]
mod tests {
use super::*;
fn manifest_with(perms: &[&str]) -> PluginManifest {
PluginManifest {
domain: "p".into(),
name: "P".into(),
version: "1".into(),
documentation: None,
iot_class: None,
config_flow: false,
integration_type: None,
dependencies: vec![],
requirements: vec![],
wasm_module: None,
wasm_module_hash: None,
wasm_module_sig: None,
publisher_key: None,
min_homecore_version: None,
host_imports_required: vec![],
homecore_permissions: perms.iter().map(|s| s.to_string()).collect(),
cog_id: None,
}
}
#[test]
fn domain_glob_allows_same_domain_only() {
let ps = PermissionSet::from_manifest(&manifest_with(&["light.*"]));
assert!(ps.may_write("light.kitchen"));
assert!(ps.may_write("light.bedroom"));
assert!(!ps.may_write("lock.front_door"));
assert!(!ps.may_write("alarm_control_panel.home"));
}
#[test]
fn no_permissions_can_write_nothing() {
let ps = PermissionSet::from_manifest(&manifest_with(&[]));
assert_eq!(ps.write_grant_count(), 0);
assert!(!ps.may_write("light.kitchen"));
assert!(!ps.may_write("sensor.temp"));
}
#[test]
fn explicit_state_write_form_is_honored() {
let ps = PermissionSet::from_manifest(&manifest_with(&["state:write:sensor.*"]));
assert!(ps.may_write("sensor.temp"));
assert!(!ps.may_write("light.kitchen"));
}
#[test]
fn read_grants_do_not_confer_write() {
let ps = PermissionSet::from_manifest(&manifest_with(&["state:read:lock.*"]));
assert!(!ps.may_write("lock.front_door"));
}
#[test]
fn exact_entity_grant_is_scoped() {
let ps = PermissionSet::from_manifest(&manifest_with(&["light.kitchen"]));
assert!(ps.may_write("light.kitchen"));
assert!(!ps.may_write("light.bedroom"));
}
#[test]
fn wildcard_grants_everything() {
let ps = PermissionSet::from_manifest(&manifest_with(&["*"]));
assert!(ps.may_write("lock.front_door"));
}
}
+397
View File
@@ -0,0 +1,397 @@
//! Plugin signature & integrity verification (ADR-162, P4).
//!
//! ADR-161/B5 honestly relabelled the manifest's `wasm_module_hash` /
//! `wasm_module_sig` / `publisher_key` fields as "(P4 — not yet enforced)":
//! they were parsed and round-tripped but **never checked** before a plugin
//! ran. This module makes that claim TRUE — it is the real verification gate
//! the plugin load path runs before instantiating any `.wasm` module.
//!
//! ## What is verified, in order
//!
//! 1. **Module hash** — SHA-256 of the actual `.wasm` bytes must equal the
//! manifest's `wasm_module_hash` (`sha256:<hex>`). A tampered module
//! (one byte changed) fails here.
//! 2. **Ed25519 signature** — `wasm_module_sig` (`ed25519:<base64>`, 64-byte
//! raw signature) must verify over the **32-byte SHA-256 digest** under
//! the `publisher_key` (`ed25519:<base64>`, 32-byte raw verifying key).
//! 3. **Trust policy** — the `publisher_key` must be on the configured
//! allowlist, unless [`PluginPolicy::AllowUnsigned`] is in force (a loud
//! dev escape hatch).
//!
//! The crypto mirrors the in-repo Ed25519 pattern from
//! `cog-ha-matter::witness_signing` (same `ed25519-dalek` 2.x API, same
//! deterministic-test-key convention). SHA-256 matches the `sha256:` prefix
//! the manifest doc already declared for `wasm_module_hash`, and the
//! `cog-ha-matter` cog manifest's `binary_sha256` hex convention.
//!
//! ## Secure default
//!
//! [`PluginPolicy::trusted`] (the production constructor) **rejects**:
//! * an unsigned module (no hash / sig / key),
//! * a signature from a key not on the allowlist,
//! * any hash or signature mismatch.
//!
//! Only [`PluginPolicy::AllowUnsigned`] loosens this, and every load it
//! waves through emits a `warn`-level log line so it cannot pass silently.
use base64::Engine as _;
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
use sha2::{Digest, Sha256};
use crate::error::PluginError;
use crate::manifest::PluginManifest;
/// Trust policy governing which plugins may load.
///
/// The production path uses [`PluginPolicy::trusted`] with an explicit
/// allowlist of publisher verifying keys. [`PluginPolicy::AllowUnsigned`]
/// is the dev escape hatch — it loads anything (even unsigned modules) but
/// logs a loud warning per load.
#[derive(Debug, Clone)]
pub enum PluginPolicy {
/// Secure default: a plugin loads only if its module hash matches, its
/// Ed25519 signature verifies, AND its publisher key is in this
/// allowlist. Each entry is the 32-byte raw Ed25519 verifying key.
Trusted { allowlist: Vec<[u8; 32]> },
/// Dev-only: skip signature/allowlist enforcement. Hash is still
/// checked when a `wasm_module_hash` is present (cheap integrity), but
/// unsigned / unknown-publisher modules are allowed. Every load logs a
/// loud `warn`.
AllowUnsigned,
}
impl PluginPolicy {
/// Construct the secure (production) policy from a list of trusted
/// publisher keys, each encoded as `ed25519:<base64>` (the same form
/// the manifest `publisher_key` uses).
pub fn trusted(publisher_keys: &[&str]) -> Result<Self, PluginError> {
let mut allowlist = Vec::with_capacity(publisher_keys.len());
for k in publisher_keys {
allowlist.push(decode_verifying_key(k)?.to_bytes());
}
Ok(PluginPolicy::Trusted { allowlist })
}
/// Secure policy that trusts no publisher at all — every signed or
/// unsigned module is rejected. Useful as a strict default.
pub fn deny_all() -> Self {
PluginPolicy::Trusted { allowlist: vec![] }
}
fn is_dev(&self) -> bool {
matches!(self, PluginPolicy::AllowUnsigned)
}
fn allows(&self, key: &VerifyingKey) -> bool {
match self {
PluginPolicy::AllowUnsigned => true,
PluginPolicy::Trusted { allowlist } => {
allowlist.iter().any(|k| k == &key.to_bytes())
}
}
}
}
/// Verify a `.wasm` module's integrity and signature against its manifest,
/// under the given trust `policy`. Returns `Ok(())` only if the module may
/// be instantiated.
///
/// On [`PluginPolicy::AllowUnsigned`] this still checks any present hash,
/// but waves through missing/untrusted signatures with a loud `warn`.
pub fn verify_module(
manifest: &PluginManifest,
wasm_bytes: &[u8],
policy: &PluginPolicy,
) -> Result<(), PluginError> {
let signed = manifest.wasm_module_hash.is_some()
|| manifest.wasm_module_sig.is_some()
|| manifest.publisher_key.is_some();
if !signed {
// No integrity material at all.
if policy.is_dev() {
eprintln!(
"[PLUGIN WARN] loading UNSIGNED plugin `{}` — no wasm_module_hash/sig/publisher_key. \
AllowUnsigned dev policy is active; this is INSECURE and must not be used in production.",
manifest.domain
);
return Ok(());
}
return Err(PluginError::SignatureRejected(format!(
"plugin `{}` is unsigned (no wasm_module_hash/sig/publisher_key) and the trust policy \
rejects unsigned modules; set PluginPolicy::AllowUnsigned to override in dev",
manifest.domain
)));
}
// (1) Hash check — always enforced when a hash is declared.
let digest = sha256_digest(wasm_bytes);
if let Some(declared) = &manifest.wasm_module_hash {
let expected = parse_sha256(declared)?;
if expected != digest {
return Err(PluginError::SignatureRejected(format!(
"plugin `{}` wasm hash mismatch: module does not match manifest wasm_module_hash \
(tampered or wrong binary)",
manifest.domain
)));
}
} else if !policy.is_dev() {
return Err(PluginError::SignatureRejected(format!(
"plugin `{}` carries a signature/publisher_key but no wasm_module_hash to bind it to",
manifest.domain
)));
}
// (2) Signature check + (3) allowlist.
match (&manifest.wasm_module_sig, &manifest.publisher_key) {
(Some(sig_str), Some(key_str)) => {
let key = decode_verifying_key(key_str)?;
let sig = decode_signature(sig_str)?;
key.verify(&digest, &sig).map_err(|_| {
PluginError::SignatureRejected(format!(
"plugin `{}` Ed25519 signature does not verify over the module hash under \
publisher_key",
manifest.domain
))
})?;
if !policy.allows(&key) {
if policy.is_dev() {
eprintln!(
"[PLUGIN WARN] plugin `{}` is validly signed but its publisher_key is NOT on \
the trust allowlist; AllowUnsigned dev policy loads it anyway.",
manifest.domain
);
return Ok(());
}
return Err(PluginError::SignatureRejected(format!(
"plugin `{}` is validly signed but its publisher_key is not on the trust \
allowlist (untrusted publisher)",
manifest.domain
)));
}
Ok(())
}
_ => {
// Hash present but signature/key incomplete.
if policy.is_dev() {
eprintln!(
"[PLUGIN WARN] plugin `{}` has a hash but no complete Ed25519 signature; \
AllowUnsigned dev policy loads it anyway.",
manifest.domain
);
return Ok(());
}
Err(PluginError::SignatureRejected(format!(
"plugin `{}` is missing a complete wasm_module_sig + publisher_key pair; the trust \
policy requires a valid signature",
manifest.domain
)))
}
}
}
/// SHA-256 of `bytes` as a 32-byte digest.
fn sha256_digest(bytes: &[u8]) -> [u8; 32] {
let mut hasher = Sha256::new();
hasher.update(bytes);
hasher.finalize().into()
}
/// Parse a `sha256:<hex>` manifest hash into a 32-byte digest.
fn parse_sha256(s: &str) -> Result<[u8; 32], PluginError> {
let hex_part = s.strip_prefix("sha256:").ok_or_else(|| {
PluginError::InvalidManifest(format!(
"wasm_module_hash must be `sha256:<hex>`, got {s:?}"
))
})?;
let raw = hex::decode(hex_part).map_err(|e| {
PluginError::InvalidManifest(format!("wasm_module_hash hex decode: {e}"))
})?;
raw.try_into().map_err(|v: Vec<u8>| {
PluginError::InvalidManifest(format!(
"wasm_module_hash must decode to 32 bytes, got {}",
v.len()
))
})
}
/// Decode an `ed25519:<base64>` 32-byte verifying key.
fn decode_verifying_key(s: &str) -> Result<VerifyingKey, PluginError> {
let b64 = s.strip_prefix("ed25519:").ok_or_else(|| {
PluginError::InvalidManifest(format!(
"publisher_key must be `ed25519:<base64>`, got {s:?}"
))
})?;
let raw = base64::engine::general_purpose::STANDARD
.decode(b64)
.map_err(|e| PluginError::InvalidManifest(format!("publisher_key base64: {e}")))?;
let bytes: [u8; 32] = raw.try_into().map_err(|v: Vec<u8>| {
PluginError::InvalidManifest(format!(
"publisher_key must decode to 32 bytes, got {}",
v.len()
))
})?;
VerifyingKey::from_bytes(&bytes)
.map_err(|e| PluginError::InvalidManifest(format!("publisher_key not a valid Ed25519 point: {e}")))
}
/// Decode an `ed25519:<base64>` 64-byte signature.
fn decode_signature(s: &str) -> Result<Signature, PluginError> {
let b64 = s.strip_prefix("ed25519:").ok_or_else(|| {
PluginError::InvalidManifest(format!(
"wasm_module_sig must be `ed25519:<base64>`, got {s:?}"
))
})?;
let raw = base64::engine::general_purpose::STANDARD
.decode(b64)
.map_err(|e| PluginError::InvalidManifest(format!("wasm_module_sig base64: {e}")))?;
let bytes: [u8; 64] = raw.try_into().map_err(|v: Vec<u8>| {
PluginError::InvalidManifest(format!(
"wasm_module_sig must decode to 64 bytes, got {}",
v.len()
))
})?;
Ok(Signature::from_bytes(&bytes))
}
/// Encode a SHA-256 digest as the manifest `sha256:<hex>` form. Exposed so
/// tooling (and tests) can produce a manifest hash for real `.wasm` bytes.
pub fn encode_sha256(wasm_bytes: &[u8]) -> String {
format!("sha256:{}", hex::encode(sha256_digest(wasm_bytes)))
}
/// Encode an Ed25519 verifying key as the manifest `ed25519:<base64>` form.
pub fn encode_verifying_key(key: &VerifyingKey) -> String {
format!(
"ed25519:{}",
base64::engine::general_purpose::STANDARD.encode(key.to_bytes())
)
}
/// Encode an Ed25519 signature as the manifest `ed25519:<base64>` form.
pub fn encode_signature(sig: &Signature) -> String {
format!(
"ed25519:{}",
base64::engine::general_purpose::STANDARD.encode(sig.to_bytes())
)
}
#[cfg(test)]
mod tests {
use super::*;
use ed25519_dalek::{Signer, SigningKey};
/// Deterministic publisher key (mirrors witness_signing's fixed-bytes
/// seed convention — DO NOT use in production).
fn publisher() -> SigningKey {
SigningKey::from_bytes(b"homecore-plugins-pub-test-seed--")
}
fn attacker() -> SigningKey {
SigningKey::from_bytes(b"homecore-plugins-attacker-seed--")
}
/// Sign `wasm_bytes` with `key` and produce a manifest carrying the real
/// hash + signature + publisher key.
fn signed_manifest(wasm_bytes: &[u8], key: &SigningKey) -> PluginManifest {
let digest = sha256_digest(wasm_bytes);
let sig = key.sign(&digest);
PluginManifest {
domain: "demo".into(),
name: "Demo".into(),
version: "1.0.0".into(),
documentation: None,
iot_class: None,
config_flow: false,
integration_type: None,
dependencies: vec![],
requirements: vec![],
wasm_module: Some("demo.wasm".into()),
wasm_module_hash: Some(encode_sha256(wasm_bytes)),
wasm_module_sig: Some(encode_signature(&sig)),
publisher_key: Some(encode_verifying_key(&key.verifying_key())),
min_homecore_version: None,
host_imports_required: vec![],
homecore_permissions: vec![],
cog_id: None,
}
}
#[test]
fn valid_sig_from_trusted_key_passes() {
let wasm = b"\0asm\x01\0\0\0fake module bytes";
let key = publisher();
let manifest = signed_manifest(wasm, &key);
let policy =
PluginPolicy::trusted(&[&encode_verifying_key(&key.verifying_key())]).unwrap();
verify_module(&manifest, wasm, &policy).expect("trusted signed module should load");
}
#[test]
fn tampered_module_is_rejected() {
let wasm = b"\0asm\x01\0\0\0fake module bytes";
let key = publisher();
let manifest = signed_manifest(wasm, &key);
let policy =
PluginPolicy::trusted(&[&encode_verifying_key(&key.verifying_key())]).unwrap();
// Flip a byte: hash no longer matches.
let tampered = b"\0asm\x01\0\0\0FAKE module bytes";
let err = verify_module(&manifest, tampered, &policy).unwrap_err();
assert!(matches!(err, PluginError::SignatureRejected(_)), "got {err:?}");
}
#[test]
fn valid_sig_from_untrusted_key_is_rejected() {
let wasm = b"\0asm\x01\0\0\0fake module bytes";
// Signed correctly by the attacker, but the attacker is not trusted.
let manifest = signed_manifest(wasm, &attacker());
let policy =
PluginPolicy::trusted(&[&encode_verifying_key(&publisher().verifying_key())]).unwrap();
let err = verify_module(&manifest, wasm, &policy).unwrap_err();
assert!(matches!(err, PluginError::SignatureRejected(_)), "got {err:?}");
}
#[test]
fn forged_signature_is_rejected() {
// Manifest claims the trusted publisher_key but the signature was
// produced by the attacker (a forged sig under a trusted identity).
let wasm = b"\0asm\x01\0\0\0fake module bytes";
let digest = sha256_digest(wasm);
let forged = attacker().sign(&digest);
let mut manifest = signed_manifest(wasm, &publisher());
manifest.wasm_module_sig = Some(encode_signature(&forged));
let policy =
PluginPolicy::trusted(&[&encode_verifying_key(&publisher().verifying_key())]).unwrap();
let err = verify_module(&manifest, wasm, &policy).unwrap_err();
assert!(matches!(err, PluginError::SignatureRejected(_)), "got {err:?}");
}
#[test]
fn unsigned_module_rejected_under_default_policy() {
let wasm = b"\0asm\x01\0\0\0unsigned";
let manifest = PluginManifest {
domain: "u".into(),
name: "U".into(),
version: "1".into(),
documentation: None,
iot_class: None,
config_flow: false,
integration_type: None,
dependencies: vec![],
requirements: vec![],
wasm_module: Some("u.wasm".into()),
wasm_module_hash: None,
wasm_module_sig: None,
publisher_key: None,
min_homecore_version: None,
host_imports_required: vec![],
homecore_permissions: vec![],
cog_id: None,
};
let err = verify_module(&manifest, wasm, &PluginPolicy::deny_all()).unwrap_err();
assert!(matches!(err, PluginError::SignatureRejected(_)), "got {err:?}");
// ...but AllowUnsigned loads it (with a warn).
verify_module(&manifest, wasm, &PluginPolicy::AllowUnsigned)
.expect("AllowUnsigned should load an unsigned module");
}
}
@@ -30,16 +30,27 @@ use wasmtime::{Engine, Linker, Module, Store};
use crate::error::PluginError;
use crate::host_abi::{LogLevel, StateChangedEventJson, MAX_ABI_BUFFER_BYTES};
use crate::manifest::PluginManifest;
use crate::permissions::PermissionSet;
use crate::verify::{verify_module, PluginPolicy};
// ── Store data ─────────────────────────────────────────────────────────────
/// Per-plugin state stored inside the Wasmtime [`Store`].
///
/// Wasmtime's `Store<T>` exposes `T` to host functions via `caller.data()`.
/// We store the `HomeCore` handle and a list of subscribed entity IDs here.
/// We store the `HomeCore` handle, a list of subscribed entity IDs, and the
/// plugin's write-permission set (ADR-162 P5 authority isolation).
pub struct PluginStoreData {
pub hc: HomeCore,
pub subscriptions: Vec<String>,
/// Entity-write authority distilled from the manifest's
/// `homecore_permissions`. Consulted by `hc_state_set`. The
/// permission-free [`WasmtimeRuntime::load_wasm`] path installs an
/// all-allowing set for backward compatibility; the
/// [`WasmtimeRuntime::load_plugin`] path installs the manifest's
/// declared set.
pub permissions: PermissionSet,
}
// ── WasmtimeRuntime ────────────────────────────────────────────────────────
@@ -59,14 +70,53 @@ impl WasmtimeRuntime {
Ok(Self { engine })
}
/// Compile and instantiate a WASM plugin from raw bytes.
/// Compile and instantiate a WASM plugin from raw bytes, **without**
/// signature verification or permission gating (the plugin gets
/// all-write authority).
///
/// Returns a [`WasmPlugin`] handle that owns the `Store` and the
/// `Instance`. The handle can be used to call into the WASM module.
/// Retained for the legacy/test path and first-party trusted modules.
/// Production plugin loading should go through [`Self::load_plugin`],
/// which verifies the module (ADR-162 P4) and scopes its write
/// authority to the manifest (P5).
pub fn load_wasm(
&self,
wasm_bytes: &[u8],
hc: HomeCore,
) -> Result<WasmPlugin, PluginError> {
self.instantiate(wasm_bytes, hc, PermissionSet::allow_all())
}
/// Verify and instantiate a WASM plugin from its manifest + raw bytes.
///
/// This is the secure load path (ADR-162):
/// 1. **P4** — [`verify_module`] checks the SHA-256 module hash and
/// Ed25519 signature against the manifest under `policy`. A
/// tampered module, bad/forged signature, untrusted publisher, or
/// (under the secure default) an unsigned module is rejected
/// **before** any guest code runs.
/// 2. **P5** — the plugin's `homecore_permissions` are distilled into
/// a [`PermissionSet`] installed in the store, so `hc_state_set`
/// can only write entities the plugin declared.
pub fn load_plugin(
&self,
manifest: &PluginManifest,
wasm_bytes: &[u8],
hc: HomeCore,
policy: &PluginPolicy,
) -> Result<WasmPlugin, PluginError> {
// P4: verify before instantiation.
verify_module(manifest, wasm_bytes, policy)?;
// P5: scope write authority to the manifest's declared permissions.
let permissions = PermissionSet::from_manifest(manifest);
self.instantiate(wasm_bytes, hc, permissions)
}
/// Shared compile + instantiate, installing the given permission set.
fn instantiate(
&self,
wasm_bytes: &[u8],
hc: HomeCore,
permissions: PermissionSet,
) -> Result<WasmPlugin, PluginError> {
let module = Module::new(&self.engine, wasm_bytes)
.map_err(|e| PluginError::RuntimeError(format!("WASM compile: {e}")))?;
@@ -77,6 +127,7 @@ impl WasmtimeRuntime {
let store_data = PluginStoreData {
hc,
subscriptions: Vec::new(),
permissions,
};
let mut store = Store::new(&self.engine, store_data);
@@ -183,7 +234,9 @@ fn register_hc_state_get(
/// Sets the state for the entity whose UTF-8 ID is at `[eid_ptr,eid_ptr+eid_len)`.
/// The new state string is at `[state_ptr,state_ptr+state_len)`.
/// The attributes JSON is at `[attrs_ptr,attrs_ptr+attrs_len)`.
/// Returns 0 on success, negative on error.
/// Returns 0 on success, negative on error: -1 (bad memory/args), -2
/// (invalid entity id), -3 (permission denied — entity not in the
/// plugin's declared `homecore_permissions`, ADR-162 P5).
fn register_hc_state_set(
linker: &mut Linker<PluginStoreData>,
) -> Result<(), PluginError> {
@@ -224,6 +277,20 @@ fn register_hc_state_set(
Ok(id) => id,
Err(_) => return -2,
};
// ── P5 authority isolation (ADR-162) ──────────────────────
// Reject a write to an entity the plugin did not declare in
// `homecore_permissions`. Return a typed error code to the
// guest (-3); do NOT panic the host.
if !caller.data().permissions.may_write(entity_id.as_str()) {
eprintln!(
"[PLUGIN WARN] denied hc_state_set on `{}` — not in plugin's declared \
homecore_permissions (P5 authority isolation)",
entity_id.as_str()
);
return -3;
}
let attrs: serde_json::Value =
serde_json::from_str(&attrs_str).unwrap_or(serde_json::json!({}));
@@ -371,4 +371,259 @@ mod wasmtime_tests {
let r = plugin.call_setup("{}").expect("setup");
assert_eq!(r, 0);
}
// ── ADR-162 P4: signature/integrity verification ────────────────────────
//
// Each of these FAILS on the pre-ADR-162 code, which had no
// `load_plugin` / `verify_module` at all — the manifest hash/sig/key
// were parsed and discarded. They drive the real verification gate.
use ed25519_dalek::{Signer, SigningKey};
use homecore_plugins::manifest::PluginManifest;
use homecore_plugins::verify::{encode_sha256, encode_signature, encode_verifying_key};
use homecore_plugins::PluginPolicy;
/// Deterministic publisher key (fixed seed — never use in production;
/// mirrors the cog-ha-matter witness_signing test-key convention).
fn publisher_key() -> SigningKey {
SigningKey::from_bytes(b"hc-plugins-integration-pub-seed-")
}
fn untrusted_key() -> SigningKey {
SigningKey::from_bytes(b"hc-plugins-integration-evil-seed")
}
/// A minimal valid module that writes `light.kitchen` on setup, plus a
/// `light.*` permission grant. Returns the WAT source.
const WRITE_LIGHT_WAT: &str = r#"
(module
(import "env" "hc_state_get" (func $hc_state_get (param i32 i32 i32 i32) (result i32)))
(import "env" "hc_state_set" (func $hc_state_set (param i32 i32 i32 i32 i32 i32) (result i32)))
(import "env" "hc_state_subscribe" (func $hc_state_subscribe (param i32 i32) (result i32)))
(import "env" "hc_log" (func $hc_log (param i32 i32 i32)))
(memory (export "memory") 1)
(global $bump (mut i32) (i32.const 512))
(data (i32.const 0) "light.kitchen")
(data (i32.const 64) "on")
(data (i32.const 128) "{}")
(func (export "alloc") (param i32) (result i32)
(local $p i32)
(local.set $p (global.get $bump))
(global.set $bump (i32.add (global.get $bump) (local.get 0)))
(local.get $p))
(func (export "dealloc") (param i32 i32))
(func (export "plugin_setup") (param i32 i32) (result i32)
(call $hc_state_set
(i32.const 0) (i32.const 13) ;; "light.kitchen"
(i32.const 64) (i32.const 2) ;; "on"
(i32.const 128) (i32.const 2)) ;; "{}"
drop
(i32.const 0))
(func (export "plugin_handle_state_changed") (param i32 i32) (result i32) (i32.const 0))
)
"#;
/// Build a manifest signed by `key` over the SHA-256 of `wasm_bytes`,
/// with the given write-permission grants.
fn signed_manifest(
wasm_bytes: &[u8],
key: &SigningKey,
perms: &[&str],
) -> PluginManifest {
use sha2::{Digest, Sha256};
let digest: [u8; 32] = Sha256::digest(wasm_bytes).into();
let sig = key.sign(&digest);
let mut m = PluginManifest::parse_json(
r#"{"domain":"demo","name":"Demo","version":"1.0.0"}"#,
)
.unwrap();
m.wasm_module = Some("demo.wasm".into());
m.wasm_module_hash = Some(encode_sha256(wasm_bytes));
m.wasm_module_sig = Some(encode_signature(&sig));
m.publisher_key = Some(encode_verifying_key(&key.verifying_key()));
m.homecore_permissions = perms.iter().map(|s| s.to_string()).collect();
m
}
#[test]
fn p4_valid_sig_from_trusted_key_loads() {
let wasm = wat::parse_str(WRITE_LIGHT_WAT).expect("WAT");
let key = publisher_key();
let manifest = signed_manifest(&wasm, &key, &["light.*"]);
let policy =
PluginPolicy::trusted(&[&encode_verifying_key(&key.verifying_key())]).unwrap();
let rt = WasmtimeRuntime::new().expect("rt");
let hc = HomeCore::new();
rt.load_plugin(&manifest, &wasm, hc, &policy)
.expect("a validly-signed, trusted plugin must load");
}
#[test]
fn p4_tampered_module_is_rejected() {
let wasm = wat::parse_str(WRITE_LIGHT_WAT).expect("WAT");
let key = publisher_key();
// Manifest signs the original bytes; we then load DIFFERENT bytes.
let manifest = signed_manifest(&wasm, &key, &["light.*"]);
let policy =
PluginPolicy::trusted(&[&encode_verifying_key(&key.verifying_key())]).unwrap();
// Re-compile a byte-different module (writes "off" not "on").
let tampered_src = WRITE_LIGHT_WAT.replace(r#""on""#, r#""of""#);
let tampered = wat::parse_str(&tampered_src).expect("WAT");
assert_ne!(wasm, tampered, "test bug: bytes must differ");
let rt = WasmtimeRuntime::new().expect("rt");
let hc = HomeCore::new();
match rt.load_plugin(&manifest, &tampered, hc, &policy) {
Err(homecore_plugins::PluginError::SignatureRejected(_)) => {}
Ok(_) => panic!("tampered module must be rejected (hash mismatch), but it loaded"),
Err(e) => panic!("expected SignatureRejected, got {e:?}"),
}
}
#[test]
fn p4_valid_sig_from_untrusted_key_is_rejected() {
let wasm = wat::parse_str(WRITE_LIGHT_WAT).expect("WAT");
// Correctly signed by the untrusted key — but it is not on the allowlist.
let manifest = signed_manifest(&wasm, &untrusted_key(), &["light.*"]);
let policy =
PluginPolicy::trusted(&[&encode_verifying_key(&publisher_key().verifying_key())])
.unwrap();
let rt = WasmtimeRuntime::new().expect("rt");
let hc = HomeCore::new();
match rt.load_plugin(&manifest, &wasm, hc, &policy) {
Err(homecore_plugins::PluginError::SignatureRejected(_)) => {}
Ok(_) => panic!("untrusted publisher must be rejected, but it loaded"),
Err(e) => panic!("expected SignatureRejected, got {e:?}"),
}
}
#[test]
fn p4_unsigned_module_rejected_by_default_loads_only_under_allow_unsigned() {
let wasm = wat::parse_str(WRITE_LIGHT_WAT).expect("WAT");
let mut manifest = PluginManifest::parse_json(
r#"{"domain":"u","name":"U","version":"1"}"#,
)
.unwrap();
manifest.wasm_module = Some("u.wasm".into());
manifest.homecore_permissions = vec!["light.*".into()];
// No hash/sig/key → unsigned.
let rt = WasmtimeRuntime::new().expect("rt");
// Secure default: rejected.
match rt.load_plugin(&manifest, &wasm, HomeCore::new(), &PluginPolicy::deny_all()) {
Err(homecore_plugins::PluginError::SignatureRejected(_)) => {}
Ok(_) => panic!("unsigned module must be rejected under the secure default"),
Err(e) => panic!("expected SignatureRejected, got {e:?}"),
}
// Dev escape hatch: loads (with a loud warn).
rt.load_plugin(
&manifest,
&wasm,
HomeCore::new(),
&PluginPolicy::AllowUnsigned,
)
.expect("AllowUnsigned dev policy must load an unsigned module");
}
// ── ADR-162 P5: authority / capability isolation ────────────────────────
//
// FAILS on the pre-ADR-162 code, where `hc_state_set` ignored
// `homecore_permissions` entirely and let any plugin write any entity.
/// Module that writes `lock.front_door` on setup (an over-privileged
/// write a `light.*` plugin must NOT be allowed to perform).
const WRITE_LOCK_WAT: &str = r#"
(module
(import "env" "hc_state_get" (func $hc_state_get (param i32 i32 i32 i32) (result i32)))
(import "env" "hc_state_set" (func $hc_state_set (param i32 i32 i32 i32 i32 i32) (result i32)))
(import "env" "hc_state_subscribe" (func $hc_state_subscribe (param i32 i32) (result i32)))
(import "env" "hc_log" (func $hc_log (param i32 i32 i32)))
(memory (export "memory") 1)
(global $bump (mut i32) (i32.const 512))
(data (i32.const 0) "lock.front_door")
(data (i32.const 64) "unlocked")
(data (i32.const 128) "{}")
(func (export "alloc") (param i32) (result i32)
(local $p i32)
(local.set $p (global.get $bump))
(global.set $bump (i32.add (global.get $bump) (local.get 0)))
(local.get $p))
(func (export "dealloc") (param i32 i32))
;; plugin_setup returns the hc_state_set result code so the host test can
;; assert the guest saw the typed permission-denied error (-3).
(func (export "plugin_setup") (param i32 i32) (result i32)
(call $hc_state_set
(i32.const 0) (i32.const 15) ;; "lock.front_door"
(i32.const 64) (i32.const 8) ;; "unlocked"
(i32.const 128) (i32.const 2))) ;; "{}"
(func (export "plugin_handle_state_changed") (param i32 i32) (result i32) (i32.const 0))
)
"#;
#[test]
fn p5_declared_light_plugin_may_write_light_but_not_lock() {
let key = publisher_key();
let trusted = PluginPolicy::trusted(&[&encode_verifying_key(&key.verifying_key())]).unwrap();
let rt = WasmtimeRuntime::new().expect("rt");
// (a) A `light.*` plugin writing `light.kitchen` → ALLOWED.
let light_wasm = wat::parse_str(WRITE_LIGHT_WAT).expect("WAT");
let light_manifest = signed_manifest(&light_wasm, &key, &["light.*"]);
let hc_a = HomeCore::new();
let plugin_a = rt
.load_plugin(&light_manifest, &light_wasm, hc_a.clone(), &trusted)
.expect("light plugin loads");
let r = plugin_a.call_setup("{}").expect("setup");
assert_eq!(r, 0, "write to declared light.kitchen should succeed");
let kitchen = homecore::EntityId::parse("light.kitchen").unwrap();
assert_eq!(
hc_a.states().get(&kitchen).expect("light.kitchen written").state,
"on"
);
// (b) The SAME `light.*` plugin attempting to write `lock.front_door`
// → REJECTED with the typed -3 code, and the lock is NOT written.
let lock_wasm = wat::parse_str(WRITE_LOCK_WAT).expect("WAT");
let lock_manifest = signed_manifest(&lock_wasm, &key, &["light.*"]);
let hc_b = HomeCore::new();
let plugin_b = rt
.load_plugin(&lock_manifest, &lock_wasm, hc_b.clone(), &trusted)
.expect("module loads (verification ok); the WRITE is what's gated");
let denied = plugin_b.call_setup("{}").expect("setup runs without trapping host");
assert_eq!(
denied, -3,
"over-privileged write to lock.front_door must return -3 (permission denied)"
);
let lock = homecore::EntityId::parse("lock.front_door").unwrap();
assert!(
hc_b.states().get(&lock).is_none(),
"lock.front_door must NOT have been written by a light-only plugin"
);
}
#[test]
fn p5_plugin_with_no_permissions_can_write_nothing() {
let key = publisher_key();
let trusted = PluginPolicy::trusted(&[&encode_verifying_key(&key.verifying_key())]).unwrap();
let rt = WasmtimeRuntime::new().expect("rt");
let wasm = wat::parse_str(WRITE_LIGHT_WAT).expect("WAT");
// No permissions declared at all.
let manifest = signed_manifest(&wasm, &key, &[]);
let hc = HomeCore::new();
let plugin = rt
.load_plugin(&manifest, &wasm, hc.clone(), &trusted)
.expect("module loads; the write is gated");
// WRITE_LIGHT_WAT drops the host-import result and returns 0, so we
// assert the denial via the side-effect: the write must NOT land.
plugin.call_setup("{}").expect("setup runs without trapping host");
let kitchen = homecore::EntityId::parse("light.kitchen").unwrap();
assert!(
hc.states().get(&kitchen).is_none(),
"no-permission plugin must not write light.kitchen (P5 authority isolation)"
);
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "wifi-densepose-cli"
version.workspace = true
version = "0.3.1"
edition.workspace = true
description = "CLI for WiFi-DensePose"
authors.workspace = true
@@ -405,7 +405,9 @@ mod tests {
#[test]
fn test_tier_config_he20() {
let cfg = tier_config("he20");
assert_eq!(cfg.num_active, 242);
// Issue #1009 §1b: HE20 baseline records all 256 delivered bins
// (no tone map in the recorder), not the 242 active tones.
assert_eq!(cfg.num_active, 256);
}
#[test]
@@ -188,6 +188,8 @@ Thread.sleep(forTimeInterval: 3)"#,
bail!("macOS camera capture requires GUI session with camera permission")
}
// Used only by the macOS capture path above; dead on other targets.
#[allow(dead_code)]
fn decode_jpeg_to_rgb(path: &PathBuf, _width: u32, _height: u32) -> Result<Frame> {
let data = std::fs::read(path)?;
let _ = std::fs::remove_file(path);
@@ -261,9 +261,15 @@ pub enum ClockGateDecision {
/// Both terms pass: node admitted at full weight.
Admit,
/// Phase OK but clock degraded: evidence-only, NO environment/model update.
MonitorOnly { clock_quality: f32 },
MonitorOnly {
/// Combined clock-quality score in [0, 1] (dispersion × age terms).
clock_quality: f32,
},
/// Either term fails hard: node excluded this cycle.
Reject { reason: ClockRejectReason },
Reject {
/// Which hard term failed (phase, dispersion, or age).
reason: ClockRejectReason,
},
}
/// Clock-quality gate: combines the phase [`CoherenceGate`] with clock
@@ -1,6 +1,6 @@
[package]
name = "wifi-densepose-sensing-server"
version = "0.3.2"
version = "0.3.3"
edition.workspace = true
description = "Lightweight Axum server for WiFi sensing UI with RuVector signal processing"
license.workspace = true
@@ -1483,6 +1483,65 @@ fn parse_esp32_frame(buf: &[u8]) -> Option<Esp32Frame> {
})
}
#[cfg(test)]
mod issue_1009_n_subcarriers_u16_tests {
//! Issue #1009 §1c — `parse_esp32_frame` must read `n_subcarriers` as a
//! u16 LE at bytes 6..7 (ADR-018 wire format), not a single byte at 6.
//!
//! An ESP32-C6 HE20 frame carries 256 subcarriers → byte 6 = 0x00,
//! byte 7 = 0x01. The pre-#1005 single-byte read decoded this as 0
//! subcarriers, silently dropping every real HE20 frame. This was the same
//! truncation as the CLI parser (`wifi-densepose-cli` calibrate.rs); this
//! module pins that the sensing-server template stays u16-correct.
use super::*;
/// Build an ADR-018 CSI frame (magic 0xC511_0001, 20-byte header).
fn build_csi_frame(n_subcarriers: u16) -> Vec<u8> {
let mut buf = vec![0u8; 20 + n_subcarriers as usize * 2];
buf[0..4].copy_from_slice(&0xC511_0001u32.to_le_bytes());
buf[4] = 7; // node_id
buf[5] = 1; // n_antennas
buf[6..8].copy_from_slice(&n_subcarriers.to_le_bytes()); // u16 LE
buf[8..12].copy_from_slice(&5180u32.to_le_bytes()); // freq_mhz (5 GHz HE)
buf[12..16].copy_from_slice(&42u32.to_le_bytes()); // sequence
buf[16] = (-40i8) as u8; // rssi
buf[17] = (-90i8) as u8; // noise_floor
buf[18] = 0; // ppdu_type
buf[19] = 0;
for k in 0..n_subcarriers as usize {
buf[20 + k * 2] = (5 + (k % 40) as i8) as u8; // i
buf[20 + k * 2 + 1] = (k % 30) as u8; // q
}
buf
}
#[test]
fn parse_esp32_frame_he20_256_bins_not_truncated() {
// 256 = 0x0100 LE: byte6 = 0x00, byte7 = 0x01. A u8 read of byte 6
// would see 0 subcarriers; a u16 read sees 256.
let buf = build_csi_frame(256);
assert_eq!(buf.len(), 532, "256-bin frame wire size = 20 + 256*2");
let frame = parse_esp32_frame(&buf).expect("256-bin HE20 frame must parse");
assert_eq!(
frame.n_subcarriers, 256,
"n_subcarriers must read as u16 (256), not the byte-6-only 0"
);
assert_eq!(frame.amplitudes.len(), 256);
assert_eq!(frame.node_id, 7);
assert_eq!(frame.rssi, -40);
assert_eq!(frame.sequence, 42);
}
#[test]
fn parse_esp32_frame_ht20_64_bins_still_parses() {
// Regression guard for the common single-byte (≤255) case.
let buf = build_csi_frame(64);
let frame = parse_esp32_frame(&buf).expect("64-bin HT20 frame must parse");
assert_eq!(frame.n_subcarriers, 64);
assert_eq!(frame.amplitudes.len(), 64);
}
}
// ── Signal field generation ──────────────────────────────────────────────────
/// Generate a signal field that reflects where motion and signal changes are occurring.
@@ -2694,6 +2753,203 @@ async fn probe_esp32(port: u16) -> bool {
}
}
// ── Source resolution state machine (issue #1004) ────────────────────────────
/// What background tasks to start, derived from `--source` and the boot probes.
///
/// Issue #1004: a one-shot startup probe latched `auto` to `simulate` forever
/// when no CSI happened to be flowing at boot (the normal case — the firmware
/// and the server race to come up). The UDP :5005 receiver was then never
/// bound, so real CSI arriving seconds later was silently ignored and the
/// server served simulated poses for the rest of the process. The UI looked
/// live; the data was fake. This is the exact "where's the real data?" failure
/// class the project fights.
///
/// The robust resolution: in `auto` mode **always bind the UDP receiver**
/// regardless of the boot probe. If no real source is up yet, serve simulated
/// data *and* keep the UDP receiver listening; the receiver promotes
/// `source` → `esp32` the instant the first real frame lands (see
/// `udp_receiver_task`, which sets `s.source = "esp32"`), mirroring the inverse
/// `esp32 → esp32:offline` reversion already in `effective_source()`.
///
/// Explicit `--source simulated` is a hard override for offline demos: it does
/// NOT bind UDP, so no promotion ever happens.
#[derive(Debug, Clone, PartialEq, Eq)]
struct SourcePlan {
/// The `AppStateInner.source` value to start with.
initial_source: String,
/// Bind the UDP :5005 receiver (and thus allow simulate→esp32 promotion).
bind_udp: bool,
/// Run the simulated-data generator (serves poses until a real frame arrives).
run_simulator: bool,
/// Run the Windows WiFi capture task.
run_wifi: bool,
}
/// Pure decision function — fully unit-testable without binding sockets.
///
/// `requested` is the normalized `--source` value. `esp32_detected` /
/// `wifi_detected` are the boot-probe results (only consulted in `auto` mode).
/// Returns `None` for an unknown source that names neither a real source nor a
/// simulate alias (the caller maps that to its own pass-through/exit policy).
fn plan_source(requested: &str, esp32_detected: bool, wifi_detected: bool) -> SourcePlan {
match requested {
"auto" => {
if esp32_detected {
// Real CSI already flowing — bind UDP, no simulator.
SourcePlan {
initial_source: "esp32".to_string(),
bind_udp: true,
run_simulator: false,
run_wifi: false,
}
} else if wifi_detected {
SourcePlan {
initial_source: "wifi".to_string(),
bind_udp: false,
run_simulator: false,
run_wifi: true,
}
} else {
// No real source *yet*. Serve simulated data, but ALSO bind UDP
// so the receiver can promote to esp32 when the first real
// frame arrives (issue #1004). Never latch on simulate.
SourcePlan {
initial_source: "simulated".to_string(),
bind_udp: true,
run_simulator: true,
run_wifi: false,
}
}
}
// Explicit overrides. "simulate" is a back-compat alias for "simulated".
"simulate" | "simulated" => SourcePlan {
initial_source: "simulated".to_string(),
bind_udp: false, // hard override: offline demo, no live promotion
run_simulator: true,
run_wifi: false,
},
"esp32" => SourcePlan {
initial_source: "esp32".to_string(),
bind_udp: true,
run_simulator: false,
run_wifi: false,
},
"wifi" => SourcePlan {
initial_source: "wifi".to_string(),
bind_udp: false,
run_simulator: false,
run_wifi: true,
},
// Unknown source — preserve it verbatim, no tasks (caller's policy).
other => SourcePlan {
initial_source: other.to_string(),
bind_udp: false,
run_simulator: false,
run_wifi: false,
},
}
}
#[cfg(test)]
mod issue_1004_source_plan_tests {
//! Issue #1004 — `--source auto` must NOT latch on `simulate` forever.
//!
//! Old behavior: a one-shot boot probe resolved the source once. With no CSI
//! flowing at boot (the normal case), the server either latched on simulate
//! (never binding UDP :5005, so later real CSI was silently ignored) or
//! hard-exited (#937), never picking up CSI that started after launch.
//!
//! New behavior (`plan_source`): in `auto` the UDP receiver is ALWAYS bound,
//! simulated data is served only until the first real frame, then
//! `udp_receiver_task` promotes `source` → "esp32". These tests pin the
//! resolution/promotion state machine directly (no sockets bound).
use super::*;
// FAILS ON OLD CODE: the old `auto`-with-no-source path bound no UDP
// receiver (it spawned only `simulated_data_task`, or exited). This asserts
// UDP IS bound even when the boot probe finds no source.
#[test]
fn auto_with_no_boot_source_still_binds_udp_and_simulates() {
let plan = plan_source("auto", false, false);
assert!(plan.bind_udp, "auto must bind UDP :5005 even with no boot source (#1004)");
assert!(plan.run_simulator, "auto must serve simulated data until real CSI arrives");
assert!(!plan.run_wifi);
assert_eq!(plan.initial_source, "simulated");
}
#[test]
fn auto_with_esp32_detected_binds_udp_no_simulator() {
let plan = plan_source("auto", true, false);
assert!(plan.bind_udp);
assert!(!plan.run_simulator, "real CSI present → no synthetic frames");
assert_eq!(plan.initial_source, "esp32");
}
#[test]
fn auto_with_wifi_detected_runs_wifi_no_udp() {
let plan = plan_source("auto", false, true);
assert!(plan.run_wifi);
assert!(!plan.bind_udp);
assert!(!plan.run_simulator);
assert_eq!(plan.initial_source, "wifi");
}
// Explicit `--source simulated` is a hard offline override: it must NOT bind
// UDP (so it can never be promoted to live), distinguishing it from
// auto-mode simulate.
#[test]
fn explicit_simulated_is_offline_override_no_udp() {
for s in ["simulated", "simulate"] {
let plan = plan_source(s, false, false);
assert!(!plan.bind_udp, "{s}: explicit simulate must not bind UDP (offline demo)");
assert!(plan.run_simulator);
assert_eq!(plan.initial_source, "simulated");
}
}
#[test]
fn explicit_esp32_binds_udp() {
let plan = plan_source("esp32", false, false);
assert!(plan.bind_udp);
assert!(!plan.run_simulator);
assert_eq!(plan.initial_source, "esp32");
}
// Promotion check: the runtime promotes by setting `AppStateInner.source`
// to "esp32" on the first real frame; `effective_source()` then reports it
// (and reverts to "esp32:offline" after a 5 s gap). This asserts the
// promotion direction the simulator/receiver rely on, without binding a
// socket — it exercises the same `source` field the UDP task writes.
#[test]
fn effective_source_promotes_from_simulated_to_esp32_on_real_frame() {
// Start as the auto/simulate plan would: source = "simulated".
let mut src = "simulated".to_string();
// effective_source() logic for the simulate state: stays "simulated".
assert_eq!(promote_view(&src, None), "simulated");
// First real frame arrives → udp_receiver_task sets source = "esp32".
src = "esp32".to_string();
let fresh = Some(std::time::Duration::from_millis(10));
assert_eq!(promote_view(&src, fresh), "esp32", "fresh esp32 frame ⇒ live");
// After a >5 s gap it reverts to offline (inverse machinery, #1004).
let stale = Some(ESP32_OFFLINE_TIMEOUT + std::time::Duration::from_secs(1));
assert_eq!(promote_view(&src, stale), "esp32:offline");
}
/// Mirror of `AppStateInner::effective_source` over just (source, age) so the
/// promotion/reversion logic is testable without constructing full state.
fn promote_view(source: &str, last_frame_age: Option<std::time::Duration>) -> String {
if source == "esp32" {
if let Some(age) = last_frame_age {
if age > ESP32_OFFLINE_TIMEOUT {
return "esp32:offline".to_string();
}
}
}
source.to_string()
}
}
// ── Simulated data generator ─────────────────────────────────────────────────
fn generate_simulated_frame(tick: u64) -> Esp32Frame {
@@ -5699,6 +5955,18 @@ async fn simulated_data_task(state: SharedState, tick_ms: u64) {
interval.tick().await;
let mut s = state.write().await;
// Issue #1004: in `auto` mode this task runs alongside `udp_receiver_task`.
// Once a real frame promotes `source` → "esp32", stop emitting synthetic
// frames so we never clobber live CSI with simulated poses. (For an
// explicit `--source simulated` demo, `source` stays "simulated" and the
// simulator keeps running — that path never binds UDP, so it is never
// promoted.) The task stays alive so it can resume serving if the real
// source later ages out to "esp32:offline".
if s.effective_source() == "esp32" {
continue;
}
s.tick += 1;
let tick = s.tick;
@@ -6584,48 +6852,48 @@ async fn main() {
info!(" UI path: {}", args.ui_path.display());
info!(" Source: {}", args.source);
// Auto-detect data source.
// Resolve the data source into a concrete task plan (issue #1004).
//
// Issue #937 / sibling fix: previously `auto` silently fell back to the
// synthetic data source when no ESP32 or Windows WiFi was reachable, with
// only an `info!` log line as the signal. Downstream API consumers
// (`/api/v1/sensing/latest`, `/ws/sensing`) had no in-band way to know they
// were being served fake CSI tagged as production telemetry. That is the
// exact "where's the real data?" pattern external reviewers (#943, #934)
// cited as the most damaging evidence of the project misrepresenting its
// posture. Synthetic-data is now opt-in only — operators who want demo
// mode must explicitly set `--source simulated` or `CSI_SOURCE=simulated`.
let source = match args.source.as_str() {
"auto" => {
info!("Auto-detecting data source...");
if probe_esp32(args.udp_port).await {
info!(" ESP32 CSI detected on UDP :{}", args.udp_port);
"esp32"
} else if probe_windows_wifi().await {
info!(" Windows WiFi detected");
"wifi"
} else {
error!(
"No real CSI source detected. Auto-detection refuses to silently \
fall back to synthetic data because that would expose downstream \
consumers (/api/v1/sensing/latest, /ws/sensing) to fake telemetry \
tagged as production. To run with synthetic data, set the source \
explicitly: --source simulated (or CSI_SOURCE=simulated in Docker). \
To use real hardware: provision an ESP32 to emit CSI on UDP :{} or \
install the Windows WiFi capture driver. See \
https://github.com/ruvnet/RuView/issues/937 for context.",
args.udp_port
);
std::process::exit(78); // EX_CONFIG
}
// Issue #937 (prior fix): `auto` must never serve fake CSI *tagged as
// production telemetry*. We keep that guarantee — in the gap before real
// CSI arrives, `source` is the honest string "simulated" (downstream
// `/api/v1/sensing/latest`, `/ws/sensing` see `source: "simulated"`, not a
// production tag). What #937's hard-exit got wrong: at boot the firmware and
// server race, so CSI usually is NOT flowing during the 2 s probe. Exiting
// (or latching on simulate) meant the server could never pick up CSI that
// started seconds later. The robust resolution (see `plan_source`): in
// `auto` always bind the UDP :5005 receiver; serve simulated until the first
// real frame; then `udp_receiver_task` promotes `source` → "esp32". Explicit
// `--source simulated` stays a hard, UDP-free override for offline demos.
let normalized = if args.source == "simulate" { "simulated" } else { args.source.as_str() };
let plan = if normalized == "auto" {
info!("Auto-detecting data source (UDP :{} bound either way)...", args.udp_port);
let esp32 = probe_esp32(args.udp_port).await;
let wifi = if esp32 { false } else { probe_windows_wifi().await };
if esp32 {
info!(" ESP32 CSI detected on UDP :{}", args.udp_port);
} else if wifi {
info!(" Windows WiFi detected");
} else {
warn!(
"No real CSI source at boot — serving SIMULATED data (tagged as \
'simulated', not production) while the UDP :{} receiver stays bound. \
The server promotes to live the instant a real frame arrives (issue \
#1004). For an offline demo with no live promotion, pass \
--source simulated explicitly.",
args.udp_port
);
}
// "simulate" is a synonym for "simulated" (back-compat alias kept so
// existing operators who already opted in don't get broken by this fix).
"simulate" => "simulated",
other => other,
plan_source("auto", esp32, wifi)
} else {
plan_source(normalized, false, false)
};
let source: &str = plan.initial_source.as_str();
info!("Data source: {source}");
info!(
"Data source: {source} (udp_receiver={}, simulator={}, wifi={})",
plan.bind_udp, plan.run_simulator, plan.run_wifi
);
// Shared state
// Vital sign sample rate derives from tick interval (e.g. 500ms tick => 2 Hz)
@@ -6905,18 +7173,22 @@ async fn main() {
data_dir: data_dir.clone(),
}));
// Start background tasks based on source
match source {
"esp32" => {
tokio::spawn(udp_receiver_task(state.clone(), args.udp_port));
tokio::spawn(broadcast_tick_task(state.clone(), args.tick_ms));
}
"wifi" => {
tokio::spawn(windows_wifi_task(state.clone(), args.tick_ms));
}
_ => {
tokio::spawn(simulated_data_task(state.clone(), args.tick_ms));
}
// Start background tasks from the resolved plan (issue #1004).
//
// In `auto` mode with no boot source, `bind_udp` AND `run_simulator` are
// both true: the UDP receiver is bound so real CSI can promote the source,
// and the simulator serves poses in the meantime (it self-suspends once
// promoted — see `simulated_data_task`). Explicit `--source simulated` has
// `bind_udp = false`, so it serves simulated data only, with no live binding.
if plan.bind_udp {
tokio::spawn(udp_receiver_task(state.clone(), args.udp_port));
tokio::spawn(broadcast_tick_task(state.clone(), args.tick_ms));
}
if plan.run_wifi {
tokio::spawn(windows_wifi_task(state.clone(), args.tick_ms));
}
if plan.run_simulator {
tokio::spawn(simulated_data_task(state.clone(), args.tick_ms));
}
// ADR-050: Parse bind address once, use for all listeners
@@ -145,6 +145,8 @@ pub fn matter_mapping(entity: EntityKind) -> Option<MatterClusterMapping> {
}
/// True iff the entity has a Matter exposure on a current spec cluster.
// P2 Matter-publisher API surface; real Matter exposure is deferred (ADR-159 §A5).
#[allow(dead_code)]
pub fn entity_on_matter(entity: EntityKind) -> bool {
matter_mapping(entity).is_some()
}
@@ -152,6 +154,8 @@ pub fn entity_on_matter(entity: EntityKind) -> bool {
/// Compute the next available endpoint ID for a node-scoped entity,
/// given a starting offset (the bridge's first child endpoint). Used
/// by the publisher to assign per-primitive endpoints deterministically.
// P2 Matter-publisher API surface; real Matter exposure is deferred (ADR-159 §A5).
#[allow(dead_code)]
pub fn next_endpoint(base: u16, primitive_index: u16) -> u16 {
base.saturating_add(primitive_index)
}
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "wifi-densepose-signal"
version = "0.3.3"
version = "0.3.4"
edition.workspace = true
description = "WiFi CSI signal processing for DensePose estimation"
license.workspace = true
@@ -1,7 +1,7 @@
//! Criterion benchmarks for the empty-room baseline calibration module (ADR-135).
//!
//! Measures per-call throughput of CalibrationRecorder and BaselineCalibration
//! across HT20 (K=52), HT40 (K=114), HE20 (K=242), and HE40 (K=484).
//! across HT20 (K=52), HT40 (K=114), HE20 (K=256, all bins; #1009), and HE40 (K=484).
//!
//! Run (compile-only — no execution):
//! cargo bench -p wifi-densepose-signal --no-default-features --bench calibration_bench --no-run
@@ -63,7 +63,8 @@ fn tiers() -> Vec<TierSpec> {
vec![
TierSpec { label: "ht20", n_active: 52, bandwidth_mhz: 20, config: CalibrationConfig::ht20() },
TierSpec { label: "ht40", n_active: 114, bandwidth_mhz: 40, config: CalibrationConfig::ht40() },
TierSpec { label: "he20", n_active: 242, bandwidth_mhz: 20, config: CalibrationConfig::he20() },
// Issue #1009 §1b: HE20 records all 256 delivered bins (he20().num_active == 256).
TierSpec { label: "he20", n_active: 256, bandwidth_mhz: 20, config: CalibrationConfig::he20() },
TierSpec { label: "he40", n_active: 484, bandwidth_mhz: 40, config: CalibrationConfig::he40() },
]
}
@@ -109,9 +109,26 @@ impl CalibrationConfig {
pub fn ht40() -> Self {
Self { tier: PhyTier::Ht40, num_subcarriers: 128, num_active: 114, min_frames: 600, max_phase_variance: 0.3 }
}
/// HE20 defaults: 256 FFT, 242 active.
/// HE20 defaults: 256 FFT, **256 active** (record all delivered bins).
///
/// Issue #1009: the ESP-IDF v5.5.2 driver delivers all 256 FFT bins on the
/// wire for an HE20 frame (242 data tones + pilots + guards + DC; n_subc =
/// 0x0100 LE, wire-verified on ESP32-C6). We set `num_active: 256` so the
/// recorder accumulates statistics over **every** delivered bin rather than
/// trimming to the first 242 columns.
///
/// Why not 242? `CalibrationRecorder` has no HE20 tone map — `extract_first_stream`
/// takes the first `num_active` columns *sequentially*. With 242 it would
/// keep bins 0..242 of the 256-bin grid, which are NOT the 242 active tones
/// (they include the lower guard band and DC) — silently corrupting the
/// empty-room baseline. Recording all 256 bins keeps amplitude/phase stats
/// aligned 1:1 with the live `deviation()` path (which also sees 256 bins),
/// so guard/DC bins simply carry near-zero, stable statistics and never
/// generate false occupancy alarms. The exact-242 tone map lives only in
/// `cir.rs` (`HE20_ACTIVE`), where the Φ sensing matrix genuinely needs it;
/// the baseline recorder does not.
pub fn he20() -> Self {
Self { tier: PhyTier::He20, num_subcarriers: 256, num_active: 242, min_frames: 600, max_phase_variance: 0.3 }
Self { tier: PhyTier::He20, num_subcarriers: 256, num_active: 256, min_frames: 600, max_phase_variance: 0.3 }
}
/// HE40 defaults: 512 FFT, 484 active.
pub fn he40() -> Self {
@@ -674,13 +691,38 @@ mod tests {
let he20 = CalibrationConfig::he20();
assert_eq!(he20.num_subcarriers, 256);
assert_eq!(he20.num_active, 242);
// Issue #1009: HE20 records all 256 delivered bins (no tone map in the
// baseline recorder), not the 242 active tones — see he20() rationale.
assert_eq!(he20.num_active, 256);
let he40 = CalibrationConfig::he40();
assert_eq!(he40.num_subcarriers, 512);
assert_eq!(he40.num_active, 484);
}
// Issue #1009 §1b: a real HE20 frame carries all 256 FFT bins. The recorder
// must accept it AND build the baseline over all 256 bins — not silently
// trim to the first 242 columns (which are guards/DC, not active tones).
//
// FAILS ON OLD CODE: with `he20().num_active == 242` the finalised baseline
// had only 242 subcarriers (256 → 242 sequential trim). This asserts 256.
#[test]
fn he20_records_all_256_bins_not_trimmed_to_242() {
let mut cfg = CalibrationConfig::he20();
cfg.min_frames = 1;
let mut rec = CalibrationRecorder::new(cfg);
// Feed a 256-bin frame exactly as ESP-IDF v5.5.2 delivers it.
let frame = constant_frame(256, 1.0, 0.0);
rec.record(&frame).expect("256-bin HE20 frame must be accepted");
let baseline = rec.finalize().expect("finalize after 1 frame (min_frames=1)");
assert_eq!(
baseline.subcarriers.len(),
256,
"HE20 baseline must cover all 256 delivered bins, not a 242-trim"
);
assert_eq!(baseline.tier, PhyTier::He20);
}
// Additional: insufficient frames → error.
#[test]
fn finalize_requires_min_frames() {
@@ -67,7 +67,10 @@ fn ht40_spec() -> TierSpec {
TierSpec { label: "HT40", n_active: 114, bandwidth_mhz: 40, config: CalibrationConfig::ht40() }
}
fn he20_spec() -> TierSpec {
TierSpec { label: "HE20", n_active: 242, bandwidth_mhz: 20, config: CalibrationConfig::he20() }
// Issue #1009 §1b: real HE20 frames carry all 256 FFT bins (242 data +
// pilots/guards/DC), and the recorder now records all 256 (he20().num_active
// == 256). Feed 256-bin frames to match the wire format.
TierSpec { label: "HE20", n_active: 256, bandwidth_mhz: 20, config: CalibrationConfig::he20() }
}
// ---------------------------------------------------------------------------
+577
View File
@@ -2,6 +2,33 @@
# It is not intended for manual editing.
version = 4
[[package]]
name = "aho-corasick"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
dependencies = [
"memchr",
]
[[package]]
name = "anes"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299"
[[package]]
name = "anstyle"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "autocfg"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "block-buffer"
version = "0.10.4"
@@ -11,12 +38,76 @@ dependencies = [
"generic-array",
]
[[package]]
name = "bumpalo"
version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "cast"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "ciborium"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e"
dependencies = [
"ciborium-io",
"ciborium-ll",
"serde",
]
[[package]]
name = "ciborium-io"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757"
[[package]]
name = "ciborium-ll"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9"
dependencies = [
"ciborium-io",
"half",
]
[[package]]
name = "clap"
version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51"
dependencies = [
"clap_builder",
]
[[package]]
name = "clap_builder"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
dependencies = [
"anstyle",
"clap_lex",
]
[[package]]
name = "clap_lex"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "cpufeatures"
version = "0.2.17"
@@ -26,6 +117,73 @@ dependencies = [
"libc",
]
[[package]]
name = "criterion"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f"
dependencies = [
"anes",
"cast",
"ciborium",
"clap",
"criterion-plot",
"is-terminal",
"itertools",
"num-traits",
"once_cell",
"oorandom",
"plotters",
"rayon",
"regex",
"serde",
"serde_derive",
"serde_json",
"tinytemplate",
"walkdir",
]
[[package]]
name = "criterion-plot"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1"
dependencies = [
"cast",
"itertools",
]
[[package]]
name = "crossbeam-deque"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51"
dependencies = [
"crossbeam-epoch",
"crossbeam-utils",
]
[[package]]
name = "crossbeam-epoch"
version = "0.9.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "crunchy"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "crypto-common"
version = "0.1.7"
@@ -46,6 +204,36 @@ dependencies = [
"crypto-common",
]
[[package]]
name = "either"
version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
[[package]]
name = "futures-core"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
[[package]]
name = "futures-task"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
[[package]]
name = "futures-util"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
dependencies = [
"futures-core",
"futures-task",
"pin-project-lite",
"slab",
]
[[package]]
name = "generic-array"
version = "0.14.7"
@@ -56,6 +244,60 @@ dependencies = [
"version_check",
]
[[package]]
name = "half"
version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
dependencies = [
"cfg-if",
"crunchy",
"zerocopy",
]
[[package]]
name = "hermit-abi"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
[[package]]
name = "is-terminal"
version = "0.4.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
dependencies = [
"hermit-abi",
"libc",
"windows-sys",
]
[[package]]
name = "itertools"
version = "0.10.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473"
dependencies = [
"either",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "js-sys"
version = "0.3.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2025f20d7a4fa7785846e7b63d10a76d3f1cee98ee5cb79ea59703f95e42162"
dependencies = [
"cfg-if",
"futures-util",
"wasm-bindgen",
]
[[package]]
name = "libc"
version = "0.2.182"
@@ -68,6 +310,192 @@ version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]]
name = "memchr"
version = "2.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "oorandom"
version = "11.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "plotters"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747"
dependencies = [
"num-traits",
"plotters-backend",
"plotters-svg",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "plotters-backend"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a"
[[package]]
name = "plotters-svg"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670"
dependencies = [
"plotters-backend",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
[[package]]
name = "rayon"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
dependencies = [
"either",
"rayon-core",
]
[[package]]
name = "rayon-core"
version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
dependencies = [
"crossbeam-deque",
"crossbeam-utils",
]
[[package]]
name = "regex"
version = "1.12.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "rustversion"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "same-file"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
dependencies = [
"winapi-util",
]
[[package]]
name = "serde"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.150"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "sha2"
version = "0.10.9"
@@ -79,22 +507,171 @@ dependencies = [
"digest",
]
[[package]]
name = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "syn"
version = "2.0.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "tinytemplate"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc"
dependencies = [
"serde",
"serde_json",
]
[[package]]
name = "typenum"
version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "walkdir"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
dependencies = [
"same-file",
"winapi-util",
]
[[package]]
name = "wasm-bindgen"
version = "0.2.123"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a254a4b10c19a76f09a27640e7ffbf9bc30bf67e16a3bf28aaefa4920fe81563"
dependencies = [
"cfg-if",
"once_cell",
"rustversion",
"wasm-bindgen-macro",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.123"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24a40fc75b0ec6f3746ceb10d36f53a93dcd68a93b11b6445983945d79eba0dc"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.123"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "908f34bd9b9ce3d4caf07b72dfab63d61504d156856c6bd3cd87fa350cf3985b"
dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.123"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7acbf7616c27b194bbb550bf77ed0c2c3e5b7fd1260a93082b95fb7f47959b92"
dependencies = [
"unicode-ident",
]
[[package]]
name = "web-sys"
version = "0.3.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e0871acf327f283dc6da28a1696cdc64fb355ba9f935d052021fa77f35cce69"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "wifi-densepose-wasm-edge"
version = "0.3.0"
dependencies = [
"criterion",
"libm",
"sha2",
]
[[package]]
name = "winapi-util"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "zerocopy"
version = "0.8.52"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.52"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
@@ -11,6 +11,20 @@ categories = ["embedded", "wasm", "science"]
[lib]
crate-type = ["cdylib", "rlib"]
# The lib's libtest harness does not understand criterion CLI flags
# (`--warm-up-time` etc.), so exclude it from `cargo bench` — only the criterion
# bench target below should receive bench args (ADR-163).
bench = false
# ADR-163: host-measured process_frame latency benches (closes the ADR-160
# "criterion benches for process_frame budget claims" deferred item — HOST only;
# the ESP32-S3 WASM3 budget remains unmeasured, see the bench header).
# `std` is required (criterion is a host crate); the crate is workspace-EXCLUDED
# so run from the crate dir: `cargo bench --features std`.
[[bench]]
name = "process_frame_bench"
harness = false
required-features = ["std"]
[dependencies]
# no_std math
@@ -18,6 +32,11 @@ libm = "0.2"
# SHA-256 for RVF build hash (optional, used by builder)
sha2 = { version = "0.10", optional = true, default-features = false }
[dev-dependencies]
# Host-only latency regression benches (ADR-163). Pinned to match the rest of
# the workspace's bench crates.
criterion = { version = "0.5", features = ["html_reports"] }
[features]
default = ["default-pipeline"]
# Enable std for testing on host + RVF builder
@@ -0,0 +1,259 @@
//! Criterion benches for the heaviest `process_frame` hot paths in the edge
//! skill library (ADR-163, closing the ADR-160 §"Deferred Backlog" item
//! "Criterion benches for process_frame budget claims").
//!
//! ## HONEST SCOPE — read this before citing any number here
//!
//! These benches measure **HOST** wall-clock latency on a development laptop.
//! The per-module doc budgets (e.g. `exo_time_crystal` "H (heavy, <10ms) on
//! ESP32-S3 WASM3") are **for a different target**: an Xtensa ESP32-S3 running
//! the WASM3 interpreter. A native x86_64 host with `-O` is an **upper-bound
//! proxy for the ALGORITHM cost only**; it is NOT the ESP32 number and does NOT
//! reproduce the ESP32 budget. WASM3 interpretation on a ~240 MHz Xtensa core is
//! typically 1-2 orders of magnitude slower than native host code, so a host
//! median well under the budget does NOT prove the ESP32 meets it — it only
//! bounds the work. The ESP32 figure remains UNMEASURED (needs hardware).
//!
//! What these benches DO prove (MEASURED-on-host):
//! * the hot paths run, on a fixed synthetic CSI frame, with a real median;
//! * a regression guard exists so a future change that 10×'s the host cost
//! is caught in CI/dev even before anyone reflashes an ESP32.
//!
//! Run (the crate is EXCLUDED from the v2 workspace — bench from the crate dir):
//! cd v2/crates/wifi-densepose-wasm-edge
//! cargo bench --features std
//! # quick smoke:
//! cargo bench --features std -- --warm-up-time 1 --measurement-time 2
//!
//! `med_seizure_detect` is gated behind `medical-experimental`; its bench is
//! `#[cfg(feature = "medical-experimental")]` and only runs when that feature is
//! also enabled:
//! cargo bench --features std,medical-experimental
use criterion::{criterion_group, criterion_main, BatchSize, Criterion};
use std::hint::black_box;
use wifi_densepose_wasm_edge::exo_ghost_hunter::GhostHunterDetector;
use wifi_densepose_wasm_edge::exo_time_crystal::TimeCrystalDetector;
use wifi_densepose_wasm_edge::sec_weapon_detect::WeaponDetector;
// ── Fixed synthetic CSI fixtures (deterministic LCG, seed-stable) ────────────
/// Deterministic pseudo-random in [lo, hi) from a 32-bit LCG, matching the
/// generator style used by `tests/budget_compliance.rs`.
fn lcg(seed: &mut u32) -> f32 {
*seed = seed.wrapping_mul(1103515245).wrapping_add(12345);
(*seed >> 16) as f32 / 32768.0
}
fn synthetic_phases(n: usize, seed: u32) -> Vec<f32> {
let mut s = seed;
(0..n).map(|_| lcg(&mut s) * 6.2832 - 3.1416).collect()
}
fn synthetic_amplitudes(n: usize, seed: u32) -> Vec<f32> {
let mut s = seed;
(0..n).map(|_| lcg(&mut s) * 10.0 + 0.1).collect()
}
fn synthetic_variance(n: usize, seed: u32) -> Vec<f32> {
let mut s = seed;
(0..n).map(|_| lcg(&mut s) * 2.0 + 0.05).collect()
}
const N_SC: usize = 32; // per-subcarrier width (matches both modules' MAX_SC)
// ── exo_time_crystal: compute_autocorrelation 256×128 hot path ───────────────
//
// `compute_autocorrelation` is private, so we drive it through the public
// `process_frame`. To hit the full 256-point × 128-lag autocorrelation the
// circular buffer must be FULL (≥256 samples) and the signal must be
// non-constant (the module early-outs on `buf_var < 1e-8`). We pre-fill once
// with a periodic-plus-noise motion-energy stream, then bench a single
// `process_frame` (each call recomputes the full 256×128 autocorrelation =
// ~32K multiply-accumulates, the M6-audit-named hot path).
fn prefilled_time_crystal() -> TimeCrystalDetector {
let mut d = TimeCrystalDetector::new();
let mut s = 0xC0FFEEu32;
// 300 frames (> BUF_LEN=256) so the buffer is full and statistics are warm.
for i in 0..300 {
// period-10 square wave + small noise → guarantees buf_var > 0 and a
// genuine autocorrelation structure (the expensive path runs).
let base = if (i % 10) < 5 { 1.0 } else { 0.0 };
let me = base + lcg(&mut s) * 0.05;
black_box(d.process_frame(black_box(me)));
}
d
}
fn bench_exo_time_crystal(c: &mut Criterion) {
c.bench_function("exo_time_crystal::process_frame[autocorr_256x128]", |b| {
let mut s = 0x1357_9BDFu32;
b.iter_batched(
prefilled_time_crystal,
|mut d| {
// One frame = one full 256×128 autocorrelation pass.
let me = if (d.frame_count() % 10) < 5 { 1.0 } else { 0.0 } + lcg(&mut s) * 0.05;
black_box(d.process_frame(black_box(me)));
},
BatchSize::SmallInput,
);
});
}
// ── exo_ghost_hunter: periodicity + hidden-breathing hot path ────────────────
//
// Heaviest path runs only when the room is reported EMPTY (presence == 0):
// per-group anomaly accumulation + aggregate-phase autocorrelation for hidden
// periodic (breathing) signatures. We warm the noise floor + phase buffer first,
// then bench one empty-room frame.
fn prefilled_ghost_hunter() -> GhostHunterDetector {
let mut d = GhostHunterDetector::new();
let mut s = 0xBADC0DEu32;
// Warm the per-group EWMA noise floors + fill the phase buffer (PHASE_BUF_LEN=64)
// with a periodic phase signal so the periodicity autocorrelation has structure.
for i in 0..120u32 {
let phases: Vec<f32> = (0..N_SC)
.map(|k| libm::sinf(i as f32 * 0.4 + k as f32 * 0.1) * 0.3 + lcg(&mut s) * 0.02)
.collect();
let amps = synthetic_amplitudes(N_SC, 4000 + i);
let var = synthetic_variance(N_SC, 4500 + i);
black_box(d.process_frame(&phases, &amps, &var, 0, 0.05));
}
d
}
fn bench_exo_ghost_hunter(c: &mut Criterion) {
let amps = synthetic_amplitudes(N_SC, 9000);
let var = synthetic_variance(N_SC, 9500);
c.bench_function("exo_ghost_hunter::process_frame[empty_room_periodicity]", |b| {
let mut s = 0x2468_ACE0u32;
b.iter_batched(
prefilled_ghost_hunter,
|mut d| {
let i = d.frame_count();
let phases: Vec<f32> = (0..N_SC)
.map(|k| libm::sinf(i as f32 * 0.4 + k as f32 * 0.1) * 0.3 + lcg(&mut s) * 0.02)
.collect();
black_box(d.process_frame(
black_box(&phases),
black_box(&amps),
black_box(&var),
black_box(0),
black_box(0.05),
));
},
BatchSize::SmallInput,
);
});
}
// ── sec_weapon_detect: per-subcarrier Welford hot path ───────────────────────
//
// After calibration the detector runs a per-subcarrier online Welford update
// over MAX_SC=32 subcarriers each frame (the M6-audit-named hot path). We
// calibrate first (the early frames just accumulate baseline stats), then bench
// one steady-state frame.
fn calibrated_weapon_detector() -> WeaponDetector {
let mut d = WeaponDetector::new();
// Drive enough empty-room frames to complete calibration + warm the running
// Welford state. Calibration window is internal; 200 frames is comfortably
// past it for MAX_SC=32.
for i in 0..200u32 {
let phases = synthetic_phases(N_SC, 6000 + i);
let amps = synthetic_amplitudes(N_SC, 6500 + i);
let var = synthetic_variance(N_SC, 7000 + i);
black_box(d.process_frame(&phases, &amps, &var, 0.05, 0));
}
d
}
fn bench_sec_weapon_detect(c: &mut Criterion) {
c.bench_function("sec_weapon_detect::process_frame[per_sc_welford]", |b| {
let mut seed = 8000u32;
b.iter_batched(
calibrated_weapon_detector,
|mut d| {
seed = seed.wrapping_add(1);
let phases = synthetic_phases(N_SC, seed);
let amps = synthetic_amplitudes(N_SC, seed.wrapping_add(500));
let var = synthetic_variance(N_SC, seed.wrapping_add(1000));
black_box(d.process_frame(
black_box(&phases),
black_box(&amps),
black_box(&var),
black_box(0.3),
black_box(1),
));
},
BatchSize::SmallInput,
);
});
}
// ── med_seizure_detect: detect_rhythm / clonic autocorrelation hot path ──────
//
// Gated behind `medical-experimental` (ADR-160 §A1). The clonic-phase rhythm
// detection autocorrelates the amplitude ring buffer (PHASE_WINDOW=100); we warm
// the buffers with a high-energy rhythmic signal, then bench one frame.
#[cfg(feature = "medical-experimental")]
mod med {
use super::*;
use wifi_densepose_wasm_edge::med_seizure_detect::SeizureDetector;
fn warmed_seizure_detector() -> SeizureDetector {
let mut d = SeizureDetector::new();
let mut s = 0x5EE_D00Du32;
// High-energy ~4 Hz rhythmic (period ~5 frames at 20 Hz) → exercises the
// clonic-phase rhythm/autocorrelation path, with presence asserted.
for i in 0..150u32 {
let me = 2.5 + libm::sinf(i as f32 * 1.25) * 1.5;
let amp = 1.0 + lcg(&mut s) * 0.2;
black_box(d.process_frame(0.0, amp, me, 1));
}
d
}
pub fn bench_med_seizure_detect(c: &mut Criterion) {
c.bench_function("med_seizure_detect::process_frame[clonic_rhythm]", |b| {
let mut s = 0x9A_BCDE_F0u32;
b.iter_batched(
warmed_seizure_detector,
|mut d| {
let i = d.frame_count();
let me = 2.5 + libm::sinf(i as f32 * 1.25) * 1.5;
let amp = 1.0 + lcg(&mut s) * 0.2;
black_box(d.process_frame(
black_box(0.0),
black_box(amp),
black_box(me),
black_box(1),
));
},
BatchSize::SmallInput,
);
});
}
}
#[cfg(feature = "medical-experimental")]
criterion_group!(
benches,
bench_exo_time_crystal,
bench_exo_ghost_hunter,
bench_sec_weapon_detect,
med::bench_med_seizure_detect,
);
#[cfg(not(feature = "medical-experimental"))]
criterion_group!(
benches,
bench_exo_time_crystal,
bench_exo_ghost_hunter,
bench_sec_weapon_detect,
);
criterion_main!(benches);
@@ -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);
}