mirror of
https://github.com/ruvnet/RuView
synced 2026-06-13 10:53:20 +00:00
Compare commits
43 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 29e937ef52 | |||
| 41665d3de9 | |||
| c6eacb7ff8 | |||
| 153bc0595b | |||
| 8fd4ee917d | |||
| 5c5112db0e | |||
| e3696da8d8 | |||
| 9457d441b2 | |||
| 626b4b2e97 | |||
| 260fceefe9 | |||
| e063de5970 | |||
| 53b327e649 | |||
| ad3908bd9e | |||
| a27ee6f6cd | |||
| 3d7530f08d | |||
| d4170ad159 | |||
| 0d6c20c278 | |||
| 3fb40a9deb | |||
| 1a17cc5b06 | |||
| 7c13ec6a00 | |||
| d3606d51a7 | |||
| 48db9d37a6 | |||
| e7b1b66f74 | |||
| 3292bd2c5d | |||
| 0ca903b497 | |||
| b8e870b314 | |||
| d1328b0299 | |||
| d0da5888e3 | |||
| e51704cd25 | |||
| dff75a479e | |||
| 9d52d49c0b | |||
| d0a7690f8f | |||
| 8487192d0f | |||
| d120cc2278 | |||
| 8ad0d0f91c | |||
| 36af09a4a8 | |||
| 772ece4568 | |||
| 48b002fa7e | |||
| 8d9c5994db | |||
| 6b5fd3cf25 | |||
| 2400216920 | |||
| 98bf8c4726 | |||
| 2e4461d64d |
@@ -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 6–7. 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 ~13–19 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 40–49 BPM; fixed reaches the true 88–91 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).
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
# PROOF — reproduce every claim, or find the one we can't yet
|
||||
|
||||
This project (RuView / wifi-densepose) has been publicly called "AI slop" and
|
||||
"fake." This document is the answer: **a skeptic can clone the repo, run one
|
||||
script, and have every headline claim either verified on their own machine or
|
||||
shown — explicitly — as "CLAIMED, not yet reproduced (here's exactly what it
|
||||
needs)."** Nothing below is asserted without a command you can run.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/ruvnet/RuView && cd RuView
|
||||
bash scripts/prove.sh # core gate + the anti-slop assertion tests
|
||||
bash scripts/prove.sh --full # also attempt the feature-gated subset
|
||||
```
|
||||
|
||||
`prove.sh` exits 0 only if every **non-gated** claim passes. Gated claims never
|
||||
fail the run; they print the prerequisite (a GPU, a dataset, real hardware, a
|
||||
trained checkpoint) so you can reproduce them yourself.
|
||||
|
||||
## Grading
|
||||
|
||||
- **MEASURED** — reproduced on our hardware, with the exact command recorded, and
|
||||
pinned by a test that *fails on the pre-fix code*. `prove.sh` re-runs these.
|
||||
- **CLAIMED** — cited from a source, or measured by the source, but not
|
||||
reproduced in this repo's automated harness.
|
||||
- **DATA-GATED / HARDWARE-GATED** — the *code path* is real and tested, but the
|
||||
*accuracy/throughput claim* needs data or hardware we don't ship. We never
|
||||
fabricate the number; the code carries a typed error or a `weights_trained`/
|
||||
provenance flag instead.
|
||||
|
||||
## The hard gate (run on any machine with Rust + Python)
|
||||
|
||||
| Claim | Grade | Reproduce |
|
||||
|---|---|---|
|
||||
| Rust workspace: 3,128 tests, 0 failed | **MEASURED** | `cd v2 && cargo test --workspace --no-default-features` |
|
||||
| Deterministic CSI pipeline proof (bit-exact SHA-256) | **MEASURED** | `python archive/v1/data/proof/verify.py` → `VERDICT: PASS` |
|
||||
|
||||
## Anti-slop assertion tests (each fails on the pre-fix code)
|
||||
|
||||
| Claim | Grade | Test (run via `cargo test -p <crate> <name>`) |
|
||||
|---|---|---|
|
||||
| Fusion crafted-input DoS panics are closed (ADR-156 §2.2) | **MEASURED** | `wifi-densepose-ruvector :: triangulation_out_of_range_index_returns_none_no_panic` |
|
||||
| **The "Soul Signature" identity claim, honestly bounded:** on WiFi-only cardiac+respiratory channels two people are **not separable** (gap ≈ 0.0005) | **MEASURED** | `wifi-densepose-bfld :: cardiac_alone_cannot_separate_identity_matches_audit` |
|
||||
| OccWorld `predict()` is real (input-dependent), not random noise | **MEASURED** | `wifi-densepose-occworld-candle :: predict_is_deterministic_for_same_input` |
|
||||
| Pose runtime emits frames under its own default config (ADR-159 A1) | **MEASURED** | `cog-pose-estimation :: default_config_emits_frames_with_real_model` |
|
||||
| Person-count flags untrained classes — no count inflation (ADR-159 A2) | **MEASURED** | `cog-person-count :: untrained_class_argmax_is_flagged_low_confidence` |
|
||||
| Medical edge skills carry a "not a medical device" disclaimer (ADR-160 A1) | **MEASURED** | `wifi-densepose-wasm-edge :: a1_med_modules_have_clinical_disclaimer` (`--features std`) |
|
||||
| Survivor dedup 3→1, count-inflation killed (ADR-158 §2) | **MEASURED** | `wifi-densepose-mat :: test_identical_vitals_no_location_dedup_to_one` (`--features mat`) |
|
||||
|
||||
## Measured performance (criterion; reproduce on your machine)
|
||||
|
||||
| Claim | Grade | Reproduce |
|
||||
|---|---|---|
|
||||
| PSD FFT-planner cache 2.0–3.1×, DTW band 2.4–4.1× (ADR-154) | **MEASURED** | `cd v2 && cargo bench -p wifi-densepose-signal` |
|
||||
| fuse() double-clone removed ~2.17× marshalling (ADR-156) | **MEASURED** | `cd v2 && cargo bench -p wifi-densepose-ruvector --bench fusion_bench` |
|
||||
| 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)
|
||||
|
||||
| Capability | Status |
|
||||
|---|---|
|
||||
| **Named person-identity from WiFi** | **NOT achieved, and measured why.** The §3.6 matcher is real, but identity does not lock on WiFi-only channels (gap 0.0005). DATA-GATED on a real enrollment feeding the AETHER/body-resonance channel — never done. No named-identity claim is made. |
|
||||
| WiFlow-STD ~96% PCK@20 | **CLAIMED-reproduced** on our RTX 5080 (`benchmarks/wiflow-std/RESULTS.md`); HARDWARE-GATED for you (needs an NVIDIA GPU + the MM-Fi dataset). The upstream *shipped checkpoint* was **REFUTED** (0.08% PCK) — we publish that. |
|
||||
| OccWorld trajectory accuracy | DATA-GATED on a trained checkpoint; `predict()` carries `weights_trained=false` until one is loaded — never silently faked. |
|
||||
| Edge-skill detection accuracy (seizure, weapon, affect, …) | UNVALIDATED — every such module is now disclaimer-gated as experimental/research; the DSP is real, the accuracy is not claimed. |
|
||||
| 802.11bf-2025 OTA conformance | No commodity silicon ships a conformant interface as of 2026; ours is a simulation-tested forward-compat protocol model, not a certified implementation. |
|
||||
|
||||
## Provenance
|
||||
|
||||
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.
|
||||
@@ -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 1–2 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.34–1.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.
|
||||
@@ -0,0 +1,132 @@
|
||||
# Edge-Skill Synthetic-Ground-Truth Validation — RESULTS
|
||||
|
||||
**Crate:** `v2/crates/wifi-densepose-wasm-edge` (workspace-EXCLUDED — build from its own dir)
|
||||
**Branch:** `feat/edge-skills-synthetic-validation`
|
||||
**ADR:** [ADR-160](../../docs/adr/ADR-160-edge-skill-library-honest-labeling.md)
|
||||
**Date:** 2026-06-13
|
||||
**Harness:** `tests/synthetic_validation.rs`
|
||||
|
||||
> **HONESTY BOUNDARY — read first.** Everything below is **synthetic-ground-truth
|
||||
> validation**: a signal is *planted* with a known answer, the **real** detector
|
||||
> is run, and detection accuracy / precision / recall / rate-error is **measured**.
|
||||
> This is **NOT field accuracy.** A skill that recovers a planted sinusoid here is
|
||||
> proven to do the math it claims on a *constructed* signal; it is **NOT** proven
|
||||
> to work on real CSI in a real room. Skills whose detection target cannot be
|
||||
> honestly planted (clinical, weapon, affect, sleep-stage, sign-language) are
|
||||
> **NOT** given a number — they are listed under **DATA-GATED** with the real
|
||||
> data each would require.
|
||||
|
||||
## Reproduce
|
||||
|
||||
```bash
|
||||
cd v2/crates/wifi-densepose-wasm-edge # workspace-excluded; build here
|
||||
cargo test --features std --test synthetic_validation -- --nocapture
|
||||
# also runs under the medical tier (med_* skills stay DATA-GATED, not validated):
|
||||
cargo test --features std,medical-experimental --test synthetic_validation -- --nocapture
|
||||
```
|
||||
|
||||
Each `MEASURED-on-synthetic | …` line printed by the harness is the source of the
|
||||
table below. Numbers are deterministic (no RNG; pseudo-noise uses a fixed LCG seed).
|
||||
|
||||
---
|
||||
|
||||
## MEASURED-on-synthetic (constructible skills)
|
||||
|
||||
| Skill | What was planted (ground truth) | Result | Grade |
|
||||
|-------|----------------------------------|--------|-------|
|
||||
| **vital_trend** | BPM held N≥6 calls at each threshold band (brady/tachy-pnea <12 / >25, brady/tachy-cardia <50 / >120, apnea breathing<1.0 for ≥20) vs normal | **acc 1.000, prec 1.000, recall 1.000** (TP5 FP0 TN5 FN0) | MEASURED |
|
||||
| **exo_time_crystal** | period-2 coordinated motion vs pseudo-noise + flat | **acc 1.000** (TP1 FP0 TN2 FN0) | MEASURED † |
|
||||
| **exo_ghost_hunter** (hidden breathing) | phase sinusoid at lag-8 (breathing band 5–15) in an empty room vs flat phase | **acc 1.000**; planted score **1.000**, flat **0.000** | MEASURED |
|
||||
| **occupancy** | 220-frame flat-amplitude calibration, then strong per-zone amplitude variance vs flat | **acc 1.000** (TP1 FP0 TN1 FN0) | MEASURED |
|
||||
| **intrusion** | calibrate→arm (330 quiet frames), then per-subcarrier Δphase>1.5 + Δamp≫3σ vs quiet | **acc 1.000** (TP1 FP0 TN1 FN0) | MEASURED |
|
||||
| **exo_rain_detect** | empty room, 60-frame baseline, then broadband variance (8/8 groups, ratio≫2.5) for ≥10 frames vs stable-low | **acc 1.000** (TP1 FP0 TN1 FN0) | MEASURED |
|
||||
| **sig_flash_attention** | sustained high phase+amplitude in each of the 8 subcarrier groups; assert reported attention peak == planted group | **peak-localization 8/8 = 1.000** | MEASURED |
|
||||
| **spt_spiking_tracker** | sparse (2-subcarrier) large phase-delta in each of the 4 zones; assert tracked zone == planted zone | **zone-localization 4/4 = 1.000** | MEASURED ‡ |
|
||||
| **sig_optimal_transport** | sustained large frame-to-frame amplitude-distribution change vs stationary | **acc 1.000** (TP1 FP0 TN1 FN0) | MEASURED |
|
||||
| **sig_mincut_person_match** | 2 persons with distinct stable per-region variance signatures over 40 frames | **person ids assigned, 0 id-swaps / 40 frames** | MEASURED |
|
||||
| **lrn_dtw_gesture_learn** | stillness → 3 identical gesture rehearsals → enrollment | **template enrolled (templates=1)** | MEASURED (enroll) §|
|
||||
| **sig_sparse_recovery** | 30 clean frames to init, then 8/32 (25%) nulled subcarriers | **dropout-detect + recovery-trigger = PASS** | MEASURED (trigger) ¶|
|
||||
|
||||
### Caveats on individual results
|
||||
|
||||
† **exo_time_crystal — honest discriminative limit.** A *pure* periodic signal
|
||||
already has autocorrelation peaks at lag L **and** 2L (natural harmonics), so this
|
||||
"period-doubling" detector cannot separate a true period-2 sub-harmonic from a
|
||||
plain periodic signal — an earlier plant using a clean sine produced a *false
|
||||
positive* (recorded during development). The construct it **can** discriminate
|
||||
with known ground truth is **periodic-coordination vs aperiodic** (noise/flat),
|
||||
which is what is measured (1.000). The original "sub-harmonic vs clean period"
|
||||
claim is **NOT** validatable with this algorithm.
|
||||
|
||||
‡ **spt_spiking_tracker — plant must be sparse.** With weights init'd home=1.0 /
|
||||
cross=0.25, firing all 8 inputs in a zone (8×0.25=2.0 > threshold 1.0) overdrives
|
||||
*every* output neuron and the tracker collapses to zone 0 (measured 1/4 during
|
||||
development). Firing only 2 inputs (home 2.0 fires, cross 0.5 silent) yields clean
|
||||
4/4 zone localization. The validatable claim is *single-zone* localization.
|
||||
|
||||
§ **lrn_dtw_gesture_learn — enrollment validated; replay-match NOT.** The
|
||||
deterministic, constructible part (stillness → 3 identical rehearsals → a template
|
||||
is enrolled) is MEASURED. The DTW *replay match* (731) did **not** fire on the
|
||||
identical replay in this run (`match_same=false`) — replay-recognition accuracy is
|
||||
**reported, not asserted**, and is not claimed as validated.
|
||||
|
||||
¶ **sig_sparse_recovery — trigger validated; recovery accuracy is NEGATIVE.**
|
||||
The dropout-detection + ISTA-recovery *trigger* pipeline fires correctly on >10%
|
||||
planted nulls (asserted). But the **measured recovery accuracy is NOT a win**:
|
||||
recovered RMSE **1.0045** vs unrecovered-null RMSE **0.9830** (**−2.2%**, i.e.
|
||||
slightly *worse* than leaving the nulls at zero) on a neighbor-correlated signal.
|
||||
The tridiagonal correlation model's fixed point does not equal the planted truth.
|
||||
**The recovery's reconstruction quality is therefore NOT validated as effective on
|
||||
synthetic data** — only its detection/trigger path is. Reported honestly; no
|
||||
positive number claimed.
|
||||
|
||||
---
|
||||
|
||||
## DATA-GATED — NOT validatable on synthetic data
|
||||
|
||||
Planting a "seizure-like" / "weapon-like" / "happy-like" synthetic signal and
|
||||
claiming the detector "works" validates **nothing real** and is exactly the
|
||||
AI-slop this project fights. These skills run real DSP (per ADR-160, 0 stubs) and
|
||||
keep their ADR-160 disclaimers, but get **no accuracy number** here. Each needs
|
||||
the specific real, labelled data listed:
|
||||
|
||||
| Skill | Why not constructible on synthetic | Real data required |
|
||||
|-------|------------------------------------|--------------------|
|
||||
| `med_seizure_detect` | "seizure-like" motion is not a seizure; no ground-truth signature exists synthetically | Clinical EEG-/video-labelled tonic-clonic seizure CSI from instrumented patients |
|
||||
| `med_sleep_apnea` | a planted breathing-pause is not clinical apnea (AHI scoring, hypopnea, desaturation) | Polysomnography-labelled (PSG) overnight CSI with scored apnea/hypopnea events |
|
||||
| `med_cardiac_arrhythmia` | a synthetic HR sequence cannot encode true arrhythmia morphology | ECG-labelled CSI (AFib/PVC/etc.) from clinical monitoring |
|
||||
| `med_respiratory_distress` | distress is a clinical gestalt, not a plantable rate | Clinician-labelled respiratory-distress CSI episodes |
|
||||
| `med_gait_analysis` | clinical gait metrics need a reference motion-capture standard | Mocap-/force-plate-labelled gait CSI |
|
||||
| `sec_weapon_detect` | a high variance ratio is RF reflectivity, **not** weapon discrimination (ADR-160 §A3 already renamed the event to `HIGH_METAL_REFLECTIVITY`) | Labelled metal-object-vs-no-object CSI with controlled object classes |
|
||||
| `exo_emotion_detect` | affect is not recoverable from a planted heuristic; outputs are proxies (ADR-160 §A2) | Validated affect-labelled CSI (self-report / physiological ground truth) |
|
||||
| `exo_happiness_score` | "happiness" is a gait-energy proxy, not a measured affect (ADR-160 §A2) | Validated affect/valence-labelled CSI |
|
||||
| `exo_dream_stage` | sleep staging needs PSG reference (EEG/EOG/EMG) | PSG-staged overnight CSI |
|
||||
| `exo_gesture_language` | coarse gesture clusters ≠ true sign language (ADR-160 §A4) | Labelled ASL letter/word CSI dataset |
|
||||
|
||||
> The above are **not failures** — they are the honest boundary. A smaller set of
|
||||
> genuinely-measured skills plus this explicit gated list is the deliverable, per
|
||||
> the prove-everything directive.
|
||||
|
||||
---
|
||||
|
||||
## Skills not in either list
|
||||
|
||||
The remaining edge skills (smart-building / retail / industrial occupancy-style,
|
||||
the other `sig_*`/`lrn_*`/`spt_*`/`tmp_*`/`qnt_*`/`aut_*`/`ais_*` algorithm-named
|
||||
modules) are **wired and exercised live** in the unified pipeline integration test
|
||||
(`tests/pipeline_all.rs`, all 59 default / 64 medical skills run without panic over
|
||||
300 synthetic frames) but were **not** given an individual planted-ground-truth
|
||||
accuracy number here. They are honest REAL-DSP modules (ADR-160) whose physical
|
||||
observable could be planted with more harness work; that is deferred, not claimed.
|
||||
|
||||
## Test counts (full crate suite)
|
||||
|
||||
```
|
||||
DEFAULT (--features std): 631 passed, 0 failed
|
||||
(lib 504; budget 25; honest_labeling 10; pipeline_all 4; synthetic_validation 12; bench 1; vendor 75)
|
||||
MEDICAL (--features std,medical-experimental): 669 passed, 0 failed
|
||||
(lib 542; +16 same new tests; med_* stay DATA-GATED, not validated)
|
||||
```
|
||||
|
||||
(M6 baseline was 615 / 653; the new pipeline_all (4) + synthetic_validation (12)
|
||||
tests add 16 to each tier.)
|
||||
@@ -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` |
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
# ADR-159: Cognitum Appliance Cluster — Beyond-SOTA Sweep, Anti-"AI-Slop" Hardening
|
||||
|
||||
- **Status**: accepted
|
||||
- **Date**: 2026-06-11
|
||||
- **Deciders**: ruv
|
||||
- **Tags**: cognitum, cogs, person-count, pose-estimation, ha-matter, drone-swarm, remote-id, manifest, prove-everything
|
||||
|
||||
## Context
|
||||
|
||||
This ADR records the beyond-SOTA sweep over the Cognitum appliance cluster
|
||||
(`cog-person-count`, `cog-pose-estimation`, `cog-ha-matter`, `ruview-swarm`),
|
||||
executed under the project's **prove-everything / anti-"AI-slop"** directive: the
|
||||
claim surface every cog presents (manifests, descriptions, runtime events,
|
||||
broadcast fields) must match what the code and the shipped weights actually do.
|
||||
|
||||
### Headline — the "never identified anyone" accusation is REFUTED
|
||||
|
||||
A read-only audit raised the worst-class accusation: that these cogs are slop that
|
||||
"never identified anyone." That accusation is **refuted by byte-level evidence**:
|
||||
|
||||
- `cog-pose-estimation` and `cog-person-count` ship **real, trained Candle models**
|
||||
(`pose_v1.safetensors`, `count_v1.safetensors`), not placeholders. The forward
|
||||
passes (`PoseNet`, `CountNet`) mirror the training scripts exactly and run on
|
||||
real CSI bytes.
|
||||
- The artifacts are **SHA-pinned and Ed25519-signed**: the on-disk
|
||||
`manifests/x86_64/manifest.json` carries a real `binary_sha256`
|
||||
(`051614ce…388b3` for person-count, `a434739a…71fa` for pose), a real
|
||||
`weights_sha256`, and a `binary_signature` over `sig_algo: Ed25519`.
|
||||
- The manifests are **brutally honest about accuracy**: person-count's
|
||||
`build_metadata` ships `training_class1_accuracy = 0.343` and a candid
|
||||
`training_caveat`; pose ships `training_pck20 = 3.0` / `training_pck50 = 18.5`.
|
||||
Nothing is inflated. That honesty *is* the anti-slop win — the models are weak
|
||||
in the field, and the manifests say so.
|
||||
|
||||
So the cogs **do** run real trained inference and **do** disclose how weak it is.
|
||||
What the audit correctly found were not fabrications but **claim-surface
|
||||
overclaims** — four places where the surface said more than the weights deliver.
|
||||
This ADR tightens those four (A1–A4) and cites the already-correct subsystems as
|
||||
NO-ACTION positives.
|
||||
|
||||
Grading vocabulary follows ADR-152 / ADR-158:
|
||||
- **MEASURED** — reproduced in this worktree, command + failing-on-old test recorded.
|
||||
- **DATA-GATED** — real code path present; honestly flagged where data/hardware is absent.
|
||||
- **NO-ACTION (already-SOTA)** — audited, found correct, cited as a positive.
|
||||
- **ACCEPTED-FUTURE** — deliberately deferred, nothing dropped.
|
||||
|
||||
## Graded SOTA Landscape
|
||||
|
||||
| Capability | Grade | Note |
|
||||
|------------|-------|------|
|
||||
| CSI person counting (`cog-person-count`) | **DATA-GATED** | Real Candle count head + Bayesian fusion; weights trained only on classes 0/1 (presence). Multi-occupant accuracy is genuinely unproven and is **not fabricated** — counts above the trained range are now flagged `low_confidence` and clamped. |
|
||||
| CSI pose estimation (`cog-pose-estimation`) | **DATA-GATED** | Real Candle encoder + 17-keypoint head; field accuracy honestly weak (PCK@50 = 18.5%, disclosed in the manifest). The default-install gate bug (A1) is fixed so it actually emits frames. |
|
||||
| Signed cog manifests (Ed25519 + SHA-256) | **NO-ACTION (already-SOTA)** | On-disk manifests are real, signed, SHA-pinned, and honest about accuracy. The CLI now emits them verbatim (A4). |
|
||||
| HA bridge (`cog-ha-matter`) MQTT + witness | **NO-ACTION (already-SOTA)** | Real Ed25519 hash-chain witness, mDNS, embedded broker. Matter commissioning is honestly deferred to v0.8 (TLS off, LAN-only) — description softened to stop claiming Matter (honest-absence). |
|
||||
| Drone-swarm MARL (`ruview-swarm`) | **DATA-GATED / honest** | `candle_ppo.rs` is real autodiff PPO; it is **untrained at runtime** (random init) by design — the swarm must be trained before deploy, which the code does not hide. |
|
||||
| ASTM F3411 Remote ID | **MEASURED (A3)** | Basic ID message is real; the Location/Vector message is honestly *not* implemented (NED metres are no longer mislabelled as WGS84 lat/lon). |
|
||||
|
||||
## Decision — Fixes Landed (MEASURED)
|
||||
|
||||
### §A1 Pose runtime emitted ZERO frames under default config (HIGH)
|
||||
|
||||
**Overclaim (silent correctness bug):** `inference.rs` hardcoded
|
||||
`confidence: 0.185` for every inference, `config.rs default_min_confidence()`
|
||||
returned `0.3`, and `runtime.rs` gated emission on `confidence >= min_confidence`.
|
||||
A default install therefore **never emitted a single `pose.frame`** while
|
||||
`health` reported healthy — the cog *claimed* to be a running pose estimator but
|
||||
silently produced nothing.
|
||||
|
||||
**Real fix:** `pose_v1` has **no confidence head** (the head emits 34 keypoint
|
||||
coordinates only), so a real per-frame confidence is genuinely unavailable. We
|
||||
took the disclosed "ok" path rather than silently lowering the threshold:
|
||||
- Introduced `inference::MODEL_TYPICAL_CONFIDENCE = 0.185` (the validation PCK@50)
|
||||
as the single published per-frame confidence, used by both `infer()` and the
|
||||
config default.
|
||||
- Pinned `default_min_confidence()` to `MODEL_TYPICAL_CONFIDENCE` so a default
|
||||
install clears its own gate and emits.
|
||||
- Documented the trade-off in the config field doc, the JSON schema
|
||||
(`default` 0.3 → 0.185, with a description), **and** added a `run.started`
|
||||
warning in `main.rs` that fires when an operator raises `min_confidence` above
|
||||
the model's typical confidence — so a deliberately-high threshold is loud, not
|
||||
silent.
|
||||
|
||||
**Failing-on-old test:** `cog_pose_estimation` smoke
|
||||
`default_config_emits_frames_with_real_model` — parses a default config and
|
||||
asserts `min_confidence <= MODEL_TYPICAL_CONFIDENCE` (and, with the real model
|
||||
loaded, that `infer().confidence >= min_confidence`). **Proven to fail** on the
|
||||
old `default_min_confidence()=0.3`:
|
||||
`default min_confidence 0.3 exceeds model typical confidence 0.185 — a default
|
||||
install would emit zero pose.frame events`.
|
||||
|
||||
**Grade: MEASURED.**
|
||||
|
||||
### §A2 8-class count head on a 2-class-trained model (MEDIUM)
|
||||
|
||||
**Overclaim:** `inference.rs COUNT_CLASSES = 8` with argmax over {0..7}, but
|
||||
`count_train_results.json` has support only for classes 0 and 1 (`per_class_accuracy`
|
||||
keys `"0"`/`"1"`). The model is a **presence detector**, not a calibrated
|
||||
multi-occupant counter; an argmax on classes 2..=7 is out-of-distribution, yet the
|
||||
cog would emit it as a confident headcount. The Cargo.toml billed it as a
|
||||
"learned multi-person counter."
|
||||
|
||||
**Real fix (no network change — DATA-GATED, accuracy not fabricated):**
|
||||
- Added `inference::MAX_TRAINED_CLASS = 1`, plus `CountPrediction::is_low_confidence()`
|
||||
(argmax beyond the trained ceiling) and `clamped_count()` (report clamped to the
|
||||
trained range, raw argmax kept for audit).
|
||||
- `person.count` events now carry `low_confidence` + `raw_count`, and downgrade to
|
||||
`level: "warn"` when out-of-distribution; the reported `count` is clamped so we
|
||||
never emit a fabricated headcount the weights can't back.
|
||||
- `run.started` discloses `count_max_trained_class` and `count_classes`.
|
||||
- Cargo.toml description changed from "learned multi-person counter" to
|
||||
"presence detector + (data-gated) person count".
|
||||
|
||||
**Failing-on-old test:** `cog_person_count` smoke
|
||||
`untrained_class_argmax_is_flagged_low_confidence` — a prediction whose argmax is
|
||||
class 5 is asserted `is_low_confidence() == true` and `clamped_count() ==
|
||||
MAX_TRAINED_CLASS`; a class-1 prediction is asserted *not* flagged. Fails on old
|
||||
code (no such methods/flag existed).
|
||||
|
||||
**Grade: MEASURED (mechanism); multi-occupant accuracy DATA-GATED.**
|
||||
|
||||
### §A3 Remote ID broadcast NED metres as WGS84 lat/lon (MEDIUM — safety/compliance)
|
||||
|
||||
**Overclaim (compliance hazard):** `security/remote_id.rs update()` stored
|
||||
`state.position.x/.y` (NED **metres**) into `drone_lat`/`drone_lon`, so the Remote
|
||||
ID broadcast would carry physically-impossible coordinates (e.g. "latitude =
|
||||
37.5 m"). The module doc claimed a "Basic ID + Location/Vector message," but only
|
||||
`encode_basic_id()` exists.
|
||||
|
||||
**Real fix (honest naming — never broadcast impossible coordinates):**
|
||||
- Renamed `drone_lat`/`drone_lon` → `drone_north_m`/`drone_east_m` (NED metres
|
||||
relative to the operator/takeoff datum), with field docs stating they are *not*
|
||||
geodetic. `operator_lat`/`operator_lon` remain true WGS84 (from the operator's
|
||||
GNSS).
|
||||
- Corrected the module doc to claim **Basic ID only**; the Location/Vector encoder
|
||||
is explicitly deferred until a datum-anchored NED→WGS84 transform lands
|
||||
(ACCEPTED-FUTURE), rather than removing a real feature.
|
||||
|
||||
**Failing-on-old test:** `security::remote_id::tests::test_ned_offset_stored_as_metres_not_latlon`
|
||||
— a 37.5 m north / −12.0 m east NED offset is asserted to land in
|
||||
`drone_north_m`/`drone_east_m`; the operator's real WGS84 fix stays in range. Fails
|
||||
on old code, where these values were stored into `drone_lat`/`drone_lon`.
|
||||
|
||||
**Grade: MEASURED.**
|
||||
|
||||
### §A4 Hollow CLI manifest (LOW)
|
||||
|
||||
**Overclaim:** `cog-person-count main.rs cmd_manifest` emitted a null skeleton
|
||||
(`binary_sha256: null`, no training metadata), making the CLI look unsigned even
|
||||
though the **real signed manifest** existed at
|
||||
`cog/artifacts/manifests/x86_64/manifest.json`.
|
||||
|
||||
**Real fix:** new `cog_person_count::manifest` module `include_str!`-embeds the
|
||||
real signed manifests (x86_64 + arm), selected by build target arch.
|
||||
`cmd_manifest` now parses-then-emits the embedded signed manifest — exactly the
|
||||
pattern `cog-pose-estimation`'s `manifest_roundtrips` test demonstrates. The CLI
|
||||
now reports the real `binary_sha256`, `weights_sha256`, Ed25519 signature, and
|
||||
honest `build_metadata` (`training_class1_accuracy = 0.343`).
|
||||
|
||||
**Failing-on-old test:** `manifest::tests::embedded_manifest_has_non_null_binary_sha256`
|
||||
asserts a 64-hex-char `binary_sha256`; companions assert the embedded manifest is
|
||||
signed (`sig_algo == Ed25519`) and `id == COG_ID`. End-to-end verified:
|
||||
`cog-person-count manifest` prints `binary_sha256:
|
||||
051614ce6ba63df704fae848a67ad095df4bb88862fdff05ef3c0419cc8388b3`.
|
||||
|
||||
**Grade: MEASURED.**
|
||||
|
||||
### §A5 cog-ha-matter description claimed Matter before it exists (LOW — honest-labeling)
|
||||
|
||||
**Overclaim:** the Cargo.toml description said "Home Assistant + Matter
|
||||
integration," but Matter commissioning is deferred to v0.8 (`TlsConfig::Off`,
|
||||
LAN-only, asserted by `runtime.rs tls_defaults_to_off_for_v1_lan_only`).
|
||||
|
||||
**Real fix (no code change):** softened the description to "Home Assistant (MQTT)
|
||||
integration … LAN-only (no TLS); Matter Bridge commissioning is deferred to v0.8
|
||||
and not yet implemented." Mirrors ADR-158 §6 honest-absence: state what isn't
|
||||
there rather than implying it is.
|
||||
|
||||
**Grade: MEASURED (label).**
|
||||
|
||||
## Negative Results (Confirmed — NO-ACTION positives)
|
||||
|
||||
Audited and found genuinely correct; cited as positives, not edited:
|
||||
|
||||
- **`cog-ha-matter` witness chain** (`witness.rs` / `witness_signing.rs`) — real
|
||||
Ed25519 hash-chained witness log. Already-SOTA.
|
||||
- **`cog-person-count` fusion** (`fusion.rs`) — real Bayesian product-of-experts
|
||||
multi-node fusion (Stoer-Wagner-bounded clip), not a heuristic. Already-SOTA.
|
||||
- **`ruview-swarm` PPO** (`marl/candle_ppo.rs`) — real Candle autodiff PPO with a
|
||||
genuine policy-gradient update; its `randn` uses (init, action sampling,
|
||||
exploration) are all legitimate, not fake-output substitutes. Untrained at
|
||||
runtime by design (the swarm must be trained before deploy), which the code
|
||||
does not hide. Already-SOTA / honest.
|
||||
|
||||
## Deferred Backlog (Nothing Dropped)
|
||||
|
||||
- **Multi-occupant count accuracy** — DATA-GATED on labelled multi-occupant CSI.
|
||||
The `low_confidence` flag + clamp (§A2) is the honest stand-in until then.
|
||||
- **Remote ID Location/Vector message** — ACCEPTED-FUTURE; requires a
|
||||
datum-anchored local-tangent-plane NED→WGS84 transform with an operator datum.
|
||||
Basic ID ships today.
|
||||
- **Matter Bridge commissioning** — ACCEPTED-FUTURE (v0.8); LAN-only MQTT ships today.
|
||||
- **Criterion benches** for cog inference latency and `mesh_guard` — ACCEPTED-FUTURE
|
||||
(cold-start timings are recorded in the manifests' `build_metadata`, not yet a
|
||||
regression bench).
|
||||
- **`wasm-edge` skill accuracy** — unvalidated; **now honestly labelled, not
|
||||
claimed** (done in ADR-160: medical/affect/security/exotic claim surfaces
|
||||
disclaimed, renamed, and feature-gated; per-skill accuracy remains DATA-GATED).
|
||||
|
||||
## Consequences
|
||||
|
||||
- A default pose-estimation install now actually emits `pose.frame` events;
|
||||
raising the threshold above the model's reach is a loud `run.started` warning,
|
||||
not a silent dropout.
|
||||
- A person-count reading on an untrained class is flagged `low_confidence`,
|
||||
clamped, and downgraded to `warn` — no fabricated headcounts.
|
||||
- The Remote ID broadcast can never carry physically-impossible coordinates; NED
|
||||
metres live in honestly-named metre fields.
|
||||
- `cog-person-count manifest` now reports the real signed manifest instead of a
|
||||
hollow null skeleton.
|
||||
- No cog Cargo.toml description claims a capability (multi-person counting, Matter)
|
||||
the code/weights don't yet deliver.
|
||||
|
||||
## Reproduction (MEASURED)
|
||||
|
||||
```bash
|
||||
cd v2
|
||||
cargo test -p cog-person-count -p cog-pose-estimation -p cog-ha-matter -p ruview-swarm \
|
||||
--no-default-features
|
||||
# ruview-swarm train path compiles (PPO autodiff)
|
||||
cargo check -p ruview-swarm --features train
|
||||
# A4 end-to-end — real signed manifest, non-null binary_sha256
|
||||
cargo run -q -p cog-person-count --no-default-features -- manifest
|
||||
```
|
||||
|
||||
Result at time of writing (all 0 failed):
|
||||
- `cog-person-count` — **19 passed** (lib 10 incl. 3 manifest; smoke 9)
|
||||
- `cog-pose-estimation` — **8 passed** (smoke)
|
||||
- `cog-ha-matter` — **64 passed** (unchanged; description-only edit)
|
||||
- `ruview-swarm` — **117 passed** (default features); `--features train` compiles clean.
|
||||
|
||||
Scope was limited to the four named crates. NO-ACTION positives (witness chain,
|
||||
fusion, PPO + randn audit) were verified by inspection and left untouched.
|
||||
@@ -0,0 +1,257 @@
|
||||
# ADR-160: Edge Skill Library (`wifi-densepose-wasm-edge`) — Honest Labeling & Soundness Cleanup
|
||||
|
||||
- **Status**: accepted
|
||||
- **Date**: 2026-06-11
|
||||
- **Deciders**: ruv
|
||||
- **Tags**: wasm-edge, esp32, edge-skills, claim-surface, medical-overclaim, affect, prove-everything, soundness, static-mut
|
||||
- **Amends**: ADR-159 (deferred-backlog line for wasm-edge now TRUE)
|
||||
|
||||
## Context
|
||||
|
||||
Beyond-SOTA sweep Milestone 6, over `v2/crates/wifi-densepose-wasm-edge` only,
|
||||
executed under the project's **prove-everything / anti-"AI-slop"** directive.
|
||||
|
||||
### Headline — 0 stubs, 0 theater, all real DSP (REFUTES the slop accusation)
|
||||
|
||||
A read-only audit found this crate has **zero stubs and zero fake-output theater:
|
||||
every one of the ~70 edge skills runs real DSP** (Welford statistics,
|
||||
autocorrelation, DTW, sliced-Wasserstein, ISTA-style recovery, Kalman/HNSW, etc.).
|
||||
The forward paths are genuine signal processing on real CSI-derived inputs. That
|
||||
is the anti-slop win and it is cited here as a positive, not a fabrication.
|
||||
|
||||
What the audit correctly found was **not fake code but an over-confident claim
|
||||
surface**: skill *names* and doc-comments asserting clinical/affective/security
|
||||
capabilities that the **unvalidated** code cannot back, concentrated in the
|
||||
medical (`med_*`) and affect (`exo_happiness`/`exo_emotion`) skills. The fix is
|
||||
**honest labeling — making the labels TRUE — NOT making the claimed capability
|
||||
real.** You cannot validate seizure detection, affect inference, or weapon
|
||||
discrimination without clinical/labelled data and reference standards; this ADR
|
||||
does not pretend to. It disclaims, renames, softens, and feature-gates so the
|
||||
surface matches what the DSP actually delivers.
|
||||
|
||||
Grading vocabulary follows ADR-152 / ADR-158 / ADR-159:
|
||||
- **MEASURED** — reproduced in this worktree, command + failing-on-old test recorded.
|
||||
- **DATA-GATED** — real code path present; honestly flagged where data is absent.
|
||||
- **NO-ACTION (already-honest)** — audited, found correct, cited as a positive.
|
||||
- **ACCEPTED-FUTURE** — deliberately deferred, nothing dropped.
|
||||
|
||||
## Per-prefix classification
|
||||
|
||||
| Prefix | Class | Note |
|
||||
|--------|-------|------|
|
||||
| `sig_*` (signal intelligence) | **REAL-DSP, honest** | Algorithm-named (flash-attention, sparse-recovery, optimal-transport, temporal-compress, mincut). Names describe the math, not an overclaimed outcome. NO-ACTION on labels; A5 soundness applied. |
|
||||
| `lrn_*` (adaptive learning) | **REAL-DSP, honest** | DTW/EWC/meta-adapt/attractor — algorithm-named. NO-ACTION on labels; A5 applied. |
|
||||
| `spt_*` / `tmp_*` | **REAL-DSP, honest** | PageRank/HNSW/spiking-tracker; LTL-guard/GOAP/pattern-sequence. Algorithm-named. NO-ACTION on labels; A5 applied. |
|
||||
| `qnt_*` | **REAL-DSP, honest (disclosed analogy)** | "quantum-**inspired**" / Grover-**inspired** are already disclosed analogies. NO-ACTION (DO-NOT-touch); A5 applied (mechanical, no label/behavior change). |
|
||||
| `bld_*` / `ret_*` / `ind_*` / `occupancy`/`intrusion` | **REAL-DSP, honest** | Occupancy/queue/forklift/clean-room etc. describe physical observables. NO-ACTION on labels; A5 applied. |
|
||||
| `sec_weapon_detect` | **REAL-DSP, overclaiming NAME** → fixed (A3) | Variance-ratio reflectivity renamed off "weapon". |
|
||||
| `med_*` (5) | **REAL-DSP, overclaiming NAME/DOC** → fixed (A1) | Clinical detection asserted as fact; now disclaimed + softened + feature-gated. |
|
||||
| `exo_happiness` / `exo_emotion` | **REAL-DSP, overclaiming NAME/DOC** → fixed (A2) | Affect outputs reframed as proxies; uncited stat removed. |
|
||||
| `exo_dream_stage` / `exo_gesture_language` | **REAL-DSP, quasi-medical/over-named** → fixed (A4) | Disclaimers added; Research tag promoted to header. |
|
||||
| `exo_time_crystal` / `exo_ghost_hunter` | **REAL-DSP, honest novelty** | Disclosed exploratory/novelty skills. NO-ACTION (DO-NOT-touch); A5 applied. |
|
||||
| `nvsim` | out of scope | Disclaimer gold standard; copied its tone. |
|
||||
|
||||
## Decision — Fixes Landed
|
||||
|
||||
### §A1 Medical overclaim (HIGH) — MEASURED
|
||||
|
||||
The five `med_*` modules (`med_seizure_detect`, `med_cardiac_arrhythmia`,
|
||||
`med_respiratory_distress`, `med_sleep_apnea`, `med_gait_analysis`) stated clinical
|
||||
detection as fact with no disclaimer ("Detects tonic-clonic seizures…").
|
||||
|
||||
**Real fix (honest labeling — the DSP is kept, untouched):**
|
||||
- **(a)** Every module's `//!` header now carries a mandatory disclaimer block,
|
||||
modelled on `sec_weapon_detect.rs` and `nvsim/src/lib.rs`: *"EXPERIMENTAL
|
||||
RESEARCH MODULE — NOT VALIDATED AGAINST CLINICAL DATA. NOT A MEDICAL DEVICE.
|
||||
Flags candidate <X>-like signatures only,"* citing ADR-160.
|
||||
- **(b)** Doc verbs softened: *"Detects tonic-clonic seizures"* →
|
||||
*"Flags candidate tonic-clonic-seizure-like motion signatures (experimental)"*;
|
||||
similarly for cardiac/respiratory/apnea/gait.
|
||||
- **(c)** All five gated behind a new **non-default** cargo feature
|
||||
`medical-experimental` (`#[cfg(feature = "medical-experimental")]` in `lib.rs`,
|
||||
`medical-experimental = []` in `Cargo.toml`, **not** in `default`) so they cannot
|
||||
be silently built into a shipping artifact.
|
||||
|
||||
**Failing-on-old tests** (`tests/honest_labeling.rs`):
|
||||
`a1_med_modules_have_clinical_disclaimer`,
|
||||
`a1_med_modules_gated_behind_medical_experimental`,
|
||||
`a1_seizure_verbs_softened`. All fail on the old, undisclaimed, ungated source.
|
||||
**Grade: MEASURED (label); per-skill clinical accuracy DATA-GATED.**
|
||||
|
||||
### §A2 Affect overclaim (HIGH) — MEASURED
|
||||
|
||||
`exo_happiness_score.rs` carried an **uncited** "Happy people walk ~12% faster"
|
||||
statistic and emits `HAPPINESS_SCORE`; `exo_emotion_detect.rs` emits
|
||||
`STRESS_INDEX`/`CALM_DETECTED`/`AGITATION_DETECTED`.
|
||||
|
||||
**Real fix (honest labeling — math kept):**
|
||||
- Deleted the uncited "12% faster" / "~12% above" / "Happy people walk" statements.
|
||||
- Added a prominent *"speculative, unvalidated affect heuristic; outputs are NOT
|
||||
measurements of emotion"* disclaimer to both `//!` headers, citing ADR-160.
|
||||
- Reframed `HAPPINESS_SCORE` in the docs as a **"gait-energy proxy, not a validated
|
||||
affect measure."**
|
||||
|
||||
**Failing-on-old tests:** `a2_affect_modules_have_unvalidated_disclaimer`,
|
||||
`a2_uncited_12_percent_stat_removed`, `a2_happiness_reframed_as_proxy`.
|
||||
**Grade: MEASURED (label); affect validity DATA-GATED.**
|
||||
|
||||
### §A3 Security event-name overclaim (MEDIUM) — MEASURED
|
||||
|
||||
`sec_weapon_detect.rs`'s module doc was already honest (research-grade,
|
||||
calibration-required), but the event/const names claimed weapon-grade
|
||||
discrimination a variance ratio cannot deliver.
|
||||
|
||||
**Real fix (honest physical-quantity naming — behavior unchanged):**
|
||||
- `EVENT_WEAPON_ALERT` → `EVENT_HIGH_METAL_REFLECTIVITY` (event id 221 unchanged).
|
||||
- `WEAPON_RATIO_THRESH` → `HIGH_REFLECTIVITY_THRESH`.
|
||||
- Internal fields/consts renamed (`weapon_run`→`high_refl_run`,
|
||||
`cd_weapon`→`cd_high_refl`, `WEAPON_DEBOUNCE`→`HIGH_REFLECTIVITY_DEBOUNCE`).
|
||||
- `lib.rs` `event_types` registry: `WEAPON_ALERT` → `HIGH_METAL_REFLECTIVITY`.
|
||||
- A reflectivity-vs-weapons honest-naming note added to the header.
|
||||
The detector still flags a high amplitude-variance/phase-variance ratio (real RF
|
||||
reflectivity); it just no longer *names* that "weapon".
|
||||
|
||||
**Failing-on-old tests:** `a3_weapon_names_renamed_to_reflectivity`,
|
||||
`a3_registry_no_longer_exports_weapon_alert` (registry no longer exports a
|
||||
`WEAPON_ALERT` name). **Grade: MEASURED.**
|
||||
|
||||
### §A4 Quasi-medical / sign-language exotic modules (MEDIUM) — MEASURED
|
||||
|
||||
`exo_dream_stage.rs` ("sleep stage classification", quasi-medical) and
|
||||
`exo_gesture_language.rs` ("sign language letter recognition").
|
||||
|
||||
**Real fix (honest labeling — DSP kept):** added an experimental "NOT VALIDATED"
|
||||
disclaimer to each `//!` header (citing ADR-160) and promoted the
|
||||
**Exotic/Research** registry tag into the header where a reader sees it.
|
||||
`exo_gesture_language` additionally states it is a coarse gesture-cluster
|
||||
classifier that **does not recognize true sign language** (never evaluated on a
|
||||
labelled ASL set).
|
||||
|
||||
**Failing-on-old test:** `a4_exotic_modules_have_experimental_disclaimer`.
|
||||
**Grade: MEASURED (label); accuracy DATA-GATED.**
|
||||
|
||||
### §A5 `static mut` event-buffer soundness (MEDIUM) — the one real code fix — MEASURED
|
||||
|
||||
~61 per-call event scratch buffers across the crate used a module-level
|
||||
`static mut EVENTS: [(i32,f32); N]` (a handful named `EV`/`TE`/`EMPTY`) and returned
|
||||
`&EVENTS[..n]`. On a `cdylib`+`rlib` linkable into multithreaded/reentrant host
|
||||
code this is latent aliasing UB, and `static_mut_refs` is deny-by-default on newer
|
||||
Rust.
|
||||
|
||||
**Real fix (mechanical, behavior-preserving):** moved each scratch buffer off
|
||||
`static mut` into an **owned per-instance field** (`events: [(i32,f32); N]` on the
|
||||
detector struct, written via `&mut self` and returned as `&self.events[..n]`). The
|
||||
public `-> &[(i32, f32)]` signature is **unchanged**, so no caller (in-module
|
||||
tests, `ghost_hunter` bin, `budget_compliance`) needed editing. Two helper methods
|
||||
that built events under `&self` (`spt_pagerank_influence::build_events`,
|
||||
`spt_spiking_tracker::build_events`) and `sig_temporal_compress::on_timer` were
|
||||
promoted to `&mut self`. Leftover now-redundant `unsafe { }` wrappers were removed.
|
||||
|
||||
**Count: 61 scratch buffers across 60 module files fixed** (the only `static mut`
|
||||
left in `src/` are the two **legitimate WASM module singletons** — `lib.rs STATE`
|
||||
and `bin/ghost_hunter.rs DETECTOR` — `#[cfg(target_arch="wasm32")]`,
|
||||
`#[no_mangle]`, accessed via `core::ptr::addr_of_mut!`, single-threaded by the
|
||||
wasm runtime contract; these are *not* the aliasing-UB scratch pattern and are
|
||||
left as-is).
|
||||
|
||||
**Verification:** the full host build (`--features std` and
|
||||
`std,medical-experimental`) compiles with **0 warnings** — there is no longer any
|
||||
`static mut <name>` + `&<name>` source for `static_mut_refs` to fire on in the 60
|
||||
fixed modules. (The pure-`wasm32-unknown-unknown` build, where the lint is
|
||||
deny-by-default, could not be run in this worktree because the `wasm32` target is
|
||||
not installed on the build toolchain; the source-level elimination is the
|
||||
evidence, asserted per-module by `a5_claim_bearing_modules_have_no_static_mut_event_buffer`.)
|
||||
**Grade: MEASURED (source-eliminated; residual = 2 legitimate singletons).**
|
||||
|
||||
## Negative Results (NO-ACTION positives — cited, not edited for labels)
|
||||
|
||||
Audited and found genuinely honest; cited as positives:
|
||||
- **`qnt_quantum_coherence.rs`** — discloses "quantum-**inspired**" analogy.
|
||||
- **`exo_time_crystal.rs`**, **`exo_ghost_hunter.rs`** — disclosed exploratory/novelty.
|
||||
- **`qnt_interference_search.rs`** — disclosed "Grover-**inspired**".
|
||||
- **`sig_*` / `lrn_*`** algorithm-named skills — names describe the DSP, not an outcome.
|
||||
- **`nvsim`** — out of scope; the project's disclaimer gold standard (its tone was
|
||||
copied into the A1/A2/A4 disclaimers).
|
||||
|
||||
(These were A5-soundness-fixed mechanically where they used `static mut`, with no
|
||||
label or behavior change, consistent with leaving their claim surface intact.)
|
||||
|
||||
## Deferred Backlog (Nothing Dropped)
|
||||
|
||||
- **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.
|
||||
- **The 2 residual `static mut` singletons** (`lib.rs STATE`, `ghost_hunter DETECTOR`)
|
||||
— **ACCEPTED-FUTURE**: these are the canonical wasm module-state pattern; migrating
|
||||
them to a safe cell is a separate, larger change with no current UB (single-threaded
|
||||
wasm runtime, `addr_of_mut!` access).
|
||||
|
||||
## Reproduction (MEASURED)
|
||||
|
||||
```bash
|
||||
cd v2/crates/wifi-densepose-wasm-edge # excluded from the v2 workspace; build here
|
||||
cargo test --features std # default
|
||||
cargo test --features std,medical-experimental # med_* skills enabled
|
||||
cargo test --no-default-features --features std # no default-pipeline
|
||||
cargo test --features std --test honest_labeling # A1–A5 label invariants
|
||||
```
|
||||
|
||||
(`std` is required for host tests — the crate is `no_std` for `wasm32`; pure
|
||||
`--no-default-features` builds only on `wasm32-unknown-unknown`, where it
|
||||
intentionally has no panic handler on the host.)
|
||||
|
||||
Result at time of writing (all 0 failed):
|
||||
- **DEFAULT** (`--features std`) — **615 passed** (lib 504; budget 25; honest_labeling 10; bench 1; vendor 75)
|
||||
- **MEDICAL** (`--features std,medical-experimental`) — **653 passed** (lib 542; +38 med_* tests; others unchanged)
|
||||
- **NO-DEFAULT** (`--no-default-features --features std`) — **615 passed**
|
||||
- Full host build emits **0 warnings**; **61** `static mut` scratch buffers eliminated, **2** legitimate wasm singletons remain.
|
||||
|
||||
## Consequences
|
||||
|
||||
- No edge skill's name or doc-comment claims a clinical, affective, security, or
|
||||
sign-language capability the unvalidated DSP cannot back.
|
||||
- The five medical skills cannot be silently compiled into a shipping artifact
|
||||
(non-default `medical-experimental` gate).
|
||||
- The security skill can never emit a "weapon alert" — it reports
|
||||
`HIGH_METAL_REFLECTIVITY`, the physical quantity it actually measures.
|
||||
- The latent `static mut` aliasing-UB / `static_mut_refs` exposure is removed from
|
||||
60 modules; the public API and all runtime behavior are unchanged (615/653 tests
|
||||
prove behavior preservation).
|
||||
- ADR-159's deferred-backlog statement *"wasm-edge … honestly labelled, not
|
||||
claimed"* is now actually TRUE.
|
||||
@@ -0,0 +1,267 @@
|
||||
# ADR-161: HOMECORE Server Layer — WebSocket Auth Bypass, Reply-Theater & Documented-but-No-Op Automation (Security & Honest Labeling)
|
||||
|
||||
- **Status**: accepted
|
||||
- **Date**: 2026-06-12
|
||||
- **Deciders**: ruv
|
||||
- **Tags**: homecore, http-ws-boundary, websocket-auth-bypass, security, automation-engine, documented-no-op, prove-everything, soundness, honest-labeling
|
||||
- **Amends**: ADR-130 (HOMECORE-API WS protocol), ADR-129 (HOMECORE-AUTO automation engine), ADR-128 (plugin manifest)
|
||||
|
||||
## Context
|
||||
|
||||
Beyond-SOTA sweep **Milestone 7**, over the HOMECORE **server/network layer**
|
||||
crates only — `homecore-api`, `homecore-server`, `homecore-automation`,
|
||||
`homecore-hap`, `homecore-plugins` — executed under the project's
|
||||
**prove-everything / anti-"AI-slop"** directive.
|
||||
|
||||
### Headline — the library cores are real, but the network boundary was unsound
|
||||
|
||||
The same audit pattern as ADR-160 held for the *library logic*: the automation
|
||||
trigger/condition/template/action evaluators, the REST handlers, the HAP
|
||||
mapping, and the plugin manifest parser are **real, tested code** — not stubs.
|
||||
That is the anti-slop positive and it is cited here as such.
|
||||
|
||||
What the audit found was **not fake business logic but an unsound trust
|
||||
boundary plus documented-but-no-op features**:
|
||||
|
||||
1. A **CRITICAL WebSocket authentication bypass** — the WS handshake accepted
|
||||
any non-empty token, ignoring the provisioned token whitelist the REST path
|
||||
enforces.
|
||||
2. **Reply-theater** — WS command responses were computed, then logged and
|
||||
**discarded**; no `result`/`pong`/`event` ever reached the client.
|
||||
3. **Documented-but-idle automation** — the engine was constructed and dropped
|
||||
(never started); time triggers, `RunMode`, `Choose` branches, and template
|
||||
conditions were each **documented as working but were no-ops in the live
|
||||
path**.
|
||||
|
||||
This is a worse class than ADR-160's over-naming: here the **doc claimed a
|
||||
capability the code did not deliver** (auth enforcement, reply transport,
|
||||
running automations). The fix is **implement where feasible, honestly relabel
|
||||
where not — never leave a false doc.** Every fix is pinned by a test that
|
||||
**fails on the old code**.
|
||||
|
||||
Grading vocabulary (ADR-152 / ADR-158 / ADR-160):
|
||||
- **MEASURED** — reproduced in this worktree, command + failing-on-old test recorded.
|
||||
- **NO-ACTION (already-honest/already-hardened)** — audited, found correct, cited as a positive.
|
||||
- **ACCEPTED-FUTURE** — deliberately deferred, nothing dropped.
|
||||
|
||||
## Decision — Fixes Landed
|
||||
|
||||
### §A1 — WebSocket auth bypass (CRITICAL, security) — MEASURED
|
||||
|
||||
`homecore-api/src/ws.rs` handshake checked only `token.trim().is_empty()` and
|
||||
sent `auth_ok` for **any** non-empty token. It never called
|
||||
`state.tokens().is_valid()` — the check the REST path uses via
|
||||
`auth::BearerAuth`. With a provisioned `HOMECORE_TOKENS` whitelist, **any
|
||||
attacker-chosen non-empty token got full WS access** (read all states, call any
|
||||
service, subscribe to all events).
|
||||
|
||||
**Real fix:** the handshake now calls
|
||||
`state.tokens().is_valid(&token).await` (the *same* store + method as REST).
|
||||
A wrong token receives `auth_invalid` and the socket closes. DEV (`allow_any`)
|
||||
mode still accepts any non-empty bearer with a warn, so smoke tests keep
|
||||
working; the empty token is rejected inside `is_valid`.
|
||||
|
||||
**Failing-on-old test** (`tests/ws_handshake.rs`):
|
||||
`wrong_token_is_rejected` — provisions a real (non-dev) store with one good
|
||||
token, sends a DIFFERENT non-empty token over the WS handshake, asserts
|
||||
`auth_invalid`. On the old source the client received
|
||||
`{"type":"auth_ok",…}` (verified: the test panics on old `ws.rs` with
|
||||
`left: "auth_ok", right: "auth_invalid"`). Companion: `correct_token_is_accepted`.
|
||||
**Grade: MEASURED. This is the milestone headline.**
|
||||
|
||||
### §A2 — WS replies never transmitted (HIGH, functional) — MEASURED
|
||||
|
||||
`ws.rs::Connection::run` moved the socket into a recv-only task; the only
|
||||
consumer of the response mpsc just did `debug!("ws emit: {msg}")` and dropped
|
||||
every message. No command reply ever reached the wire.
|
||||
|
||||
**Real fix:** the socket is split with `futures_util::StreamExt::split`. A
|
||||
dedicated **writer task** drains the response channel onto `sink.send(...)`
|
||||
(text frames; a `__pong:<n>` sentinel maps to a Pong control frame); the reader
|
||||
task parses commands concurrently. On reader exit the senders drop and the
|
||||
writer task ends cleanly.
|
||||
|
||||
**Failing-on-old tests:** `result_reply_is_received` (connect → auth →
|
||||
`get_states` → assert a `result` reply is RECEIVED within 5s) and
|
||||
`ping_pong_reply_is_received`. Both time out on the old source (verified:
|
||||
`Elapsed` panic). **Grade: MEASURED.**
|
||||
|
||||
### §A8 — `homecore-api` bin: no env-token path, network-exposed (HIGH, security) — MEASURED
|
||||
|
||||
`homecore-api/src/bin/server.rs` bound `0.0.0.0:8123` with
|
||||
`SharedState::new()` → `allow_any_non_empty()` and **no** `HOMECORE_TOKENS`
|
||||
path (unlike `homecore-server`), so a provisioned operator had no way to lock
|
||||
it down.
|
||||
|
||||
**Real fix:** the bin now mirrors `homecore-server`'s provisioning — prefer the
|
||||
`HOMECORE_TOKENS` whitelist (`LongLivedTokenStore::from_env()`), fall back to an
|
||||
**explicitly warn-logged** DEV mode only when unset. It also defaults the bind
|
||||
address to **`127.0.0.1`** (loopback) so a bare `cargo run` is not
|
||||
network-exposed, with `HOMECORE_BIND` to opt into LAN.
|
||||
|
||||
**Failing-on-old test** (`tests/server_bin_auth.rs`):
|
||||
`provisioned_bin_rejects_wrong_bearer` reproduces the bin's exact provisioning
|
||||
path (a populated, non-dev store) and asserts a wrong bearer → 401;
|
||||
`from_env_path_enforces_whitelist` proves `from_env()` is not dev mode and
|
||||
enforces the list. The old bin's `allow_any_non_empty()` accepted the wrong
|
||||
bearer. **Grade: MEASURED.**
|
||||
|
||||
### §A3 — Automation engine never started (HIGH) — MEASURED
|
||||
|
||||
`homecore-server/src/main.rs` did `let _automation_engine = AutomationEngine::new(...)`
|
||||
then dropped it immediately, while the header doc claimed "Automation engine
|
||||
subscribed to the state machine."
|
||||
|
||||
**Real fix:** the engine is now built into a long-lived binding and `.start()`
|
||||
is called, spawning the event loop + timer task; the header/log lines state it
|
||||
is started with N automations and which trigger classes are active. (With A4–A7
|
||||
the running engine is genuinely functional, not theater.)
|
||||
|
||||
**Evidence:** the engine-behavior tests below run against the same
|
||||
`AutomationEngine::start()` path now wired into the bin. **Grade: MEASURED.**
|
||||
|
||||
### §A4 — `Trigger::Time` hard-coded `false`, no timer (HIGH) — MEASURED
|
||||
|
||||
`trigger.rs::matches_sync` returned `false` for `Time` and there was **no timer
|
||||
task** anywhere, so time automations could never fire.
|
||||
|
||||
**Real fix:** `AutomationEngine::start_timer` — a 1 Hz tokio interval that
|
||||
compares each `time:` automation's `at` (`HH:MM` or `HH:MM:SS`) against the
|
||||
local wall-clock second and fires it once per match (conditions still gate it).
|
||||
`matches_sync` returning `false` for `Time` is now **correct and documented**
|
||||
(it is a wall-clock trigger with no state-change context); a public
|
||||
`fire_time_for_test` exposes the same path deterministically.
|
||||
|
||||
**Failing-on-old test** (`tests/engine_behaviors.rs`):
|
||||
`time_trigger_fires_via_timer_path` (+ unit `time_at_matches_handles_hh_mm_and_hh_mm_ss`).
|
||||
The method does not exist on the old engine. **Grade: MEASURED.**
|
||||
|
||||
### §A5 — `RunMode` documented as AtomicBool-enforced but unbounded-parallel (HIGH) — MEASURED
|
||||
|
||||
`engine.rs` doc claimed "RunMode::Single is enforced via a per-automation
|
||||
AtomicBool" — but no such code existed and **every** trigger spawned an
|
||||
unbounded parallel task regardless of `mode`.
|
||||
|
||||
**Real fix:** each registered automation carries a `running: Arc<AtomicBool>`.
|
||||
`Single`/`IgnoreFirst` modes `compare_exchange` the flag before spawning and
|
||||
**skip** the trigger if a run is already in flight, clearing it on completion;
|
||||
`Parallel` (and, for now, `Restart`/`Queued`) spawn on every trigger.
|
||||
|
||||
**Failing-on-old tests** (`tests/engine_behaviors.rs`):
|
||||
`single_mode_does_not_double_fire_on_rapid_triggers` (two rapid triggers while
|
||||
the first run sleeps → exactly **1** run; old code fired **2**, verified) and
|
||||
`parallel_mode_does_fire_concurrently` (→ 2). **Grade: MEASURED (Single/Parallel
|
||||
honored; bounded `Queued`/`Restart`/`max` ordering → ACCEPTED-FUTURE, see below).**
|
||||
|
||||
### §A6 — `Action::Choose` ignored branches (HIGH) — MEASURED
|
||||
|
||||
`action.rs` discarded `choices` and always ran `default`.
|
||||
|
||||
**Real fix:** `ChoiceBranch::matches` deserialises each branch's
|
||||
`serde_yaml::Value` conditions into `Condition` and evaluates them (AND
|
||||
semantics, against an `EvalContext` now carried on `ExecutionContext`). `Choose`
|
||||
runs the **first matching branch's** sequence and falls to `default` only if
|
||||
none match.
|
||||
|
||||
**Failing-on-old tests** (`action.rs` inline):
|
||||
`choose_runs_matching_branch_not_default` (matching branch runs, default does
|
||||
NOT — old code ran default, verified) and
|
||||
`choose_falls_to_default_when_no_branch_matches`. **Grade: MEASURED.**
|
||||
|
||||
### §A7 — Template conditions always false in the live engine (MEDIUM) — MEASURED
|
||||
|
||||
`condition.rs` returned `false` for `Template` whenever `template_env` was
|
||||
`None`, and the engine built every `EvalContext` with `template_env: None`
|
||||
(`EvalContext::new`), so `template:` conditions could never be true in
|
||||
production — only in unit tests that hand-built a template env.
|
||||
|
||||
**Real fix:** the engine constructs one `TemplateEnvironment` over the state
|
||||
machine and threads it into every `EvalContext` via
|
||||
`EvalContext::with_templates` (event loop, timer task, and
|
||||
`ExecutionContext` for `Choose` branches).
|
||||
|
||||
**Failing-on-old tests** (`tests/engine_behaviors.rs`):
|
||||
`template_condition_evaluates_true_in_engine` (a `{{ is_state(...) }}` condition
|
||||
gates an action true) and `template_condition_evaluates_false_blocks_action`.
|
||||
On the old engine the action never ran (template always false, verified).
|
||||
**Grade: MEASURED.**
|
||||
|
||||
### §B5 — Plugin manifest sig/hash "verified before execution" doc was false (LOW, honesty) — relabeled
|
||||
|
||||
`homecore-plugins/src/manifest.rs` documented `wasm_module_hash` as "verified
|
||||
before execution" and carried `wasm_module_sig` / `publisher_key`, but these
|
||||
fields are **never read** for verification (only ever set to `None` in tests).
|
||||
|
||||
**Fix (honest labeling — no false capability claimed):** the three fields are
|
||||
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).** *(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)
|
||||
|
||||
These were checked and are genuinely sound/honest; cited as positives, **not**
|
||||
touched:
|
||||
- **CSPRNG correctness** — all IDs are `uuid::v4`; the rng/`randn` suspicion was
|
||||
**REFUTED**. No weak-randomness issue exists.
|
||||
- **CORS allowlist** (`app.rs`) — already hardened (explicit `AllowOrigin::list`,
|
||||
no `permissive()`, `allow_credentials(false)`, env override). NO-ACTION.
|
||||
- **No path traversal in `homecore-migrate`** — audited, clean.
|
||||
- **No secrets in logs** — audited, clean.
|
||||
- **HAP pairing stub** — honestly disclaimed as a surface stub; not over-claimed.
|
||||
- **`InProcessRuntime` "no sandbox" disclaimer** — honest; left as-is.
|
||||
|
||||
## Deferred Backlog (Nothing Dropped)
|
||||
|
||||
- **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.~~ **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
|
||||
honored; bounded queueing, restart-kill, and `max` concurrency are not yet
|
||||
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.
|
||||
|
||||
## Reproduction (MEASURED)
|
||||
|
||||
```bash
|
||||
cd v2
|
||||
cargo test -p homecore-api -p homecore-server -p homecore-automation -p homecore-hap --no-default-features
|
||||
cargo test -p homecore-plugins --features wasmtime
|
||||
cargo build --workspace --no-default-features
|
||||
```
|
||||
|
||||
Result at time of writing (all 0 failed):
|
||||
- **homecore-api** — **25 passed** (lib 18; `server_bin_auth` 3; `ws_handshake` 4)
|
||||
- **homecore-automation** — **42 passed** (lib 37; `engine_behaviors` 5)
|
||||
- **homecore-hap** — **17 passed**
|
||||
- **homecore-server** — bin, **0 tests**
|
||||
- (**homecore-plugins** — **15 passed**: lib 12; integration 3)
|
||||
- Full workspace `cargo build --workspace --no-default-features` succeeds.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The WebSocket path can no longer be entered with a forged token — it enforces
|
||||
the same `LongLivedTokenStore` whitelist as REST (A1).
|
||||
- WS clients now actually receive `result`/`pong`/`event` frames (A2).
|
||||
- The `homecore-api` dev bin defaults to loopback and honors `HOMECORE_TOKENS`
|
||||
(A8); it is no longer an open `0.0.0.0` accept-any endpoint by default.
|
||||
- The automation engine is started for real and its time triggers, `Single`
|
||||
run-mode, `Choose` branches, and `template:` conditions all function — no doc
|
||||
claims a capability the code lacks (A3–A7).
|
||||
- The plugin manifest no longer claims signature verification it does not
|
||||
perform (B5).
|
||||
- Files kept under the 500-line guideline (`engine.rs` 462; behavioral tests
|
||||
moved to `tests/engine_behaviors.rs`).
|
||||
@@ -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 1–2 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.
|
||||
@@ -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-154–163) just landed.** That sweep is itself a structured retraction layer: each ADR exists *because* an earlier accepted-or-shipped claim was found false (a dead CIR coherence gate, a fake-gradient TTA path, a self-certifying proof, a WebSocket auth bypass, an inflated survivor count). The sweep hardened five subsystems but was narrowly scoped — it never touched the two largest capability gaps (camera-teacher training validation; federation/BFLD privacy chains). A ledger is needed to record what the sweep retracted and what it left open.
|
||||
2. **The status field can no longer be trusted as a source of truth.** A five-lens audit (status-distribution, supersession-chains, contradictions, coverage-gaps, data-hardware-gated) found ~24 ADRs mislabeled `Proposed` while their own commit-pinned Implementation-Status notes report them built and tested; 6 ADR numbers collide; 3 files have no Status header at all. An auditor reading headers would conclude "not built" for landed code, and "built/Accepted" for unvalidated capability.
|
||||
|
||||
The detailed lens outputs and the full per-ADR census live in `docs/adr/gap-analysis/` (`lens-findings.md`, `census.md`). This ADR is the authoritative summary and remediation backlog.
|
||||
|
||||
## Decision
|
||||
|
||||
**This ADR is the authoritative gap ledger and remediation backlog for the ADR corpus as of 2026-06-12.** It does not change any subsystem behavior. It records, with cited ADR ids:
|
||||
|
||||
- the status/impl distribution and the bookkeeping-drift problem;
|
||||
- a prioritized Gap Register with a recommended action per gap;
|
||||
- supersession-integrity defects;
|
||||
- the contradiction/retraction list (the anti-slop centerpiece);
|
||||
- shipped capabilities with no governing ADR;
|
||||
- the genuinely open data/hardware-gated backlog.
|
||||
|
||||
Until the Gap Register items are worked, **treat the ADR Status header as advisory, not authoritative**, and treat any accuracy number authored before ADR-155 landed as CLAIMED (not MEASURED) until re-derived through the post-155 leak-free validation split.
|
||||
|
||||
## Status Distribution
|
||||
|
||||
Counts are approximate (`~`) where a status string is non-canonical or dual-valued; the per-ADR breakdown is in `census.md`.
|
||||
|
||||
| Status bucket | Count | impl_state | Count |
|
||||
|---|---|---|---|
|
||||
| Accepted (incl. partial/in-progress/Phase-1 variants) | ~56 | implemented | ~36 |
|
||||
| Proposed (incl. conditional/research-only) | ~88 | partial | ~50 |
|
||||
| Superseded | 1 (ADR-002) | proposed-only | ~64 |
|
||||
| Rejected | 1 (ADR-098) | stale-or-contradicted | 3 (029/030/031) |
|
||||
| Missing / no Status header | 3 (ADR-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 | 136–145 | ~~mark-stale → "Accepted — partial (integration glue pending)" (one batch)~~ DONE: all 10 (136–145) flipped to "Accepted — partial"; each retains its commit-pinned Implementation-Status note. NB: notes describe *building blocks built + tested*, **not** live-path integration — "partial" is the honest label, not full "Accepted" |
|
||||
| G6 | Stale `Proposed` headers on built+published code | HIGH | 029/030/031, 095/096, 152, 154–157, 024/027/072, 150 | mark-stale; reconcile with downstream/CLAUDE.md evidence |
|
||||
| G7 | Status-graph inversion: Accepted ADR depends on Proposed parent | HIGH | 032→029/030/031; 053→052; 048→045; 077→075/076; 104→103 | promote parents to match built reality, or downgrade dependents |
|
||||
| G8 | ADR-002 supersession not reciprocated by successors; 5 children stranded | HIGH | 002→016/017; children 003/007/008/009/010 | reconcile-docs (add reciprocal language or downgrade); split 002 to "partially superseded" |
|
||||
| G9 | Streaming-engine integrator crate has no governing ADR (composition/back-pressure/live-path seam) | HIGH | wifi-densepose-engine (composes 135–146) | write-missing-ADR |
|
||||
| G10 | CLAUDE.md doc-vs-header drift (doc says one status, header another) | HIGH | 017, 024, 027, 072, 152 | reconcile-docs |
|
||||
| G11 | Open security HIGH findings, gate FAILED, never marked done | 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/007–010, 105–109, 118–125, HOMECORE 124–133, 033/046/049/067/074/085 | close-as-gated or mark Deferred/Rejected + open tracking issues |
|
||||
| G16 | De-facto supersessions never recorded (lifecycle graph incomplete) | MEDIUM | 098/099, 063/064, 042/153, 050/060, 035/023, 100/109, 117 retracts PyPI v1.1.0 | reconcile (add supersedes/superseded_by fields) |
|
||||
| G17 | Accepted but no implementation evidence ("unverified done") | MEDIUM | 034 (FieldView app — no crate); 044 (wifi-densepose-geo — bare Accepted, no Date/Deciders) | implement or downgrade to Proposed |
|
||||
| G18 | Workspace has ~38 crates; CLAUDE.md publishing list (12-step) and crate table (15) are stale | MEDIUM | corpus-wide (crate-graph topology) | write-missing-ADR (crate-graph / publish boundaries) + reconcile CLAUDE.md |
|
||||
|
||||
## Supersession Integrity
|
||||
|
||||
Only **3 formal supersession edges** exist; all three are defective (see G8/G12; full detail in `lens-findings.md` Lens 2):
|
||||
|
||||
- **ADR-002 → ADR-016 / ADR-017** is one-directional. ADR-016 never mentions ADR-002 (its References list only 014/015); ADR-017 only *corrects* ADR-002's "fictional crate names" and never says "supersede." The census `supersedes:["ADR-002"]` on 016/017 is **file-unsupported** — the superseded ADR points up at two successors that do not point back.
|
||||
- **ADR-002 is an umbrella** whose children 003/007/008/009/010 are still `Proposed`. ADR-016/017 realize only the training/signal/MAT integration points; the RVF-container (003), PQ-crypto (007), Raft (008), WASM-edge-runtime (009), and witness-chains (010) decisions are **neither implemented nor formally superseded**. Marking the parent fully "Superseded" silently buries 5 live-but-abandoned child decisions. Recommended: split ADR-002 to "partially superseded."
|
||||
- **ADR-052-tauri → ADR-054** is declared by the predecessor but ADR-054 contains zero references to ADR-052. ADR-054 ("Full Implementation", in progress) is the impl plan *for* 052, not a replacement — likely a mis-modeled edge.
|
||||
- **No cycles** detected. The graph is clean structurally; the defect is missing reciprocity and ~7 unrecorded de-facto supersessions (G16).
|
||||
|
||||
## Contradictions & Retractions (anti-slop centerpiece)
|
||||
|
||||
The four CRITICAL items are the corpus's load-bearing AI-slop admissions — each an accepted-or-shipped surface whose stated accuracy/security/function was provably false until the sweep landed. **Every accuracy number predating ADR-155 should be treated as CLAIMED until re-derived through the post-155 leak-free split.** Source-cited evidence is in `lens-findings.md` Lens 3.
|
||||
|
||||
- **[CRITICAL] ADR-155** retracts every prior NN accuracy/TTA/proof claim: real MM-Fi training validated against a *synthetic* val set with stride-1 (~99%) window leakage (§2.2); a *fake gradient* `grad += v*0.01` in the TTA path (§2.3); a *self-certifying* proof that blessed whatever the pipeline emitted and PASSed on 1e-9 float noise (§2.4).
|
||||
- **[CRITICAL] ADR-154** proves the ADR-134 CIR coherence gate was **dead in production for every canonical 56-tone frame** (`SubcarrierMismatch`, 0 Ok / 8 mismatch), silently degrading coherence to freq-only. Any "CIR-enhanced coherence/ToF" claim before this fix overstated reality.
|
||||
- **[CRITICAL] ADR-079** carries three mutually inconsistent values for its own central metric: proxy PCK@20 = 2.5% (prose) vs 35.3% (baseline table — equal to the *target*) vs 0% upper-body joints; #640 measured 0% on real local data. An Accepted ADR whose headline 10–20x improvement is self-refuting.
|
||||
- **[CRITICAL] ADR-161** fixes a HOMECORE WebSocket **auth bypass** (any non-empty token accepted) + reply-theater + no-op automation; **ADR-162** then enforces plugin Ed25519 signature verification, capability isolation, and bounded RunModes — retracting ADR-128/129/130's implied security guarantees.
|
||||
- **[HIGH]** ADR-152 self-refutes 1 of 25 claims (ESP WiFi-6 "drop-in" REFUTED 0-3); CLAUDE.md's "WiFlow-STD MEASURED-EQUIVALENT ~96% PCK" contradicts §F1's own gating (97.25% is CLAIMED until measurements (a)–(c) run). ADR-150 retracts the implied cross-subject capability (81.63% in-domain vs ~11.6% leakage-free cross-subject; DANN ~0 gain). ADR-159 ships real models but discloses person-count `training_class1_accuracy = 0.343` and renames "learned multi-person counter" → "presence detector," gutting ADR-103/104's claim.
|
||||
- **[MEDIUM]** ADR-163 leaves the ESP32/Xtensa on-hardware latency figure UNMEASURED; ADR-098↔099 partial reversal on midstream; ADR-147 self-retracts Cosmos for OccWorld.
|
||||
|
||||
## Coverage Gaps (shipped capability, no/broken governing ADR)
|
||||
|
||||
- ~~**CRITICAL — `homecore-recorder`** (SQLite state history + semantic search) cites "ADR-132", which **does not exist**. The durable-state backbone is ungoverned. → write HOMECORE-RECORDER ADR.~~ **RESOLVED in `docs/adr-gap-remediation-1`:** ADR-132 written (`ADR-132-homecore-recorder-history-semantic-search.md`, Status: Accepted — reverse-documented from the shipped crate).
|
||||
- ~~**CRITICAL — `homecore-migrate`** (reads untrusted Python-HA `.storage/*.json`) cites "ADR-134", but on-disk ADR-134 is CIR. A data-integrity-sensitive importer governed by a phantom identity. → resolve 134 collision + write HOMECORE-MIGRATE ADR (trust boundary).~~ **RESOLVED in `docs/adr-gap-remediation-1`:** ADR-165 written (`ADR-165-homecore-migrate-from-home-assistant.md`, Status: Accepted — P1 scaffold); crate's `ADR-134` refs repointed → ADR-165; on-disk ADR-134 (CIR) left intact. ADR-126's series-map row (which labels the *role* "ADR-134 HOMECORE-MIGRATE") is owner-gated and unchanged.
|
||||
- **HIGH — `wifi-densepose-engine`** composes ADR-135..146 onto the live 20 Hz path but **no ADR governs the integrator contract** (ordering, back-pressure, "one pipeline cycle" boundary).
|
||||
- **MEDIUM — `wasm-edge`** (~70 skills) governed only by remediation ADRs 160/163 — no creation/taxonomy/ABI ADR. **`occworld-candle`** is a Rust-native backend swap ADR-147 explicitly deferred. **`pointcloud`** has only a viewer-deploy ADR (094), no data-format contract.
|
||||
- **MEDIUM — workspace topology:** ~38 crates exist; the CLAUDE.md 15-crate table and 12-step publishing order are stale, and no ADR governs crate-graph/publish boundaries at this scale.
|
||||
- Verified-governed (scoped out): worldmodel→147, worldgraph→139, cog-*→101/103/116, ruview-swarm→148, nvsim→089/092, bfld→118-123/141, calibration→151, homecore-hap→125, geo→044, desktop→052/054.
|
||||
|
||||
## Open / Gated Backlog (genuinely unresolved, honestly labeled)
|
||||
|
||||
The ADR-154–163 sweep was narrowly scoped. The two largest **capability** gaps it did not touch:
|
||||
|
||||
- **CRITICAL — Camera-teacher training validation (ADR-079 / 072 / 150).** P7–P9 Pending; blocker is a real synchronized camera+ESP32 paired-capture session + GPU training on the fleet (ruvultra RTX 5080). Cross-subject collapse (11.6%) is data-gated on a heterogeneous multi-subject CSI dataset, per ADR-150 §F3 / ADR-152 F3 (the lever is *more data*, not capacity). Accepted-on-paper, not proven.
|
||||
- **HIGH — Federation + BFLD privacy chains (ADR-105–109, 118–125).** All Proposed-only, ACs unchecked. Blockers: KIT BFId dataset (121), Pi5/Nexmon CBFR capture hardware (123 — ESP32 structurally cannot sniff CBFR), Soul-Signature + cog-ha-matter (122/125). The privacy control *plane* (ADR-141) is built; the *capture/scoring* chain it gates is not.
|
||||
- **HIGH — Sensing-server security (ADR-080).** Distinct from the HOMECORE boundary the sweep fixed; XFF bypass / stack-trace leakage / JWT-in-URL remain open.
|
||||
- **MEDIUM — gold-standard deferrals (model to follow):** ADR-163 (ESP32 on-hardware latency UNMEASURED), ADR-160 (medical/affect/weapon NOT validated, relabelled), ADR-158 (RF-through-rubble + learned counter DATA-GATED). Code is real, the claim is withheld pending absent hardware/labelled data — labels are honest.
|
||||
- **MEDIUM — purely hardware/data-gated Proposed decisions (no overreach):** ADR-023, 027, 042, 063/064, 065/066, 070, 073/078, 083, 086, 091, 103, 110 (HE-CSI needs ESP-IDF ≥5.5), 113, 114, 134/135, 143-v2, 144. *needs verification* where flags rely on downstream prose rather than direct file inspection.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive.** One authoritative ledger replaces scattered, drifting status fields. The anti-slop retractions are recorded in a citable place, so the "AI slop" accusation is met with a structured admission + fix-trail rather than denial. The Gap Register is a concrete, severity-ordered work queue. Batch-fixing G5 (10 streaming-engine headers) and G1/G2 (numbering + missing headers) is high-ROI and unblocks ADR tooling.
|
||||
|
||||
**Negative.** This ADR is a snapshot; it goes stale the moment the next ADR lands. Counts marked `~` are approximate and a few impl_state values are *needs verification* (downstream-prose-derived, not file-confirmed). Acting on the register (renumbering, status flips, supersession edits) touches ~30 files and risks transient cross-reference breakage if not done atomically.
|
||||
|
||||
**Neutral.** No subsystem behavior changes. Renumbering decisions (which of the colliding files keeps each number) are deferred to the follow-up remediation PR — this ADR records the collision, not the resolution. Whether to close abandoned chains as `Rejected` vs `Deferred` is a judgment call left to the deciders per chain.
|
||||
|
||||
## Links
|
||||
|
||||
- `docs/adr/gap-analysis/census.md` — full per-ADR census (162 entries).
|
||||
- `docs/adr/gap-analysis/lens-findings.md` — five-lens findings (status-distribution, supersession-chains, contradictions, coverage-gaps, data-hardware-gated), verbatim.
|
||||
- Anti-slop sweep: ADR-154, ADR-155, ADR-156, ADR-157, ADR-158, ADR-159, ADR-160, ADR-161, ADR-162, ADR-163.
|
||||
- Most-cited defects: ADR-079, ADR-134, ADR-002, ADR-136–145, ADR-152.
|
||||
- Governance: CLAUDE.md (crate table + publishing order — stale per G18); ADR-038 (prior roadmap census, now stale).
|
||||
@@ -0,0 +1,129 @@
|
||||
# ADR-165: HOMECORE-MIGRATE — Migration Tooling from Python Home Assistant
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Accepted — P1 scaffold (full conversion deferred to P2) |
|
||||
| **Date** | 2026-05-25 |
|
||||
| **Deciders** | ruv |
|
||||
| **Codename** | **HOMECORE-MIGRATE** |
|
||||
| **Crate** | `v2/crates/homecore-migrate` |
|
||||
| **Relates to** | [ADR-126](ADR-126-ruview-native-ha-port-master.md) (HOMECORE master — series map row "ADR-134 HOMECORE-MIGRATE"), [ADR-127](ADR-127-homecore-state-machine-rust.md) (HOMECORE-CORE), [ADR-132](ADR-132-homecore-recorder-history-semantic-search.md) (HOMECORE-RECORDER — P2 side-by-side export target) |
|
||||
| **Tracking issue** | [#800](https://github.com/ruvnet/RuView/pull/800) (HOMECORE intake) |
|
||||
|
||||
> **Number-collision resolution (2026-06-12).** The HOMECORE series in ADR-126 §4 planned
|
||||
> "ADR-134 = HOMECORE-MIGRATE", and the `homecore-migrate` crate cites "ADR-134" throughout.
|
||||
> But the on-disk `ADR-134-csi-to-cir-time-domain-multipath.md` is a **different, unrelated
|
||||
> decision** (First-Class CIR Support, a signal-processing tier). The migrate crate was
|
||||
> therefore governed by a phantom identity (ADR-164 Gap G3 / Coverage-Gaps Lens §A). This
|
||||
> ADR takes the next free number (**165**) and becomes the real governing record for
|
||||
> HOMECORE-MIGRATE; the `ADR-134` references inside `v2/crates/homecore-migrate/` are
|
||||
> repointed to ADR-165. The real ADR-134 (CIR) is untouched. ADR-126's series-map row still
|
||||
> labels the *role* "ADR-134 HOMECORE-MIGRATE" for historical traceability; that registry
|
||||
> renumber is owner-gated and left for the follow-up. This ADR reverse-documents the shipped
|
||||
> P1 scaffold; it introduces no new design.
|
||||
|
||||
---
|
||||
|
||||
## 1. Context
|
||||
|
||||
ADR-126 decided to reimplement Home Assistant (HA) natively in Rust. A user adopting
|
||||
HOMECORE has an existing HA install whose configuration lives in two places on disk:
|
||||
|
||||
- `.storage/*.json` — versioned JSON envelopes (`{ version, minor_version, data }`) holding
|
||||
the entity registry, device registry, and config entries;
|
||||
- top-level YAML — `secrets.yaml`, `automations.yaml`.
|
||||
|
||||
To migrate, HOMECORE must read this foreign, **untrusted** on-disk state. It is untrusted in
|
||||
the security sense: the schema can drift between HA releases, and silently mis-parsing a
|
||||
registry would corrupt the imported home. ADR-164 flagged this as a CRITICAL coverage gap —
|
||||
a data-integrity-sensitive importer governed by a non-existent ADR identity.
|
||||
|
||||
The decision an ADR must pin here is the **trust boundary and import contract**: which HA
|
||||
files are read, how schema versions are validated, and what happens on an unknown version.
|
||||
|
||||
## 2. Decision
|
||||
|
||||
Ship `homecore-migrate` as a CLI + library that reads an existing HA filesystem and imports
|
||||
its configuration into HOMECORE. P1 is a **scaffold**: it parses and inspects everything and
|
||||
converts the entity registry; full conversion of the remaining artifacts is deferred to P2.
|
||||
|
||||
### 2.1 Storage reader + versioned format gate (P1, shipped)
|
||||
|
||||
- `HaStorageDir` / `HaStorageEnvelope` read HA's `.storage/` directory; `read_envelope(path)`
|
||||
deserializes a `.storage/*.json` envelope (`src/storage.rs`).
|
||||
- Versioned parsers live under `storage_format::v<N>` (e.g. `v13` for the entity registry)
|
||||
(`src/storage_format/`).
|
||||
- **Schema-version validation is the load-bearing safety rule (§6 Q5 of this ADR):** an
|
||||
unknown `minor_version` is a **hard error** (`MigrateError::UnsupportedSchemaVersion`),
|
||||
never a silent best-effort parse. Better to refuse than to corrupt.
|
||||
|
||||
### 2.2 Per-artifact parsers (P1, shipped)
|
||||
|
||||
- `entity_registry::load()` — `core.entity_registry` → `Vec<homecore::EntityEntry>`
|
||||
(ready for import).
|
||||
- `device_registry::load()` — `core.device_registry` → `Vec<DeviceImport>` (P1 diagnostic;
|
||||
full conversion P2).
|
||||
- `config_entries::load()` — `core.config_entries` → domain counts + integration names
|
||||
(the format is undocumented per §6 Q5; treated diagnostically).
|
||||
- `secrets::load_secrets()` — `secrets.yaml` → `HashMap<String, String>` (resolution P2).
|
||||
- `automations::load()` — `automations.yaml` → count + ID/alias list (conversion P2).
|
||||
|
||||
### 2.3 CLI (P1, shipped)
|
||||
|
||||
- `homecore-migrate inspect <ha-dir>` previews what will be migrated (entity/device/config
|
||||
counts, redacted secret/automation lists) (`src/cli.rs`, `src/main.rs`).
|
||||
- `import-entities` and `export-for-sidecar` are declared but their full behaviour is P2.
|
||||
|
||||
### 2.4 Structured errors (P1, shipped)
|
||||
|
||||
- `MigrateError` carries context (`path`, line/field) for I/O, JSON, YAML, missing-field,
|
||||
unsupported-schema-version, and entity-id parse failures (`src/lib.rs`).
|
||||
|
||||
### 2.5 Deferred to P2+ (NOT built — honestly labelled)
|
||||
|
||||
- Convert `config_entries` → HOMECORE plugin manifests.
|
||||
- Convert `automations.yaml` → `homecore-automation` YAML.
|
||||
- Side-by-side runtime mode (requires `homecore-recorder`, ADR-132; behind the `recorder`
|
||||
Cargo feature, currently a no-op stub).
|
||||
- `!secret` reference resolution in non-secrets YAML files.
|
||||
|
||||
### 2.6 Test evidence (as shipped)
|
||||
|
||||
- 19 tests (`cargo test -p homecore-migrate`), per the crate README badge.
|
||||
|
||||
## 3. Consequences
|
||||
|
||||
**Positive.**
|
||||
|
||||
- The trust boundary is explicit: unknown HA schema versions are rejected, not guessed, so a
|
||||
schema drift fails loudly instead of corrupting an imported home.
|
||||
- Reusing HA's own `.storage` and YAML formats means no intermediate export step; the tool
|
||||
reads a live HA install directly.
|
||||
- P1 `inspect` gives users a no-risk dry run before any write.
|
||||
|
||||
**Negative / honest limits.**
|
||||
|
||||
- P1 is a **scaffold**: only the entity registry is conversion-ready. Device registry,
|
||||
config-entry→plugin, automation, and secret-resolution conversions are P2 and **not yet
|
||||
built** — the Status field and crate docs say so.
|
||||
- The side-by-side recorder export depends on ADR-132 and is currently a feature-gated
|
||||
no-op.
|
||||
- Performance figures in the README (envelope parse < 5 ms, 1 000-entity load < 50 ms) are
|
||||
estimates, **needs verification** with a benchmark.
|
||||
|
||||
**Neutral.**
|
||||
|
||||
- This resolves only the *identity* of the migrate decision (134→165). The broader 6-way
|
||||
duplicate-number cleanup (incl. ADR-126's series-map registry row) is owner-gated.
|
||||
|
||||
## 4. Links
|
||||
|
||||
- Crate: `v2/crates/homecore-migrate/` — `Cargo.toml`, `README.md`, `src/lib.rs`,
|
||||
`src/storage.rs`, `src/storage_format/`, `src/entity_registry.rs`,
|
||||
`src/device_registry.rs`, `src/config_entries.rs`, `src/secrets.rs`,
|
||||
`src/automations.rs`, `src/cli.rs`, `src/main.rs`.
|
||||
- [ADR-126](ADR-126-ruview-native-ha-port-master.md) — HOMECORE master (series map: HOMECORE-MIGRATE).
|
||||
- [ADR-132](ADR-132-homecore-recorder-history-semantic-search.md) — HOMECORE-RECORDER (P2 side-by-side export target).
|
||||
- [ADR-134](ADR-134-csi-to-cir-time-domain-multipath.md) — First-Class CIR Support (the *unrelated* decision the crate was mistakenly citing).
|
||||
- [ADR-164](ADR-164-adr-corpus-gap-analysis.md) — gap analysis that surfaced this collision (Gap G3).
|
||||
- [Home Assistant `.storage` format](https://developers.home-assistant.io/docs/storage/).
|
||||
@@ -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) |
|
||||
@@ -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.1–2.3/2.6 implemented, WiFlow-STD MEASURED-EQUIVALENT ~96% PCK.
|
||||
5. **ADR-154/155/156/157** (beyond-SOTA sweeps) — `Proposed` but each describes fixes **already landed with revert-verified regression tests**.
|
||||
6. **ADR-024 (AETHER) / 027 (MERIDIAN) / 072 (WiFlow)** — `Proposed` but CLAUDE.md lists them Accepted and code references them as implemented.
|
||||
7. **ADR-017** — header Accepted but CLAUDE.md still calls it "Proposed" (inverse drift).
|
||||
8. **ADR-018** — `Proposed` but ADR-012 cites it as the working firmware/aggregator impl.
|
||||
|
||||
#### SEVERITY: HIGH — Status ahead of its dependencies (Accepted depends on Proposed)
|
||||
|
||||
- **ADR-032** Accepted → depends on Proposed 029/030/031.
|
||||
- **ADR-053** Accepted → depends on Proposed ADR-052.
|
||||
- **ADR-048** Accepted → depends on Proposed ADR-045.
|
||||
- **ADR-077** Accepted → depends on Proposed ADR-075/076.
|
||||
|
||||
#### SEVERITY: MEDIUM — Proposed-but-looks-abandoned (decisions that will likely never land)
|
||||
|
||||
Cluster heads where the whole chain is Proposed with zero implementation evidence:
|
||||
- **ADR-003/007/008/009/010** — RuVector child ADRs orphaned after parent ADR-002 was superseded by 016/017.
|
||||
- **ADR-105/106/107/108** — entire federation chain, none implemented.
|
||||
- **ADR-118/119/120/121/122/123** — entire BFLD chain, all ACs unchecked, tracking issues TBD.
|
||||
- **ADR-124/125/126/127/128/129/130/133** — HOMECORE/bridge chain, multi-quarter future-dated, all TBD.
|
||||
- **ADR-033** (remote-viewing), **ADR-042** (CHCI, superseded-in-intent by 153), **ADR-046** (Android TV), **ADR-049** (Python v1 legacy), **ADR-067** (RuVector v2.0.5 upgrade not adopted), **ADR-074** (SNN), **ADR-085** (RaBitQ expansion), **ADR-011** (Proposed-URGENT despite proof pipeline live).
|
||||
|
||||
#### SEVERITY: MEDIUM — Accepted but no implementation evidence (unverified "done")
|
||||
|
||||
- **ADR-034** (FieldView mobile app) — Accepted, no crate/dir in CLAUDE.md.
|
||||
- **ADR-044** (wifi-densepose-geo) — bare Accepted, no Date/Deciders, crate not in CLAUDE.md table.
|
||||
|
||||
#### Ranked actionable backlog (do in this order)
|
||||
|
||||
1. **Resolve 6 duplicate ADR numbers + 3 missing-header files** (CRITICAL — breaks the index/tooling). 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-136–145)** from Proposed → "Accepted — partial" — they have commit-pinned, test-backed Implementation Status notes. Highest ROI: one batch fixes the largest stale-status cluster.
|
||||
3. **Fix the status-graph inversions** (032/053/048/077 depend on Proposed parents; promote parents 029/030/031/045/052/075/076 to match their built reality, or downgrade the dependents).
|
||||
4. **Reconcile CLAUDE.md vs ADR headers** for 017, 024, 027, 072, 152 (doc says one thing, header another).
|
||||
5. **Triage the abandoned chains** (003/007–010, 105–108, 118–123, HOMECORE 124–133) — either mark `Rejected`/`Deferred` or open tracking issues; leaving ~50 Proposed-forever ADRs pollutes every gap analysis.
|
||||
6. **Verify or downgrade unsubstantiated Accepteds** (034, 044).
|
||||
|
||||
**Bottom line:** the corpus's #1 defect is not abandoned work — it's **bookkeeping drift**. ~24 ADRs are mislabeled Proposed while code+tests exist, 6 numbers collide, and 3 files lack Status headers entirely. The status field cannot currently be trusted as a source of truth for what is built.
|
||||
|
||||
---
|
||||
|
||||
## Lens 2: supersession-chains
|
||||
|
||||
I have all evidence needed. ADR-016 doesn't mention ADR-002 at all (only 014/015); ADR-017 discusses ADR-002 as a "correction" but never uses the word "supersede." This confirms the broken reciprocity. Final analysis below.
|
||||
|
||||
### Supersession & Lifecycle Integrity — ADR Corpus
|
||||
|
||||
Total declared supersession edges in census: **3** (ADR-002→016, ADR-002→017, ADR-052-tauri→054). All three were file-verified. Findings below; severity graded **CRITICAL / HIGH / MEDIUM / LOW**.
|
||||
|
||||
#### Reconstructed chains
|
||||
- **ADR-002** (RuVector RVF Integration Strategy) → superseded-by **ADR-016 + ADR-017** (dual realization). Self-declared `supersedes` on 016/017.
|
||||
- **ADR-052-tauri** (Tauri Desktop Frontend) → superseded-by **ADR-054** (declared in 052's `superseded_by`).
|
||||
- No other formal `supersedes`/`superseded_by` links exist. No cycles detected (the only multi-node graph, ADR-002→{016,017}, is a DAG; ADR-052→054 is a single edge). **No cycles — clean.**
|
||||
|
||||
#### Broken / asymmetric links
|
||||
|
||||
**1. ADR-002 → ADR-016 / ADR-017: one-directional, never reciprocated. (HIGH)**
|
||||
ADR-002 header declares "Superseded by [ADR-016] and [ADR-017]" (`docs/adr/ADR-002-ruvector-rvf-integration-strategy.md:4`). But neither successor claims it:
|
||||
- **ADR-016** (`ADR-016-ruvector-integration.md`) never mentions ADR-002 anywhere — its `## References` lists only ADR-014/015. It does not assert supersession; the census `supersedes:["ADR-002"]` for ADR-016 is **unsupported by the file**.
|
||||
- **ADR-017** (`ADR-017-ruvector-signal-mat-integration.md`) discusses ADR-002 only as a `## Correction to ADR-002 Dependency Strategy` (line 532) — corrects "fictional crate names" — but **never uses the word "supersede."** Census `supersedes:["ADR-002"]` is again file-unsupported.
|
||||
- Net: ADR-002 points up at two ADRs that don't point back. The supersession is asserted by the superseded ADR alone — backwards from convention, and unverifiable from the successors.
|
||||
|
||||
**2. ADR-002 partial-supersession leaves 5 orphaned children stranded. (HIGH)**
|
||||
ADR-002 is an umbrella whose children ADR-003, 007, 008, 009, 010 are still `Proposed`. ADR-016/017 only realize the *training/signal/MAT* integration points (mincut, attention, solver, etc.). The RVF-container (003), PQ-crypto (007), Raft consensus (008), WASM edge runtime (009), and witness-chains (010) decisions are **neither implemented nor formally superseded** — ADR-017:555 explicitly acknowledges 008/009 "described in ADR-002" are not carried forward. Marking the parent fully "Superseded" silently buries 5 live-but-abandoned child decisions. ADR-010's role is additionally filled de facto by ADR-028's witness-bundle without any supersession link.
|
||||
|
||||
**3. ADR-052-tauri → ADR-054: declared by predecessor, not acknowledged by successor. (HIGH)**
|
||||
Census records ADR-052-tauri `superseded_by:["ADR-054"]`. **ADR-054 (`ADR-054-desktop-full-implementation.md`) contains zero references to ADR-052** (grep for `ADR-052|replac|supersed` returns nothing). ADR-054 is titled "RuView Desktop **Full Implementation**" and is "in progress" — functionally it's the implementation plan *for* 052, not a replacement. The supersession edge is unconfirmed by the successor and arguably mis-modeled (an in-progress impl doesn't supersede its own design ADR).
|
||||
|
||||
#### Orphaned superseded ADRs still marked accepted/active
|
||||
**4. No classic orphan (superseded ADR still `Accepted`), but two soft variants: (MEDIUM)**
|
||||
- **ADR-052-tauri** is `Proposed` *and* `superseded_by ADR-054`, yet downstream ADR-053/055/056 (all `Accepted`) build on it and treat the desktop app as shipped (v0.3.0). A Proposed-and-superseded ADR anchoring three Accepted descendants is a lifecycle inconsistency: the live decision-of-record is ambiguous (052? 054? 056?).
|
||||
- **ADR-002** is correctly `Superseded`, so not an orphan — but ADR-038's roadmap census still counts it among 37 active ADRs, so stale references persist downstream.
|
||||
|
||||
#### De-facto supersessions never recorded (missing links) — MEDIUM
|
||||
These pairs behave as supersession in the corpus but carry **no** `supersedes`/`superseded_by` fields, so the chain graph understates reality:
|
||||
- **ADR-098 ⇄ ADR-099** (`MEDIUM`): ADR-098 **Rejected** midstream; ADR-099 revives its carve-outs ("Adopt midstream…"). A rejection partially reversed by a later Proposed ADR — neither links the other via supersession fields (only prose tension).
|
||||
- **ADR-063 → ADR-064**, **ADR-035 → ADR-023/036**, **ADR-042 → ADR-153**, **ADR-050-provisioning → ADR-060**, **ADR-117 retracts PyPI v1.1.0**, PQ chain **ADR-100 → ADR-109 / ADR-107→108**: each is "superseded in scope/intent" per flags but has empty supersession fields. Not broken links (none falsely asserted), but **missing** ones — the lifecycle graph is incomplete.
|
||||
|
||||
#### Cycles
|
||||
**None.** ADR-002→{016,017} and ADR-052→054 are acyclic; no ADR is reachable from its own successor.
|
||||
|
||||
#### Severity summary
|
||||
| # | Issue | Severity |
|
||||
|---|-------|----------|
|
||||
| 1 | ADR-002 supersession not reciprocated by ADR-016/017 (016 never mentions 002; 017 only "corrects") | HIGH |
|
||||
| 2 | ADR-002 children 003/007/008/009/010 stranded under a "fully superseded" parent | HIGH |
|
||||
| 3 | ADR-052→054 edge unacknowledged by ADR-054 (no reference); likely mis-modeled (impl, not replacement) | HIGH |
|
||||
| 4 | ADR-052-tauri Proposed+superseded yet anchors Accepted 053/055/056 | MEDIUM |
|
||||
| 5 | De-facto supersessions (098/099, 063/064, 042/153, 050/060, 035/023, 100/109) unrecorded | MEDIUM |
|
||||
| — | Cycles | none (clean) |
|
||||
|
||||
Highest-priority fix: add reciprocal `supersedes: ADR-002` language to ADR-016/017 (or downgrade the claim), and split ADR-002's status to "partially superseded" so children 003/007/008/009/010 aren't silently closed.
|
||||
|
||||
---
|
||||
|
||||
## Lens 3: contradictions
|
||||
|
||||
All findings confirmed against source. Here is the contradictions lens.
|
||||
|
||||
### Contradictions & Retractions Lens — ADR Corpus
|
||||
|
||||
The sweep (ADR-154–163) is itself a structured retraction layer: each "Beyond-SOTA / anti-AI-slop" ADR exists *because* an earlier accepted claim was found false. Findings graded **CRITICAL** (life-safety, security, or a published accuracy number that was meaningless) / **HIGH** (a capability/number retracted or directly contradicted) / **MEDIUM** (status or scope conflict) / **LOW** (cosmetic/doc drift).
|
||||
|
||||
#### A. Accepted/published claims later RETRACTED or REFUTED
|
||||
|
||||
**[CRITICAL] ADR-155 retracts every prior NN accuracy/TTA/proof claim.** ADR-155 §2.2 discloses `bin/train.rs` validated a *real* MM-Fi training run against a **synthetic** val set, and windows leak at stride-1 (~99% overlap) — *"any PCK it printed was meaningless on two counts."* §2.3: `rapid_adapt.rs` `contrastive_step`/`entropy_step` wrote a **fake gradient** (`grad += v * 0.01`) unrelated to the objective — every "TTA improves the metric" result was unsupported. §2.4: the deterministic proof **self-certified** (`generate_expected_hash` blessed whatever the pipeline emitted; PASS counted any loss decrease incl. 1e-9 float noise; missing hash defaulted to PASS). This retroactively voids accuracy claims made anywhere in the corpus that depended on the training/proof path prior to commit landing ADR-155.
|
||||
|
||||
**[CRITICAL] ADR-154 retracts the ADR-134 CIR coherence gate as live.** ADR-152/CLAUDE.md present CIR (ADR-134) as a contributing signal in the multistatic coherence gate. ADR-154 §2 proves it was **DEAD in production for every canonical frame**: the HT20 CIR estimator returns `SubcarrierMismatch` on all 56-tone canonical frames (`cir_gate_ht20_is_dead_on_canonical56`: 0 Ok / 8 mismatch), so `coherence = 0.7·freq + 0.3·dominant_tap_ratio` silently degraded to freq-only (`cir_gate_dead_ht20_equals_gate_off`, |Δ|<1e-9). Any ADR claiming CIR-enhanced coherence/ToF before this fix overstated reality.
|
||||
|
||||
**[CRITICAL] ADR-079 internal accuracy contradiction (self-flagged in census, confirmed).** Context states proxy PCK@20 = **2.5%** (lines 11, 25) and "10-20x improvement: 2.5% → 35%+". The baseline table (line 497) reports proxy PCK@20 = **35.3%** — i.e. the *baseline already equals the stated target* — while per-joint upper body (nose/shoulders/wrists) is **0%** (line 503). The headline 10–20x improvement number is therefore self-refuting against its own baseline table. CLAUDE.local.md adds the local-Windows attempt (#640) measured **0% PCK**. An Accepted ADR with three mutually inconsistent values for its own central metric.
|
||||
|
||||
**[HIGH] ADR-152 self-refutes one verified research claim (F4).** ADR-152 grades 25 claims 3-vote; §F4 records the "Espressif `esp_wifi_sensing` is **drop-in compatible with RuView nodes**" claim **REFUTED 0-3** (WiFi-6 parts use a different CSI acquisition config struct). ADR-110 ("ESP32-C6 Wi-Fi 6 CSI") and the CLAUDE.md hardware table treat C6/Wi-Fi-6 CSI as a smooth extension; ADR-152 also notes HE-CSI needs ESP-IDF ≥5.5 (v5.4 silently downconverts to HT). The "WiFlow-STD MEASURED-EQUIVALENT ~96% PCK@20" line in CLAUDE.md is *not* yet supported: §2.2/§F1 mark external pose numbers (incl. the 97.25% WiFlow-STD figure) **CLAIMED**, and §F1 explicitly forbids citing 97.25% as comparable until measurements (a)–(c) are run. CLAUDE.md asserting "MEASURED-EQUIVALENT" contradicts the ADR's own gating.
|
||||
|
||||
**[HIGH] ADR-150 retracts the implied cross-subject capability of the encoder line.** AETHER/MERIDIAN ADRs (024/027) and the foundation-encoder framing imply subject-invariant embeddings work. ADR-150 measures **81.63% in-domain vs ~11.6% leakage-free cross-subject** torso-PCK, and reports DANN **failed** (27.26%→27.54%, empirically ~0 gain) and bigger capacity *hurt* (transformer 24.8% < conv 27.3%). §1.1/§4 conclude the cross-subject acceptance gate "is **unlikely to be met without new multi-subject** data" — a direct retraction of the "more capacity / adversarial alignment solves cross-environment loss" premise underlying ADR-027.
|
||||
|
||||
**[HIGH] ADR-159 refutes the "never identified anyone" accusation but simultaneously retracts cog-person-count's marketing.** ADR-159 ships real SHA-pinned Candle models, but discloses person-count `training_class1_accuracy = 0.343` (presence-only, classes 0/1), and **renames** the Cargo description from "learned multi-person counter" → "presence detector + (data-gated) person count," clamping/`low_confidence`-flagging multi-occupant counts. This retracts ADR-103's "learned multi-person counter (SOTA WiFi CSI counting)" claim and ADR-104's count tool, which depended on it.
|
||||
|
||||
**[HIGH] ADR-161 retracts HOMECORE server security + functionality claims.** ADR-130 (HOMECORE-API, wire-compatible, Ed25519-JWT) implied a secured server. ADR-161 fixes a **CRITICAL WebSocket auth bypass** (any non-empty token accepted), "reply-theater" (WS responses computed then discarded), and documented-but-no-op automation — then ADR-162 enforces the ADR-161 deferrals (plugin Ed25519 sig verification, capability isolation, bounded RunModes that were "parsed-but-unenforced/unbounded-parallel"), retracting ADR-128/129's implied plugin-signing and automation guarantees.
|
||||
|
||||
**[MEDIUM] ADR-163 converts CLAIMED latency budgets to MEASURED — retracting prior budget citations.** ADR-160/159 cited wasm-edge/cog latency *budgets*. ADR-163 adds host benches and explicitly states the **ESP32/Xtensa-on-hardware figure remains UNMEASURED** — so any doc citing the device latency budget as achieved is unsupported.
|
||||
|
||||
**[MEDIUM] ADR-098 → ADR-099 partial reversal.** ADR-098 **Rejected** midstream as a system component; ADR-099 (Proposed) **adopts** midstream's temporal-compare (DTW) + temporal-attractor-studio as a parallel tap. Framed as "complementary," but it revives the exact carve-outs ADR-098 declined to integrate — a live decision conflict pending resolution.
|
||||
|
||||
**[MEDIUM] ADR-147 (OccWorld) self-retracts Cosmos.** The accepted ADR-147 title/decision was revised from "NVIDIA Cosmos WFM Integration" to OccWorld after a hardware finding (Cosmos needs 32.5 GB VRAM); Cosmos is retracted as primary. The companion ADR-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, 136–145, 150, 152, 154–157 all carry `Status: Proposed` while their own appended Implementation-Status notes (or downstream ADRs / CLAUDE.md) report them built+tested with commits. ADR-024/027 say Proposed; CLAUDE.md lists them Accepted; pose_tracker.rs already uses AETHER re-ID. Cosmetic but corpus-wide; it is the mechanism by which retracted/overstated claims survive (a green "built" note under a "Proposed" header is exactly where ADR-155's self-certifying proof hid).
|
||||
|
||||
#### Cited source files (absolute)
|
||||
- C:\Users\ruv\Projects\wifi-densepose\docs\adr\ADR-079-camera-ground-truth-training.md (lines 11/25/497/503 — 2.5% vs 35.3% vs 0%)
|
||||
- C:\Users\ruv\Projects\wifi-densepose\docs\adr\ADR-150-rf-foundation-encoder.md (81.63% vs 11.6%; DANN ~0)
|
||||
- C:\Users\ruv\Projects\wifi-densepose\docs\adr\ADR-152-wifi-pose-sota-2026-intake.md (F4 REFUTED 0-3; 97.25% CLAIMED-not-MEASURED)
|
||||
- C:\Users\ruv\Projects\wifi-densepose\docs\adr\ADR-154-signal-dsp-beyond-sota.md (§2 dead CIR gate)
|
||||
- C:\Users\ruv\Projects\wifi-densepose\docs\adr\ADR-155-nn-training-beyond-sota.md (§2.2–2.4 synthetic-val / fake gradient / self-certifying proof)
|
||||
- C:\Users\ruv\Projects\wifi-densepose\docs\adr\ADR-159-cognitum-appliance-beyond-sota.md (person-count 0.343; description renamed)
|
||||
|
||||
**Top-severity summary:** the four CRITICAL items (ADR-155 fake-gradient+synthetic-val+self-certifying proof; ADR-154 dead CIR gate; ADR-079 self-inconsistent PCK; ADR-161 WS auth bypass) are the corpus's load-bearing "AI-slop" admissions — each is an *accepted-or-shipped* surface whose stated accuracy/security/function was provably false until the sweep landed. Every accuracy number predating ADR-155 in any other ADR should be treated as CLAIMED, not MEASURED, until re-derived through the post-155 leak-free split.
|
||||
|
||||
---
|
||||
|
||||
## Lens 4: coverage-gaps
|
||||
|
||||
Confirmed — ADR-094 governs the pointcloud *viewer deployment* (proposed-only), not the crate's sensing-data-production contract. I have all evidence needed.
|
||||
|
||||
### Coverage Gaps — Crates/Capabilities vs Governing ADRs
|
||||
|
||||
Severity: **CRITICAL** (shipped code with no/broken governing ADR), **HIGH** (architect would expect an ADR, none exists), **MEDIUM** (governed only by a remediation/deploy ADR, no creation/architecture ADR), **LOW** (minor).
|
||||
|
||||
#### A. Shipped crates whose cited ADR does not exist (CRITICAL)
|
||||
|
||||
Two crates are built and in-tree but reference ADR numbers that point to *different* on-disk ADRs or to files that never existed (confirmed: no `ADR-131*.md` or `ADR-132*.md` exists; `ADR-134` on disk is CIR, not HOMECORE-MIGRATE):
|
||||
|
||||
- **`v2/crates/homecore-recorder`** — Cargo.toml header: *"SQLite state history + semantic search (ADR-132)"*. **No ADR-132 exists.** The HOMECORE series map (ADR-126 §4) lists ADR-132 HOMECORE-RECORDER as planned, but it was never written. A shipped persistence/history crate has zero governing decision record. **CRITICAL** — this is the recorder, the durable-state surface, ungoverned.
|
||||
- **`v2/crates/homecore-migrate`** — Cargo.toml header: *"Implements ADR-134 (HOMECORE-MIGRATE)"*. **On-disk ADR-134 is "First-Class CIR Support"** (census + glob confirm). ADR-129/126 also cite ADR-134 as HOMECORE-MIGRATE. The crate implements a migration tool from Python HA reading `.storage/*.json` — a data-integrity-sensitive importer — governed by a phantom ADR identity. **CRITICAL** (compounds the documented ADR-134 duplicate-number collision).
|
||||
|
||||
These are not stale-header issues like the ADR-136..146 cluster (where the ADR exists and is just marked Proposed); here the cited governing ADR **is absent or is a different decision**.
|
||||
|
||||
#### B. Shipped crates with NO governing ADR at all (HIGH)
|
||||
|
||||
- **`v2/crates/wifi-densepose-engine`** — *"streaming-engine integration layer — composes the ADR-135..146 building blocks into one trust-traceable pipeline cycle."* It composes ~12 ADRs' outputs into the live pipeline-cycle aggregate, but **no ADR governs the composition/orchestration contract itself** (ordering, back-pressure, the "one pipeline cycle" boundary). ADR-136 defines frame contracts/stages but not the integrator crate. An architect would expect an ADR for the seam that wires 135–146 onto the live 20 Hz path — exactly the "integration glue not yet on live path" caveat repeated across ADR-136..146. **HIGH.**
|
||||
|
||||
#### C. Capabilities governed only by a remediation/deploy ADR — no creation/architecture ADR (MEDIUM)
|
||||
|
||||
- **`v2/crates/wifi-densepose-wasm-edge` (~70 edge skills)** — The only ADRs touching it are **ADR-160** (honest *relabeling*/soundness cleanup) and **ADR-163** (latency *measurement*). Both are anti-slop remediation ADRs that presuppose ~70 skills already shipped. There is **no creation/architecture ADR** defining the skill taxonomy, ABI, event-ID allocation, or budget tiers for this crate. (Contrast ADR-041, which *does* catalog the 60-module registry — but for the ESP32/WASM3 on-device path of ADR-040, a different artifact.) A whole ~70-module crate's design rationale lives nowhere. **MEDIUM-HIGH.**
|
||||
- **`v2/crates/wifi-densepose-occworld-candle`** — *"OccWorld TransVQVAE inference ported to Candle (Rust-native, no Python IPC)."* ADR-147 (OccWorld) decided a **Python-subprocess** thin client and explicitly deferred a Rust backend swap to "Phase B / RoboOccWorld." A native Candle reimplementation is a material architecture change (new dep surface, no IPC, weight-loading path) that **no ADR records the decision to build now**. **MEDIUM.**
|
||||
- **`v2/crates/wifi-densepose-pointcloud`** — ADR-094 governs only the *GitHub-Pages viewer deployment* (Proposed). The crate as a **point-cloud data-production/format contract** (what it emits, schema, real-data-stream toggle wiring) has no governing decision beyond the demo-deploy doc. **MEDIUM.**
|
||||
- **`v2/crates/homecore-hap`** — header cites ADR-125 P1 scaffold; ADR-125 (Apple Home HAP bridge) exists and covers it. **Governed — no gap.** (Listed to scope out the false positive.)
|
||||
- **`v2/crates/wifi-densepose-geo`** — governed by ADR-044 (geospatial). Governed, but ADR-044 is a bare "Accepted" with no implementation evidence and is cross-referenced incorrectly by ADR-052 (cites ADR-044 for provisioning). **LOW** (governed but the ADR itself is thin).
|
||||
|
||||
#### D. Decision areas an architect would expect an ADR for, but none exists (HIGH)
|
||||
|
||||
1. **Persistence/storage strategy for HOMECORE state history** — `homecore-recorder` ships SQLite with an "HA-compat schema," but no ADR decides SQLite-vs-alternatives, retention, or the semantic-search index. Recorder is the durability backbone; an unrecorded storage choice is a classic missing-ADR. **HIGH** (ties to gap A).
|
||||
2. **Python-HA → HOMECORE migration/import contract** — `homecore-migrate` reads foreign `.storage` JSON (untrusted input, schema-drift risk) with no governing ADR (the cited one is CIR). Migration correctness and trust boundary are exactly what an ADR should pin. **HIGH** (ties to gap A).
|
||||
3. **The streaming-engine *integrator* contract** (`wifi-densepose-engine`) — see B. **HIGH.**
|
||||
4. **Cross-crate workspace dependency/publishing ADR** — CLAUDE.md lists a hand-maintained 12-step publishing order and a 15-crate table, but the workspace now has **38 crates** (glob count) including ungoverned ones (engine, worldmodel, worldgraph, occworld-candle, geo, wasm-edge, homecore-*, cog-*, ruview-swarm, pointcloud, nvsim-server, desktop). No ADR governs crate-graph topology / publish boundaries at this scale — the publishing list in CLAUDE.md is already stale against reality. **MEDIUM-HIGH.**
|
||||
5. **No ADR ties the streaming-engine (`engine`) to the cog/appliance deploy surface** — ADR-101/102/159 govern cogs; ADR-136..146 govern the engine; nothing decides how the trust-traceable engine output becomes a deployed cog. The seam between the two largest subsystems is ungoverned. **MEDIUM.**
|
||||
|
||||
#### E. Scoped-out false positives (verified governed)
|
||||
|
||||
- `wifi-densepose-worldmodel` → ADR-147 (OccWorld bridge). Governed.
|
||||
- `wifi-densepose-worldgraph` → ADR-139. Governed.
|
||||
- `cog-ha-matter` → ADR-116; `cog-person-count` → ADR-103; `cog-pose-estimation` → ADR-101. Governed.
|
||||
- `ruview-swarm` → ADR-148. `nvsim`/`nvsim-server` → ADR-089/092. `wifi-densepose-bfld` → ADR-118–123/141. `wifi-densepose-calibration` → ADR-151. All governed.
|
||||
- `wifi-densepose-desktop` → ADR-052/054 (contested status, but an ADR exists). Not a coverage gap (it's a status-drift issue, out of this lens's scope).
|
||||
|
||||
#### Top-priority remediation (concrete)
|
||||
1. Write **ADR-132 HOMECORE-RECORDER** (or renumber) so the shipped `homecore-recorder` SQLite/history crate has a real governing decision — **CRITICAL**.
|
||||
2. Resolve the **ADR-134 identity collision** and create a real **HOMECORE-MIGRATE** ADR for `homecore-migrate` (untrusted-import trust boundary) — **CRITICAL**.
|
||||
3. Write a **streaming-engine integrator ADR** for `wifi-densepose-engine` (the 135–146 composition contract / live-path seam) — **HIGH**.
|
||||
4. Write a **creation/architecture ADR for `wifi-densepose-wasm-edge`** (taxonomy/ABI/event-IDs) — currently only remediation ADRs (160/163) exist — **MEDIUM-HIGH**.
|
||||
5. Add an ADR for the **`occworld-candle` Rust-native backend swap** (ADR-147 only blessed the Python-subprocess path) — **MEDIUM**.
|
||||
|
||||
Evidence files: `v2/crates/homecore-recorder/Cargo.toml`, `v2/crates/homecore-migrate/Cargo.toml`, `v2/crates/wifi-densepose-engine/Cargo.toml`, `v2/crates/wifi-densepose-occworld-candle/Cargo.toml`, `docs/adr/ADR-134-csi-to-cir-time-domain-multipath.md` (CIR, not MIGRATE), `docs/adr/ADR-160-edge-skill-library-honest-labeling.md`, `docs/adr/ADR-094-pointcloud-github-pages-deployment.md`; absence confirmed via glob: no `ADR-131*`, no `ADR-132*`.
|
||||
|
||||
---
|
||||
|
||||
## Lens 5: data-hardware-gated
|
||||
|
||||
Evidence confirmed. I have enough to write the section.
|
||||
|
||||
### Open / Gated Decisions — The Real Backlog (data/hardware-gated, in-progress, or unresolved)
|
||||
|
||||
Severity = how load-bearing the unresolved decision is to a shipped claim. Sweep coverage = whether ADR-154–163 touched it.
|
||||
|
||||
#### CRITICAL — life-safety or shipped-claim surface, still gated
|
||||
|
||||
**ADR-079 — Camera Ground-Truth Training Pipeline.** *Accepted, but core decision unvalidated.* P7–P9 (real paired-data collection, training, cross-room LoRA) are **Pending** (file lines 476–478). Blocker: a real synchronized camera+ESP32 paired-capture session and GPU training run — neither done. The ADR's own baseline table is self-contradictory: text says proxy PCK@20=2.5% (lines 11, 25) yet line 497 reports 35.3% (the *target*) with line 503 confessing **upper-body joints at 0%** — the proxy has no real spatial signal. CLAUDE.local.md records the local-Windows attempt (#640) at 0% PCK. The fleet (ruvultra RTX 5080, cognitum-seed-1) is the unblock, but the decision is accepted-on-paper, not proven. **Sweep: NOT addressed** — 154–163 never touch the camera-teacher path. Real open backlog item.
|
||||
|
||||
**ADR-158 — MAT/World-Model sweep (life-safety).** *Accepted/implemented for the correctness fixes, but capability remains DATA-GATED.* The sweep honestly fixed the dangerous bugs (unified the two divergent triage engines so survivor count can't inflate from repeat detection — lines 46–56, 184–186), but explicitly grades the actual capabilities as unproven: **RF-through-rubble survivor detection = DATA-GATED** (needs instrumented rubble trials, line 37); **learned multi-person counter = DATA-GATED** on labelled multi-occupant CSI (lines 41, 173); PicoScenes/Intel-5300/Atheros live capture DATA-GATED on NIC/driver hardware (lines 177–179). **Sweep: addressed the slop, honestly deferred the capability.** This is the model the rest should follow — code is real, accuracy claim is withheld pending absent hardware. Severity CRITICAL because it is the life-safety surface; the residual gate is acceptable and labeled.
|
||||
|
||||
#### HIGH — shipped/benchmarked claim with an explicit residual gate
|
||||
|
||||
**ADR-152 — WiFi-Pose SOTA 2026 Intake.** Status header stale (says Proposed; commits + line 58 report §2.1–2.3/2.6 implemented and WiFlow-STD **MEASURED-EQUIVALENT 96.09% PCK@20** on RTX 5080). Residual gates are real and disclosed: (1) **1 of 25 verified claims REFUTED 0-3** — "ESP WiFi-6 drop-in compatible with RuView nodes" is false (WiFi-6 parts use a different CSI acquisition struct, lines 31, 123); (2) external pose numbers (PerceptAlign −60% cross-domain; UNSW MAE pose transfer) remain **CLAIMED until reproduced on our hardware** (lines 21, 27, 119–122); (3) measurement (b)/(c) open — line 111 confirms pretrained init gives optimization transfer but **no feature transfer**, and no run beat a mean-pose baseline on single-subject data, so **no CSI→pose capability is citable** until multi-subject/multi-position data exists. Blocker: heterogeneous multi-subject CSI dataset (data-gated, per ADR-150 §F3). **Sweep: this ADR *is* the prove-everything discipline applied to research intake** — gates labeled, not buried.
|
||||
|
||||
**ADR-072 / ADR-150 — WiFlow pose + RF foundation encoder.** ADR-072 >80% PCK@20 target unverifiable without camera labels (resolved-path via ADR-079, itself gated above). ADR-150 cites measured 81.63% in-domain vs **~11.6% leakage-free cross-subject** — the cross-subject collapse is real and the stated lever (ADR-152 F3) is *more heterogeneous data*, not capacity. Blocker: multi-subject/room dataset + libtorch GPU training. **Sweep: NOT directly addressed** (155 fixed PCK/OKS metric-integrity plumbing, which makes these numbers *trustworthy* but doesn't close the data gap).
|
||||
|
||||
#### HIGH — security/privacy decisions still Proposed-only (no sweep touched the gate itself)
|
||||
|
||||
**ADR-080 — QE Remediation.** Tracks unfixed security HIGH findings (X-Forwarded-For bypass, leaked stack traces, JWT-in-URL CWE-598), gate FAILED, status Proposed, no done-marking. The HOMECORE sweep (ADR-161/162) fixed *HOMECORE*'s WS-auth bypass and plugin signing — a **different** server boundary. **Sweep: did NOT cover ADR-080's sensing-server findings.** Genuine open security backlog.
|
||||
|
||||
**ADR-105→109, ADR-118–125 (BFLD/federation/fabric chains).** Entire federation chain (105–109) and BFLD surface (118–125) are Proposed-only, all ACs unchecked, several "tracking issue TBD." Blockers: KIT BFId dataset (ADR-121 calibration), Pi5/Nexmon CBFR capture hardware (ADR-123 — ESP32 *structurally cannot* sniff CBFR), Soul-Signature + cog-ha-matter dependencies (ADR-122/125). **Sweep: NOT addressed** — 154–163 stop at HOMECORE/MAT/cog/edge; the privacy control *plane* (ADR-141, built) exists but the BFLD *capture/scoring* chain it would gate does not. Backlog, honestly gated by absent hardware.
|
||||
|
||||
#### MEDIUM — hardware-gated, honestly deferred BY the sweep (lowest risk)
|
||||
|
||||
**ADR-163 — Edge-latency measurement.** *Accepted/implemented* for host benches, but the **ESP32/Xtensa on-hardware `process_frame` figure is explicitly UNMEASURED / PENDING (hardware)** (lines 31–32, 79–83, 92–93). Blocker: `wasm32-unknown-unknown` built + flashed to ESP32-S3 and timed on-device; host x86_64 median is "an upper bound on algorithm work, not the ESP32 number." This is the **gold-standard deferral**: the gate is stated everywhere, no claim overreaches. **Sweep: this *is* a sweep ADR honestly deferring its own residual.**
|
||||
|
||||
**ADR-160 — wasm-edge skill labeling.** Medical/affect/weapon capabilities explicitly **NOT validated** — relabelled/disclaimed/feature-gated rather than implemented, reference-standard-gated. **Sweep: addressed by relabeling, capability honestly deferred.**
|
||||
|
||||
**ADR-110 — ESP32-C6 firmware.** Implemented, but HE-CSI requires ESP-IDF ≥5.5 (v5.4 silently downconverts to HT) — capability hardware/toolchain-gated per WITNESS §B1. Not a sweep target; gate is a noted hardware constraint, not slop.
|
||||
|
||||
**Other purely hardware/data-gated Proposed decisions (no sweep involvement, no overreach):** ADR-023 (paired data+GPU), ADR-027/MERIDIAN (multi-env data), ADR-042 CHCI (custom PCB/TCXO — largely superseded by 153), ADR-063/064 (ESP32-C6+MR60BHA2 mmWave), ADR-065/066 (live Cognitum Seed deploy), ADR-070 (live 2-node+Seed capture), ADR-073/078 (multi-AP mesh deployment), ADR-083 (pending field evidence), ADR-086 (real-deployment suppression rates), ADR-091 (COTS sub-THz + ITAR-clear use case), ADR-103 (labelled count data), ADR-113 (Fresnel-sim, not hardware-validated), ADR-114 (real NV-diamond device), ADR-134/135 (COM9/COM12 hardware-test feature), ADR-143 v2 (7-day fleet validation campaign, dead-code until then), ADR-144 (no UWB radio in fleet).
|
||||
|
||||
#### Cross-cutting finding
|
||||
The sweep (ADR-154–163) is **narrowly scoped**: it hardened MAT (158), Cognitum cogs (159), wasm-edge (160), HOMECORE server+plugins (161/162), and latency debt (163) — converting CLAIMED→MEASURED or DATA-GATED with honest labels. It **did not** touch the two largest *capability* gaps: the **camera-teacher training validation (ADR-079/072/150)** and the **federation/BFLD privacy chains (105–109, 118–125)** — both remain data/hardware-gated and Proposed-only. The single hard contradiction worth flagging to a human: **ADR-079's baseline table reports the target (35.3%) as if achieved while the prose and #640 evidence say 2.5%/0%** — that is the one place a reader could mistake an aspiration for a measurement.
|
||||
@@ -0,0 +1,147 @@
|
||||
#!/usr/bin/env bash
|
||||
# prove.sh — one-command reproduction harness for RuView / wifi-densepose.
|
||||
#
|
||||
# Mission: this project has been publicly accused of being "AI slop / fake."
|
||||
# The answer is reproducibility. Clone the repo, run THIS script, and every
|
||||
# headline claim is either VERIFIED on your machine (MEASURED) or printed as
|
||||
# "CLAIMED — not reproduced here (why)". Nothing is asserted without a command.
|
||||
#
|
||||
# Usage:
|
||||
# bash scripts/prove.sh # core gate + anti-slop assertion tests
|
||||
# bash scripts/prove.sh --full # also run the tch/GPU/dataset-gated claims
|
||||
#
|
||||
# Exit code 0 only if every NON-gated claim passes. Gated claims never fail the
|
||||
# run; they print exactly what they need (libtorch, a GPU, a dataset) so you can
|
||||
# reproduce them yourself.
|
||||
set -uo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
FULL=0; [ "${1:-}" = "--full" ] && FULL=1
|
||||
|
||||
pass=0; fail=0; skip=0
|
||||
PASS(){ echo " [PASS] $1"; pass=$((pass+1)); }
|
||||
FAIL(){ echo " [FAIL] $1"; fail=$((fail+1)); }
|
||||
SKIP(){ echo " [CLAIMED — not reproduced here] $1"; skip=$((skip+1)); }
|
||||
hr(){ echo "------------------------------------------------------------"; }
|
||||
|
||||
echo "RuView / wifi-densepose — PROOF harness"
|
||||
echo "repo: $ROOT"
|
||||
echo "date: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
hr
|
||||
|
||||
# ── 1. HARD GATE: Rust workspace tests (no native libs required) ────────────
|
||||
echo "[1] Rust workspace tests (cargo test --workspace --no-default-features)"
|
||||
if command -v cargo >/dev/null 2>&1; then
|
||||
if ( cd v2 && cargo test --workspace --no-default-features ) > /tmp/prove_ws.log 2>&1; then
|
||||
n=$(grep -oE "result: ok\. [0-9]+ passed" /tmp/prove_ws.log | grep -oE "[0-9]+" | awk '{s+=$1} END {print s}')
|
||||
PASS "workspace tests green — ${n:-?} passed, 0 failed (CARGO exit 0)"
|
||||
else
|
||||
FAIL "workspace tests — see /tmp/prove_ws.log (grep 'test result: FAILED')"
|
||||
fi
|
||||
else
|
||||
SKIP "cargo not installed — install Rust to run the workspace gate"
|
||||
fi
|
||||
hr
|
||||
|
||||
# ── 2. HARD GATE: deterministic Python pipeline proof (SHA-256) ─────────────
|
||||
echo "[2] Deterministic CSI pipeline proof (archive/v1/data/proof/verify.py)"
|
||||
if command -v python >/dev/null 2>&1; then
|
||||
if python archive/v1/data/proof/verify.py > /tmp/prove_py.log 2>&1 && grep -q "VERDICT: PASS" /tmp/prove_py.log; then
|
||||
PASS "Python proof VERDICT: PASS (bit-exact SHA-256 of reference features)"
|
||||
else
|
||||
FAIL "Python proof — see /tmp/prove_py.log"
|
||||
fi
|
||||
else
|
||||
SKIP "python not installed — install Python 3.10+ to run the deterministic proof"
|
||||
fi
|
||||
hr
|
||||
|
||||
# ── 3. ANTI-SLOP ASSERTION TESTS — each encodes a headline MEASURED claim ────
|
||||
# Format: claim_test <crate> <test-name-filter> <human claim> [extra cargo args]
|
||||
claim_test(){
|
||||
local crate="$1" filt="$2" desc="$3"; shift 3
|
||||
if ! command -v cargo >/dev/null 2>&1; then SKIP "$desc (cargo missing)"; return; fi
|
||||
if ( cd v2 && cargo test -p "$crate" "$@" "$filt" ) > /tmp/prove_claim.log 2>&1 \
|
||||
&& grep -qE "test result: ok\. [1-9]" /tmp/prove_claim.log; then
|
||||
PASS "$desc"
|
||||
else
|
||||
# distinguish "didn't run" (feature/lib gated) from real failure
|
||||
if grep -qE "0 passed|filtered out;? finished|error: no test target" /tmp/prove_claim.log \
|
||||
&& ! grep -q "test result: FAILED" /tmp/prove_claim.log; then
|
||||
SKIP "$desc (test gated/absent in this build — see /tmp/prove_claim.log)"
|
||||
else
|
||||
FAIL "$desc — see /tmp/prove_claim.log"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# Variant for workspace-excluded crates (e.g. wasm-edge): run from the crate dir.
|
||||
claim_test_indir(){
|
||||
local dir="$1" filt="$2" desc="$3"; shift 3
|
||||
if ! command -v cargo >/dev/null 2>&1; then SKIP "$desc (cargo missing)"; return; fi
|
||||
if ( cd "$dir" && cargo test "$@" "$filt" ) > /tmp/prove_claim.log 2>&1 \
|
||||
&& grep -qE "test result: ok\. [1-9]" /tmp/prove_claim.log; then
|
||||
PASS "$desc"
|
||||
else
|
||||
if grep -qE "0 passed|error: no test target" /tmp/prove_claim.log \
|
||||
&& ! grep -q "test result: FAILED" /tmp/prove_claim.log; then
|
||||
SKIP "$desc (test gated/absent — see /tmp/prove_claim.log)"
|
||||
else
|
||||
FAIL "$desc — see /tmp/prove_claim.log"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
echo "[3] Anti-slop assertion tests (each fails on the pre-fix code)"
|
||||
echo " ADR-156 §2.2 — fusion crafted-input DoS panics are closed:"
|
||||
claim_test wifi-densepose-ruvector triangulation_out_of_range_index_returns_none_no_panic \
|
||||
"crafted out-of-range index returns None, no panic" --no-default-features
|
||||
|
||||
echo " Soul Signature §3.6 — the audit's 'identity does not lock' claim, MEASURED:"
|
||||
claim_test wifi-densepose-bfld cardiac_alone_cannot_separate_identity_matches_audit \
|
||||
"WiFi-only cardiac+respiratory channels CANNOT separate two people (gap ~0.0005)"
|
||||
|
||||
echo " OccWorld — predict() is real (input-dependent), not random:"
|
||||
claim_test wifi-densepose-occworld-candle predict_is_deterministic_for_same_input \
|
||||
"same occupancy input -> identical prediction (no randn stub)"
|
||||
|
||||
echo " ADR-159 A1 — pose runtime actually emits under its own default config:"
|
||||
claim_test cog-pose-estimation default_config_emits_frames_with_real_model \
|
||||
"default install emits pose frames (confidence >= min_confidence)" --no-default-features
|
||||
|
||||
echo " ADR-159 A2 — person-count flags untrained classes (no count inflation):"
|
||||
claim_test cog-person-count untrained_class_argmax_is_flagged_low_confidence \
|
||||
"argmax on an untrained class is flagged low_confidence" --no-default-features
|
||||
|
||||
echo " ADR-160 A1 — medical edge skills carry a not-a-medical-device disclaimer:"
|
||||
# wasm-edge is a workspace-excluded crate → run from its own directory.
|
||||
claim_test_indir v2/crates/wifi-densepose-wasm-edge a1_med_modules_have_clinical_disclaimer \
|
||||
"every med_* module carries the experimental/non-clinical disclaimer" --features std
|
||||
hr
|
||||
|
||||
# ── 4. DATA/HARDWARE-GATED claims — honestly NOT reproduced by this script ───
|
||||
echo "[4] DATA/HARDWARE-GATED claims (reproduce instructions, not asserted here)"
|
||||
if [ "$FULL" = "1" ]; then
|
||||
echo " (--full) attempting the gated claims; missing prereqs are reported, not failed:"
|
||||
claim_test wifi-densepose-mat test_identical_vitals_no_location_dedup_to_one \
|
||||
"ADR-158 §2 survivor dedup 3->1 (count-inflation fix)" --features mat
|
||||
else
|
||||
SKIP "WiFlow-STD ~96% PCK@20 reproduction — needs an NVIDIA GPU + MM-Fi dataset; see benchmarks/wiflow-std/RESULTS.md"
|
||||
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
|
||||
|
||||
# ── verdict ──────────────────────────────────────────────────────────────────
|
||||
echo "VERDICT: $pass verified · $fail failed · $skip claimed-not-reproduced-here"
|
||||
if [ "$fail" -eq 0 ]; then
|
||||
echo "RESULT: PASS — every reproducible claim verified on this machine."
|
||||
exit 0
|
||||
else
|
||||
echo "RESULT: FAIL — $fail claim(s) did not reproduce. See the /tmp/prove_*.log files."
|
||||
exit 1
|
||||
fi
|
||||
Generated
+17
-9
@@ -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",
|
||||
@@ -3472,6 +3474,7 @@ dependencies = [
|
||||
"axum",
|
||||
"chrono",
|
||||
"dashmap",
|
||||
"futures-util",
|
||||
"homecore",
|
||||
"http-body-util",
|
||||
"hyper 1.8.1",
|
||||
@@ -3479,6 +3482,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
"tower 0.5.3",
|
||||
"tower-http",
|
||||
"tracing",
|
||||
@@ -3552,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",
|
||||
@@ -10933,7 +10941,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wifi-densepose-hardware"
|
||||
version = "0.3.0"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"approx",
|
||||
"byteorder",
|
||||
@@ -10953,7 +10961,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wifi-densepose-mat"
|
||||
version = "0.3.0"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"approx",
|
||||
@@ -10985,7 +10993,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wifi-densepose-nn"
|
||||
version = "0.3.0"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"candle-core 0.4.1",
|
||||
@@ -11039,7 +11047,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wifi-densepose-ruvector"
|
||||
version = "0.3.1"
|
||||
version = "0.3.2"
|
||||
dependencies = [
|
||||
"approx",
|
||||
"criterion",
|
||||
@@ -11059,7 +11067,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wifi-densepose-sensing-server"
|
||||
version = "0.3.1"
|
||||
version = "0.3.2"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"chrono",
|
||||
@@ -11093,7 +11101,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wifi-densepose-signal"
|
||||
version = "0.3.2"
|
||||
version = "0.3.3"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"criterion",
|
||||
@@ -11120,7 +11128,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wifi-densepose-train"
|
||||
version = "0.3.1"
|
||||
version = "0.3.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"approx",
|
||||
@@ -11158,7 +11166,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wifi-densepose-vitals"
|
||||
version = "0.3.0"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"serde",
|
||||
@@ -11190,7 +11198,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wifi-densepose-wifiscan"
|
||||
version = "0.3.0"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"tokio",
|
||||
|
||||
@@ -5,7 +5,7 @@ edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Cognitum Cog: Home Assistant + Matter integration for the Seed (ADR-116). Wraps ADR-115's HA-DISCO + HA-MIND publisher as a Seed-installable artifact with mDNS, embedded broker, RuVector-backed thresholds, and Ed25519 witness."
|
||||
description = "Cognitum Cog: Home Assistant (MQTT) integration for the Seed (ADR-116). Wraps ADR-115's HA-DISCO + HA-MIND publisher as a Seed-installable artifact with mDNS, embedded broker, RuVector-backed thresholds, and Ed25519 witness. LAN-only (no TLS); Matter Bridge commissioning is deferred to v0.8 and not yet implemented."
|
||||
|
||||
[[bin]]
|
||||
name = "cog-ha-matter"
|
||||
|
||||
@@ -5,7 +5,7 @@ edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Cognitum Cog: learned multi-person counter from WiFi CSI (ADR-103). Replaces the PR #491 slot heuristic with a Candle-based count head + Stoer-Wagner multi-node fusion."
|
||||
description = "Cognitum Cog: WiFi-CSI presence detector + (data-gated) person count (ADR-103). Candle-based head trained on classes 0/1 (presence); the 8-class count head ships but counts above the trained range are flagged low_confidence. Stoer-Wagner multi-node fusion."
|
||||
|
||||
[[bin]]
|
||||
name = "cog-person-count"
|
||||
@@ -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);
|
||||
@@ -24,6 +24,17 @@ pub const INPUT_TIMESTEPS: usize = 20;
|
||||
/// Count classification over {0, 1, ..., 7} persons.
|
||||
pub const COUNT_CLASSES: usize = 8;
|
||||
|
||||
/// Highest class the shipped `count_v1` weights were actually **trained** on.
|
||||
///
|
||||
/// The count head has 8 logits, but `count_train_results.json` only has support
|
||||
/// for classes 0 and 1 (`per_class_accuracy` keys are `"0"` and `"1"`). The model
|
||||
/// is a presence detector (0 vs ≥1 person), **not** a calibrated multi-occupant
|
||||
/// counter. An argmax landing on classes 2..=7 is out-of-distribution: the logits
|
||||
/// there were never supervised against labelled data. We flag such outputs
|
||||
/// `low_confidence` so downstream consumers don't trust a fabricated headcount.
|
||||
/// (Multi-occupant *accuracy* is DATA-GATED — not fabricated here.)
|
||||
pub const MAX_TRAINED_CLASS: usize = 1;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CsiWindow {
|
||||
pub data: Vec<f32>,
|
||||
@@ -45,6 +56,23 @@ impl CountPrediction {
|
||||
self.probs.iter().all(|v| v.is_finite()) && self.confidence.is_finite()
|
||||
}
|
||||
|
||||
/// True when the maximum-likelihood class is beyond what the shipped weights
|
||||
/// were trained on ([`MAX_TRAINED_CLASS`]). Such a prediction is out-of-
|
||||
/// distribution — the count head's logits for classes 2..=7 were never
|
||||
/// supervised, so the headcount is not trustworthy. Surfaced as the
|
||||
/// `low_confidence` field on the `person.count` event (honest-clip pattern).
|
||||
pub fn is_low_confidence(&self) -> bool {
|
||||
self.argmax() > MAX_TRAINED_CLASS
|
||||
}
|
||||
|
||||
/// Argmax clamped to [`MAX_TRAINED_CLASS`]. When the raw argmax is an
|
||||
/// untrained class we clamp the *reported* count to the highest trained
|
||||
/// class rather than emit a fabricated multi-occupant headcount. The raw
|
||||
/// distribution is still available in `probs` for diagnostics.
|
||||
pub fn clamped_count(&self) -> usize {
|
||||
self.argmax().min(MAX_TRAINED_CLASS)
|
||||
}
|
||||
|
||||
/// Maximum-likelihood class.
|
||||
pub fn argmax(&self) -> usize {
|
||||
let mut best_i = 0;
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
pub mod fusion;
|
||||
pub mod inference;
|
||||
pub mod manifest;
|
||||
pub mod publisher;
|
||||
pub mod runtime;
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ use cog_person_count::{
|
||||
publisher, COG_ID, COG_VERSION,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Parser)]
|
||||
@@ -83,19 +82,11 @@ fn cmd_version() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
|
||||
fn cmd_manifest() -> Result<(), Box<dyn std::error::Error>> {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&json!({
|
||||
"id": COG_ID,
|
||||
"version": COG_VERSION,
|
||||
"binary_url": Value::Null,
|
||||
"binary_bytes": Value::Null,
|
||||
"binary_sha256": Value::Null,
|
||||
"binary_signature": Value::Null,
|
||||
"installed_at": Value::Null,
|
||||
"status": Value::Null,
|
||||
}))?
|
||||
);
|
||||
// Emit the real, signed manifest embedded at compile time (ADR-159 §A4) —
|
||||
// not the old hollow null skeleton. Parse-then-emit so a malformed embedded
|
||||
// artifact fails loudly and the output is canonical JSON.
|
||||
let spec = cog_person_count::manifest::embedded_manifest_value()?;
|
||||
println!("{}", serde_json::to_string_pretty(&spec)?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
//! Embedded signed cog manifest (ADR-100 §"manifest.json", ADR-159 §A4).
|
||||
//!
|
||||
//! The `cog-person-count manifest` subcommand emits the **real, signed**
|
||||
//! manifest the release pipeline produced — byte-for-byte the artifact served
|
||||
//! from GCS, with a real `binary_sha256`, `weights_sha256`, Ed25519
|
||||
//! `binary_signature`, and honest `build_metadata` (e.g. `training_class1_accuracy
|
||||
//! = 0.343`, not inflated). The previous implementation printed a hollow
|
||||
//! skeleton with `binary_sha256: null`, which made the CLI look unsigned even
|
||||
//! though the signed manifest existed on disk.
|
||||
//!
|
||||
//! The matching manifest for the build's target arch is selected via `cfg!`.
|
||||
|
||||
/// Real signed manifest for `x86_64-unknown-linux-gnu`.
|
||||
pub const MANIFEST_X86_64: &str =
|
||||
include_str!("../cog/artifacts/manifests/x86_64/manifest.json");
|
||||
|
||||
/// Real signed manifest for `aarch64`/`arm` (the Seed appliance).
|
||||
pub const MANIFEST_ARM: &str = include_str!("../cog/artifacts/manifests/arm/manifest.json");
|
||||
|
||||
/// The embedded signed manifest matching the build's target arch.
|
||||
pub fn embedded_manifest_str() -> &'static str {
|
||||
if cfg!(any(target_arch = "aarch64", target_arch = "arm")) {
|
||||
MANIFEST_ARM
|
||||
} else {
|
||||
MANIFEST_X86_64
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse the embedded manifest into canonical JSON. Returns an error if the
|
||||
/// embedded artifact is malformed (so the CLI fails loudly rather than printing
|
||||
/// garbage).
|
||||
pub fn embedded_manifest_value() -> Result<serde_json::Value, serde_json::Error> {
|
||||
serde_json::from_str(embedded_manifest_str())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// ADR-159 §A4 — the embedded manifest the CLI emits must carry a real
|
||||
/// `binary_sha256` (the field the old hollow `cmd_manifest` left null).
|
||||
#[test]
|
||||
fn embedded_manifest_has_non_null_binary_sha256() {
|
||||
let v = embedded_manifest_value().expect("embedded manifest parses");
|
||||
let sha = v.get("binary_sha256").and_then(|s| s.as_str());
|
||||
assert!(
|
||||
sha.is_some(),
|
||||
"embedded manifest must have a non-null binary_sha256 (got {:?})",
|
||||
v.get("binary_sha256")
|
||||
);
|
||||
let sha = sha.unwrap();
|
||||
assert_eq!(sha.len(), 64, "binary_sha256 must be a 32-byte hex digest");
|
||||
assert!(
|
||||
sha.chars().all(|c| c.is_ascii_hexdigit()),
|
||||
"binary_sha256 must be hex"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_manifest_is_signed() {
|
||||
let v = embedded_manifest_value().expect("parse");
|
||||
assert!(
|
||||
v.get("binary_signature").and_then(|s| s.as_str()).is_some(),
|
||||
"embedded manifest must carry an Ed25519 binary_signature"
|
||||
);
|
||||
assert_eq!(
|
||||
v.get("sig_algo").and_then(|s| s.as_str()),
|
||||
Some("Ed25519")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_manifest_id_matches_cog() {
|
||||
let v = embedded_manifest_value().expect("parse");
|
||||
assert_eq!(v.get("id").and_then(|s| s.as_str()), Some(crate::COG_ID));
|
||||
}
|
||||
}
|
||||
@@ -45,20 +45,35 @@ pub fn run_started(cog_id: &str, sensing_url: &str, poll_ms: u64, model_path: &s
|
||||
"sensing_url": sensing_url,
|
||||
"poll_ms": poll_ms,
|
||||
"model_path": model_path,
|
||||
// Honest disclosure: the count head has 8 classes but the shipped
|
||||
// weights were only trained on classes 0..=MAX_TRAINED_CLASS
|
||||
// (presence, not multi-occupant counting). Counts above this are
|
||||
// flagged `low_confidence` on each person.count event.
|
||||
"count_max_trained_class": crate::inference::MAX_TRAINED_CLASS,
|
||||
"count_classes": crate::inference::COUNT_CLASSES,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
pub fn person_count(tick: u64, fused: &CountPrediction, n_nodes: usize) {
|
||||
let (lo, hi) = fused.p95_range();
|
||||
let low_confidence = fused.is_low_confidence();
|
||||
emit_event(&Event {
|
||||
ts: now_secs(),
|
||||
level: "info",
|
||||
// An out-of-distribution count (argmax beyond the trained classes) is
|
||||
// a warning, not a clean info reading.
|
||||
level: if low_confidence { "warn" } else { "info" },
|
||||
event: "person.count",
|
||||
fields: json!({
|
||||
"tick": tick,
|
||||
"count": fused.argmax(),
|
||||
// Reported count is clamped to the trained range — we never emit a
|
||||
// fabricated multi-occupant headcount the weights can't back.
|
||||
"count": fused.clamped_count(),
|
||||
// Raw argmax kept for diagnostics/audit.
|
||||
"raw_count": fused.argmax(),
|
||||
"confidence": fused.confidence,
|
||||
// True when argmax > MAX_TRAINED_CLASS (untrained class).
|
||||
"low_confidence": low_confidence,
|
||||
"count_p95_low": lo,
|
||||
"count_p95_high": hi,
|
||||
"n_nodes": n_nodes,
|
||||
|
||||
@@ -4,7 +4,7 @@ use cog_person_count::{
|
||||
fusion::{fuse_confidence_weighted, fuse_with_mincut_clip},
|
||||
inference::{
|
||||
CountPrediction, CsiWindow, InferenceEngine, SyntheticInput, COUNT_CLASSES,
|
||||
INPUT_SUBCARRIERS, INPUT_TIMESTEPS,
|
||||
INPUT_SUBCARRIERS, INPUT_TIMESTEPS, MAX_TRAINED_CLASS,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -83,6 +83,51 @@ fn fusion_passes_through_single_node() {
|
||||
assert!((out.confidence - 0.6).abs() < 1e-6);
|
||||
}
|
||||
|
||||
/// ADR-159 §A2 — the 8-class count head ships, but the weights were only
|
||||
/// trained on classes 0/1 (presence). A prediction whose argmax lands on an
|
||||
/// UNTRAINED class (2..=7) must be flagged `low_confidence` and the reported
|
||||
/// count clamped to the trained range, so we never emit a fabricated
|
||||
/// multi-occupant headcount. Fails on old code (no such flag/clamp existed).
|
||||
#[test]
|
||||
fn untrained_class_argmax_is_flagged_low_confidence() {
|
||||
// Sanity: the trained ceiling is below the head width.
|
||||
assert!(MAX_TRAINED_CLASS < COUNT_CLASSES - 1);
|
||||
|
||||
// Mass on an untrained class (5 persons) — out-of-distribution.
|
||||
let mut probs = [0.0_f32; COUNT_CLASSES];
|
||||
probs[5] = 0.9;
|
||||
probs[1] = 0.1;
|
||||
let oodp = CountPrediction {
|
||||
probs,
|
||||
confidence: 0.95, // even a "confident" softmax must be flagged
|
||||
};
|
||||
assert_eq!(oodp.argmax(), 5);
|
||||
assert!(
|
||||
oodp.is_low_confidence(),
|
||||
"argmax beyond MAX_TRAINED_CLASS must be flagged low_confidence"
|
||||
);
|
||||
assert_eq!(
|
||||
oodp.clamped_count(),
|
||||
MAX_TRAINED_CLASS,
|
||||
"reported count must clamp to the trained ceiling, not fabricate a headcount"
|
||||
);
|
||||
|
||||
// A trained-range prediction (1 person) is NOT flagged.
|
||||
let mut probs2 = [0.0_f32; COUNT_CLASSES];
|
||||
probs2[1] = 0.8;
|
||||
probs2[0] = 0.2;
|
||||
let inp = CountPrediction {
|
||||
probs: probs2,
|
||||
confidence: 0.8,
|
||||
};
|
||||
assert_eq!(inp.argmax(), 1);
|
||||
assert!(
|
||||
!inp.is_low_confidence(),
|
||||
"a trained-range count must not be flagged"
|
||||
);
|
||||
assert_eq!(inp.clamped_count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mincut_clip_with_high_cap_is_noop() {
|
||||
let mut probs = [0.0_f32; COUNT_CLASSES];
|
||||
|
||||
@@ -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);
|
||||
@@ -26,8 +26,8 @@
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1,
|
||||
"default": 0.3,
|
||||
"description": "Drop frames where the inferred pose confidence is below this threshold."
|
||||
"default": 0.185,
|
||||
"description": "Drop frames where the inferred pose confidence is below this threshold. pose_v1 has no confidence head, so every frame carries the model's published per-frame confidence (0.185 = validation PCK@50); the default is pinned to that value so a default install actually emits frames. Raising it above 0.185 suppresses ALL pose.frame events (the runtime warns when this happens)."
|
||||
}
|
||||
},
|
||||
"required": ["model_path"]
|
||||
|
||||
@@ -23,6 +23,13 @@ pub struct CogConfig {
|
||||
pub poll_ms: u64,
|
||||
|
||||
/// Confidence threshold below which a frame's keypoints are not emitted.
|
||||
///
|
||||
/// Defaults to [`crate::inference::MODEL_TYPICAL_CONFIDENCE`] (0.185) — the
|
||||
/// model's published per-frame confidence. `pose_v1` has no confidence head,
|
||||
/// so every frame carries this same value; a default above it would silently
|
||||
/// suppress *all* `pose.frame` events while health still reports healthy.
|
||||
/// The runtime warns at `run.started` if this is raised above the model's
|
||||
/// typical confidence rather than dropping frames quietly.
|
||||
#[serde(default = "default_min_confidence")]
|
||||
pub min_confidence: f32,
|
||||
}
|
||||
@@ -36,7 +43,9 @@ fn default_poll_ms() -> u64 {
|
||||
}
|
||||
|
||||
fn default_min_confidence() -> f32 {
|
||||
0.3
|
||||
// Pinned to the model's typical/published confidence so a default install
|
||||
// actually emits frames. See `min_confidence` doc and ADR-159 §A1.
|
||||
crate::inference::MODEL_TYPICAL_CONFIDENCE
|
||||
}
|
||||
|
||||
impl CogConfig {
|
||||
|
||||
@@ -27,6 +27,16 @@ pub const INPUT_SUBCARRIERS: usize = 56;
|
||||
pub const INPUT_TIMESTEPS: usize = 20;
|
||||
pub const OUTPUT_KEYPOINTS: usize = 17;
|
||||
|
||||
/// The model's typical self-reported confidence. `pose_v1` has **no confidence
|
||||
/// head** (the head emits 34 keypoint coordinates only), so per-frame confidence
|
||||
/// is not available from the network. This is the validation-set PCK@50 (18.5%)
|
||||
/// the training run reported, used as the published per-frame confidence floor.
|
||||
///
|
||||
/// Surfaced as a public constant so the runtime can warn when a configured
|
||||
/// `min_confidence` threshold exceeds it — otherwise a default install would
|
||||
/// silently emit zero `pose.frame` events while health reports healthy.
|
||||
pub const MODEL_TYPICAL_CONFIDENCE: f32 = 0.185;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CsiWindow {
|
||||
pub data: Vec<f32>, // length INPUT_SUBCARRIERS * INPUT_TIMESTEPS
|
||||
@@ -283,12 +293,15 @@ impl InferenceEngine {
|
||||
let out = model.net.forward(&t)?; // [1, 34]
|
||||
let flat: Vec<f32> = out.flatten_all()?.to_vec1()?;
|
||||
// Confidence from pose_v1 is a published constant rather than per-frame —
|
||||
// the trained model didn't emit a confidence head. Use the validation-set
|
||||
// PCK@50 (18.5%) as the published self-reported confidence so downstream
|
||||
// consumers can gate display decisions on it.
|
||||
// the trained model has no confidence head (the head emits 34 keypoint
|
||||
// coordinates only), so a real per-frame value is genuinely unavailable.
|
||||
// We surface the validation-set PCK@50 (`MODEL_TYPICAL_CONFIDENCE`) as the
|
||||
// honest self-reported confidence. The runtime's `min_confidence` default
|
||||
// is pinned at or below this so a default install actually emits frames
|
||||
// (and warns if an operator raises the threshold above the model's reach).
|
||||
Ok(PoseOutput {
|
||||
keypoints: flat,
|
||||
confidence: 0.185,
|
||||
confidence: MODEL_TYPICAL_CONFIDENCE,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,6 +113,18 @@ fn cmd_run(
|
||||
let cfg = CogConfig::load(&config_path)?;
|
||||
emit_event(&Event::run_started(COG_ID, &cfg));
|
||||
|
||||
// Disclosure: pose_v1 has no confidence head, so every frame carries the
|
||||
// same `MODEL_TYPICAL_CONFIDENCE`. A `min_confidence` above that silently
|
||||
// suppresses *all* pose.frame events. Warn loudly rather than drop quietly.
|
||||
if cfg.min_confidence > cog_pose_estimation::inference::MODEL_TYPICAL_CONFIDENCE {
|
||||
tracing::warn!(
|
||||
min_confidence = cfg.min_confidence,
|
||||
model_typical_confidence = cog_pose_estimation::inference::MODEL_TYPICAL_CONFIDENCE,
|
||||
"configured min_confidence exceeds the model's typical confidence; \
|
||||
no pose.frame events will be emitted until this is lowered"
|
||||
);
|
||||
}
|
||||
|
||||
let engine = InferenceEngine::with_adapter(adapter.as_deref())?;
|
||||
if engine.is_calibrated() {
|
||||
tracing::info!("per-room calibration adapter loaded");
|
||||
|
||||
@@ -172,3 +172,56 @@ fn manifest_roundtrips() {
|
||||
assert_eq!(back.id, "pose-estimation");
|
||||
assert_eq!(back.version, "0.0.1");
|
||||
}
|
||||
|
||||
/// ADR-159 §A1 — the default-config min_confidence threshold must not silently
|
||||
/// suppress every `pose.frame`. With the old `default_min_confidence()=0.3` and
|
||||
/// the model's per-frame confidence pinned at 0.185, the runtime gate
|
||||
/// (`out.confidence >= cfg.min_confidence`) never fired, so a default install
|
||||
/// emitted ZERO frames while health reported healthy. This asserts the default
|
||||
/// install actually clears its own gate.
|
||||
#[test]
|
||||
fn default_config_emits_frames_with_real_model() {
|
||||
use cog_pose_estimation::config::CogConfig;
|
||||
|
||||
// A minimal config (only the required model_path) exercises every
|
||||
// `#[serde(default)]` path — i.e. the *default* install threshold.
|
||||
let cfg: CogConfig =
|
||||
serde_json::from_value(serde_json::json!({ "model_path": "pose_v1.safetensors" }))
|
||||
.expect("default config parse");
|
||||
|
||||
// Real model when present; stub otherwise. Either way the per-frame
|
||||
// confidence the runtime gates on must clear the default threshold,
|
||||
// OR (stub case) the gate must still let the model's typical confidence
|
||||
// through. We assert against the same value the runtime emits.
|
||||
let weights = std::path::Path::new("cog/artifacts/pose_v1.safetensors");
|
||||
let engine = if weights.exists() {
|
||||
InferenceEngine::with_weights(Some(weights)).expect("load real weights")
|
||||
} else {
|
||||
InferenceEngine::new().expect("engine init")
|
||||
};
|
||||
|
||||
// Core regression assertion (fails on the old `default_min_confidence()=0.3`):
|
||||
// the default threshold must not exceed the model's published per-frame
|
||||
// confidence (0.185), which is the exact value `infer()` emits for the real
|
||||
// model. With 0.3 the runtime gate `out.confidence >= min_confidence` never
|
||||
// fired → zero pose.frame events on a default install.
|
||||
assert!(
|
||||
cfg.min_confidence <= cog_pose_estimation::inference::MODEL_TYPICAL_CONFIDENCE,
|
||||
"default min_confidence {} exceeds model typical confidence {} — \
|
||||
a default install would emit zero pose.frame events",
|
||||
cfg.min_confidence,
|
||||
cog_pose_estimation::inference::MODEL_TYPICAL_CONFIDENCE
|
||||
);
|
||||
|
||||
// End-to-end: when the real model is loaded, the value it actually emits
|
||||
// must clear the default gate (i.e. the runtime would emit this frame).
|
||||
if engine.backend().starts_with("candle-") {
|
||||
let out = engine.infer(&SyntheticInput.as_window()).expect("infer");
|
||||
assert!(
|
||||
out.confidence >= cfg.min_confidence,
|
||||
"default install must emit: infer confidence {} < default min_confidence {}",
|
||||
out.confidence,
|
||||
cfg.min_confidence
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,8 +33,12 @@ chrono = { version = "0.4", features = ["serde"] }
|
||||
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
dashmap = "6"
|
||||
futures-util = { version = "0.3", default-features = false, features = ["sink"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tower = { version = "0.5", features = ["util"] }
|
||||
hyper = "1"
|
||||
http-body-util = "0.1"
|
||||
# End-to-end WS handshake + reply tests (HC-WS-01/02, ADR-161).
|
||||
tokio-tungstenite = "0.24"
|
||||
futures-util = { version = "0.3", default-features = false }
|
||||
|
||||
@@ -88,6 +88,11 @@ fn default_origins() -> Vec<HeaderValue> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// `set_var`/`remove_var` mutate process-global state; serialize every test
|
||||
// that touches HOMECORE_CORS_ORIGINS so they cannot race in parallel.
|
||||
// Poison-tolerant: a panicking test must not cascade-fail the others.
|
||||
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
#[test]
|
||||
fn default_origins_includes_vite_and_ha_ports() {
|
||||
let origins = default_origins();
|
||||
@@ -98,6 +103,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn env_override_via_homecore_cors_origins() {
|
||||
let _env = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
std::env::set_var("HOMECORE_CORS_ORIGINS", "https://example.com,https://other.example.com");
|
||||
// build_cors_layer() returns a CorsLayer which doesn't expose
|
||||
// its origin list; we test the parse path indirectly by
|
||||
@@ -112,6 +118,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn env_empty_falls_back_to_defaults() {
|
||||
let _env = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
std::env::set_var("HOMECORE_CORS_ORIGINS", " ");
|
||||
let raw = std::env::var("HOMECORE_CORS_ORIGINS").ok();
|
||||
let trimmed = raw.as_deref().map(|s| s.trim()).unwrap_or("");
|
||||
|
||||
@@ -1,15 +1,31 @@
|
||||
//! `homecore-api-server` binary. Boots a HomeCore runtime and serves
|
||||
//! the HA-compat REST + WS API on `:8123`.
|
||||
//! the HA-compat REST + WS API.
|
||||
//!
|
||||
//! P1: bare-minimum bring-up. No persistence, no plugins, no auth
|
||||
//! beyond "any non-empty bearer". Useful for `curl` smoke tests of
|
||||
//! the wire format from the existing HA companion app:
|
||||
//! ## Auth (ADR-161, HC-WS-08)
|
||||
//!
|
||||
//! Token provisioning matches `homecore-server`: if `HOMECORE_TOKENS`
|
||||
//! is set (comma-separated bearer tokens) the API enforces that
|
||||
//! whitelist on both the REST and WS paths. If it is **unset**, the
|
||||
//! binary falls back to an explicitly-logged DEV mode (any non-empty
|
||||
//! bearer accepted) — before this fix the bin unconditionally used
|
||||
//! `allow_any_non_empty()` with no env path, so a provisioned operator
|
||||
//! had no way to lock it down.
|
||||
//!
|
||||
//! ## Bind address
|
||||
//!
|
||||
//! Defaults to `127.0.0.1` (loopback only) so a bare `cargo run` of
|
||||
//! this dev binary is not network-exposed. Override with
|
||||
//! `HOMECORE_BIND=0.0.0.0:8123` for a LAN deployment (and provision
|
||||
//! `HOMECORE_TOKENS` when you do).
|
||||
//!
|
||||
//! cargo run -p homecore-api --bin homecore-api-server
|
||||
//! curl -H "Authorization: Bearer test" http://127.0.0.1:8123/api/
|
||||
//! HOMECORE_TOKENS=secret curl -H "Authorization: Bearer secret" \
|
||||
//! http://127.0.0.1:8123/api/
|
||||
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use homecore::HomeCore;
|
||||
use homecore_api::{router, SharedState, DEFAULT_PORT};
|
||||
use homecore_api::{router, LongLivedTokenStore, SharedState, DEFAULT_PORT};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
@@ -21,10 +37,34 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
.init();
|
||||
|
||||
let homecore = HomeCore::new();
|
||||
let state = SharedState::new(homecore);
|
||||
|
||||
// Token provisioning (HC-WS-08). Prefer the HOMECORE_TOKENS env
|
||||
// whitelist; fall back to DEV mode (warn-logged) only when unset.
|
||||
let tokens = if std::env::var("HOMECORE_TOKENS")
|
||||
.map(|v| !v.trim().is_empty())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
let s = LongLivedTokenStore::from_env();
|
||||
let n = s.len().await;
|
||||
tracing::info!("LongLivedTokenStore provisioned with {n} bearer token(s) from HOMECORE_TOKENS");
|
||||
s
|
||||
} else {
|
||||
tracing::warn!(
|
||||
"HOMECORE_TOKENS not set — token store in DEV mode (any non-empty bearer \
|
||||
accepted). Set HOMECORE_TOKENS before exposing this binary to the network."
|
||||
);
|
||||
LongLivedTokenStore::allow_any_non_empty()
|
||||
};
|
||||
|
||||
let state = SharedState::with_tokens(homecore, "Home", env!("CARGO_PKG_VERSION"), tokens);
|
||||
let app = router(state);
|
||||
|
||||
let addr = std::net::SocketAddr::from(([0, 0, 0, 0], DEFAULT_PORT));
|
||||
// Default to loopback so `cargo run` is not network-exposed; allow
|
||||
// an explicit HOMECORE_BIND override for LAN deployments.
|
||||
let addr: SocketAddr = match std::env::var("HOMECORE_BIND") {
|
||||
Ok(v) if !v.trim().is_empty() => v.parse()?,
|
||||
_ => SocketAddr::from(([127, 0, 0, 1], DEFAULT_PORT)),
|
||||
};
|
||||
tracing::info!("HOMECORE-API listening on http://{addr} (HA-compat /api + /api/websocket)");
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||
|
||||
@@ -9,6 +9,16 @@
|
||||
//!
|
||||
//! `ha_version` is the homecore version string — see ADR-130 Q1 for the
|
||||
//! companion-app feature-detect concern.
|
||||
//!
|
||||
//! ## Security (ADR-161)
|
||||
//!
|
||||
//! The `auth` token is validated against [`crate::tokens::LongLivedTokenStore`]
|
||||
//! via `state.tokens().is_valid()` — the *same* store the REST path uses
|
||||
//! (`auth::BearerAuth`). A wrong token receives `auth_invalid` and the socket
|
||||
//! is closed. (HC-WS-01 closed the prior bypass where any non-empty token was
|
||||
//! accepted.) Command replies are transmitted by a dedicated writer task that
|
||||
//! drains the response channel onto the socket (HC-WS-02 closed the prior
|
||||
//! reply-theater where responses were logged and discarded).
|
||||
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
@@ -18,7 +28,7 @@ use axum::extract::State;
|
||||
use axum::response::IntoResponse;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::broadcast;
|
||||
use tracing::{debug, warn};
|
||||
use tracing::warn;
|
||||
|
||||
use homecore::{Context, ServiceCall, ServiceName, SystemEvent};
|
||||
|
||||
@@ -58,11 +68,18 @@ async fn handle_socket(mut socket: WebSocket, state: SharedState) {
|
||||
_ => return,
|
||||
};
|
||||
|
||||
// P1: accept any non-empty token. P2: validate against store.
|
||||
if token.trim().is_empty() {
|
||||
// Validate the bearer token against the same store the REST path
|
||||
// uses (`state.tokens().is_valid()` — see `rest.rs` /
|
||||
// `auth::BearerAuth`). Before the HC-WS-01 fix this checked only
|
||||
// `token.trim().is_empty()` and accepted ANY non-empty token even
|
||||
// with a provisioned `HOMECORE_TOKENS` whitelist — a full WS auth
|
||||
// bypass. `is_valid()` rejects the empty token internally and, in
|
||||
// DEV (`allow_any`) mode, still accepts any non-empty bearer (with
|
||||
// a warn) so smoke tests keep working.
|
||||
if !state.tokens().is_valid(&token).await {
|
||||
let _ = socket
|
||||
.send(Message::Text(
|
||||
serde_json::json!({"type":"auth_invalid","message":"empty token"}).to_string(),
|
||||
serde_json::json!({"type":"auth_invalid","message":"invalid token"}).to_string(),
|
||||
))
|
||||
.await;
|
||||
return;
|
||||
@@ -140,54 +157,71 @@ impl Connection {
|
||||
}
|
||||
}
|
||||
|
||||
async fn run(self, mut socket: WebSocket) {
|
||||
async fn run(self, socket: WebSocket) {
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
|
||||
let conn = Arc::new(self);
|
||||
// Split the socket so a dedicated writer task can drain `rx` onto
|
||||
// the wire while the reader task processes commands concurrently.
|
||||
// Before the HC-WS-02 fix the socket was moved into a recv-only
|
||||
// task and the only `rx` consumer just `debug!`-logged and
|
||||
// DISCARDED every message — so no `result`/`pong`/`event` ever
|
||||
// reached the client. Now `rx` feeds `socket.send`.
|
||||
let (mut sink, mut stream) = socket.split();
|
||||
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<String>();
|
||||
|
||||
let sender_tx = tx.clone();
|
||||
let recv_task = {
|
||||
let conn = Arc::clone(&conn);
|
||||
tokio::spawn(async move {
|
||||
while let Some(frame) = socket.recv().await {
|
||||
match frame {
|
||||
Ok(Message::Text(raw)) => {
|
||||
let cmd: WsCommand = match serde_json::from_str(&raw) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
warn!("bad ws command: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
conn.handle_cmd(cmd, &sender_tx).await;
|
||||
}
|
||||
Ok(Message::Ping(p)) => {
|
||||
let _ = sender_tx.send(format!("__pong:{}", p.len()));
|
||||
}
|
||||
Ok(Message::Close(_)) | Err(_) => break,
|
||||
_ => {}
|
||||
}
|
||||
// Writer task: drain replies onto the socket. A `__pong:<n>`
|
||||
// sentinel maps to a binary Pong control frame; everything else
|
||||
// is a JSON text frame.
|
||||
let writer_task = tokio::spawn(async move {
|
||||
while let Some(msg) = rx.recv().await {
|
||||
let send_result = if let Some(n) = msg.strip_prefix("__pong:") {
|
||||
let len: usize = n.parse().unwrap_or(0);
|
||||
sink.send(Message::Pong(vec![0u8; len])).await
|
||||
} else {
|
||||
sink.send(Message::Text(msg)).await
|
||||
};
|
||||
if send_result.is_err() {
|
||||
break;
|
||||
}
|
||||
// Cancel all subscriptions on disconnect.
|
||||
for entry in conn.subs.iter() {
|
||||
entry.value().abort.abort();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
tokio::spawn(async move {
|
||||
while let Some(msg) = rx.recv().await {
|
||||
if msg.starts_with("__pong:") {
|
||||
// pong handled inline; skip
|
||||
continue;
|
||||
// Reader task: parse and dispatch commands; responses are pushed
|
||||
// into `tx` and transmitted by the writer task above.
|
||||
let reader_tx = tx.clone();
|
||||
{
|
||||
let conn = Arc::clone(&conn);
|
||||
while let Some(frame) = stream.next().await {
|
||||
match frame {
|
||||
Ok(Message::Text(raw)) => {
|
||||
let cmd: WsCommand = match serde_json::from_str(&raw) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
warn!("bad ws command: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
conn.handle_cmd(cmd, &reader_tx).await;
|
||||
}
|
||||
// Use the socket from the recv task via a one-shot mpsc
|
||||
// (in this minimal P1, the recv task owns the socket
|
||||
// and we ack inline below — this branch is for the
|
||||
// subscription fan-out emit path)
|
||||
debug!("ws emit: {msg}");
|
||||
Ok(Message::Ping(p)) => {
|
||||
let _ = reader_tx.send(format!("__pong:{}", p.len()));
|
||||
}
|
||||
Ok(Message::Close(_)) | Err(_) => break,
|
||||
_ => {}
|
||||
}
|
||||
})
|
||||
};
|
||||
let _ = recv_task.await;
|
||||
}
|
||||
// Cancel all subscriptions on disconnect.
|
||||
for entry in conn.subs.iter() {
|
||||
entry.value().abort.abort();
|
||||
}
|
||||
}
|
||||
|
||||
// Reader loop ended → drop the senders so the writer task's `rx`
|
||||
// closes and the task exits cleanly.
|
||||
drop(tx);
|
||||
drop(reader_tx);
|
||||
let _ = writer_task.await;
|
||||
}
|
||||
|
||||
async fn handle_cmd(&self, cmd: WsCommand, tx: &tokio::sync::mpsc::UnboundedSender<String>) {
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
//! HC-WS-08 (ADR-161): the `homecore-api-server` bin must honor the
|
||||
//! `HOMECORE_TOKENS` env whitelist instead of unconditionally accepting
|
||||
//! any non-empty bearer.
|
||||
//!
|
||||
//! `main()` is not directly callable, so this reproduces the bin's exact
|
||||
//! token-provisioning path (`LongLivedTokenStore::from_env()` when
|
||||
//! `HOMECORE_TOKENS` is set) and drives a real HTTP request through the
|
||||
//! router. On the pre-fix bin — which used `SharedState::new()` →
|
||||
//! `allow_any_non_empty()` with NO env path — a wrong bearer was
|
||||
//! accepted; this test asserts it is now rejected with 401.
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use homecore::HomeCore;
|
||||
use homecore_api::{router, LongLivedTokenStore, SharedState};
|
||||
use tower::ServiceExt; // for `oneshot`
|
||||
|
||||
/// Build the same state the bin builds when HOMECORE_TOKENS is set.
|
||||
async fn provisioned_state(valid: &str) -> SharedState {
|
||||
// Mirror `from_env()` deterministically without mutating process
|
||||
// env (which would race other tests): an `empty()` store with the
|
||||
// one provisioned token registered is exactly what
|
||||
// `from_env()` produces for `HOMECORE_TOKENS=<valid>`.
|
||||
let store = LongLivedTokenStore::empty();
|
||||
store.register(valid).await;
|
||||
SharedState::with_tokens(HomeCore::new(), "Home", "test", store)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn provisioned_bin_rejects_wrong_bearer() {
|
||||
let app = router(provisioned_state("the_real_token").await);
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/states")
|
||||
.header("Authorization", "Bearer the_wrong_token")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"a provisioned token store must reject a wrong bearer (HC-WS-08)"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn provisioned_bin_accepts_correct_bearer() {
|
||||
let app = router(provisioned_state("the_real_token").await);
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/states")
|
||||
.header("Authorization", "Bearer the_real_token")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn from_env_path_enforces_whitelist() {
|
||||
// Exercise the literal `from_env()` constructor the bin uses, under
|
||||
// a serialized env mutation, to prove the env path itself enforces.
|
||||
std::env::set_var("HOMECORE_TOKENS", "env_token_1, env_token_2");
|
||||
let store = LongLivedTokenStore::from_env();
|
||||
std::env::remove_var("HOMECORE_TOKENS");
|
||||
|
||||
assert!(store.is_valid("env_token_1").await);
|
||||
assert!(store.is_valid("env_token_2").await);
|
||||
assert!(!store.is_valid("not_in_whitelist").await);
|
||||
assert!(!store.is_dev_mode().await, "from_env must NOT be dev mode");
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
//! End-to-end WebSocket handshake + reply tests (ADR-161, HC-WS-01/02).
|
||||
//!
|
||||
//! These bind a real `TcpListener`, serve the full router, and connect
|
||||
//! with a real WS client (`tokio-tungstenite`). They exercise the wire
|
||||
//! path the in-crate unit tests cannot.
|
||||
//!
|
||||
//! - `wrong_token_is_rejected` — FAILS on the pre-fix `ws.rs` that only
|
||||
//! checked `token.trim().is_empty()` and accepted any non-empty token
|
||||
//! (HC-WS-01: WS auth bypass).
|
||||
//! - `result_reply_is_received` — FAILS on the pre-fix `ws.rs` that moved
|
||||
//! the socket into a recv-only task and discarded every reply with
|
||||
//! `debug!("ws emit: {msg}")` (HC-WS-02: reply theater).
|
||||
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use homecore::HomeCore;
|
||||
use homecore_api::{router, LongLivedTokenStore, SharedState};
|
||||
use tokio_tungstenite::connect_async;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
|
||||
/// Spawn the API on an ephemeral port with a real (non-dev) token store
|
||||
/// containing exactly one valid token. Returns the bound address.
|
||||
async fn spawn_server_with_token(valid_token: &str) -> SocketAddr {
|
||||
let hc = HomeCore::new();
|
||||
let tokens = LongLivedTokenStore::empty();
|
||||
tokens.register(valid_token).await;
|
||||
let state = SharedState::with_tokens(hc, "Test", "test-version", tokens);
|
||||
let app = router(state);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move {
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
});
|
||||
addr
|
||||
}
|
||||
|
||||
/// Read text frames until one parses as JSON; returns the parsed value.
|
||||
async fn next_json<S>(ws: &mut S) -> serde_json::Value
|
||||
where
|
||||
S: StreamExt<Item = Result<Message, tokio_tungstenite::tungstenite::Error>> + Unpin,
|
||||
{
|
||||
loop {
|
||||
match ws.next().await {
|
||||
Some(Ok(Message::Text(raw))) => {
|
||||
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&raw) {
|
||||
return v;
|
||||
}
|
||||
}
|
||||
Some(Ok(_)) => continue,
|
||||
other => panic!("expected text frame, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn wrong_token_is_rejected() {
|
||||
// HC-WS-01: a provisioned store with one good token must reject a
|
||||
// DIFFERENT (non-empty) token over the WS handshake. The old code
|
||||
// sent `auth_ok` for any non-empty token — this asserts the fix.
|
||||
let addr = spawn_server_with_token("good_token_abc").await;
|
||||
let url = format!("ws://{addr}/api/websocket");
|
||||
let (mut ws, _resp) = connect_async(&url).await.unwrap();
|
||||
|
||||
// Server → auth_required
|
||||
let req = next_json(&mut ws).await;
|
||||
assert_eq!(req["type"], "auth_required");
|
||||
|
||||
// Client → auth with the WRONG token
|
||||
ws.send(Message::Text(
|
||||
serde_json::json!({"type":"auth","access_token":"wrong_token_xyz"}).to_string(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Server → auth_invalid (NOT auth_ok)
|
||||
let resp = next_json(&mut ws).await;
|
||||
assert_eq!(
|
||||
resp["type"], "auth_invalid",
|
||||
"wrong token must be rejected with auth_invalid, got: {resp}"
|
||||
);
|
||||
assert_ne!(resp["type"], "auth_ok", "wrong token must NOT receive auth_ok");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn correct_token_is_accepted() {
|
||||
let addr = spawn_server_with_token("good_token_abc").await;
|
||||
let url = format!("ws://{addr}/api/websocket");
|
||||
let (mut ws, _resp) = connect_async(&url).await.unwrap();
|
||||
|
||||
let req = next_json(&mut ws).await;
|
||||
assert_eq!(req["type"], "auth_required");
|
||||
|
||||
ws.send(Message::Text(
|
||||
serde_json::json!({"type":"auth","access_token":"good_token_abc"}).to_string(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let resp = next_json(&mut ws).await;
|
||||
assert_eq!(resp["type"], "auth_ok", "correct token should be accepted, got: {resp}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn result_reply_is_received() {
|
||||
// HC-WS-02: after a successful auth, a `get_states` command must
|
||||
// produce a `result` reply RECEIVED over the socket. The old code
|
||||
// discarded all replies in the rx-draining task, so this hangs/
|
||||
// fails on the pre-fix source.
|
||||
let addr = spawn_server_with_token("good_token_abc").await;
|
||||
let url = format!("ws://{addr}/api/websocket");
|
||||
let (mut ws, _resp) = connect_async(&url).await.unwrap();
|
||||
|
||||
let req = next_json(&mut ws).await;
|
||||
assert_eq!(req["type"], "auth_required");
|
||||
|
||||
ws.send(Message::Text(
|
||||
serde_json::json!({"type":"auth","access_token":"good_token_abc"}).to_string(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
let auth = next_json(&mut ws).await;
|
||||
assert_eq!(auth["type"], "auth_ok");
|
||||
|
||||
// Send a command and assert we RECEIVE a result reply.
|
||||
ws.send(Message::Text(
|
||||
serde_json::json!({"id": 1, "type": "get_states"}).to_string(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let reply = tokio::time::timeout(std::time::Duration::from_secs(5), next_json(&mut ws))
|
||||
.await
|
||||
.expect("did not receive a reply within 5s — reply theater (HC-WS-02)");
|
||||
assert_eq!(reply["type"], "result", "expected a result reply, got: {reply}");
|
||||
assert_eq!(reply["id"], 1);
|
||||
assert_eq!(reply["success"], true);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ping_pong_reply_is_received() {
|
||||
// The `ping` command must produce a `pong` reply on the wire — also
|
||||
// exercises the writer task that HC-WS-02 introduced.
|
||||
let addr = spawn_server_with_token("good_token_abc").await;
|
||||
let url = format!("ws://{addr}/api/websocket");
|
||||
let (mut ws, _resp) = connect_async(&url).await.unwrap();
|
||||
|
||||
let _ = next_json(&mut ws).await; // auth_required
|
||||
ws.send(Message::Text(
|
||||
serde_json::json!({"type":"auth","access_token":"good_token_abc"}).to_string(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
let _ = next_json(&mut ws).await; // auth_ok
|
||||
|
||||
ws.send(Message::Text(
|
||||
serde_json::json!({"id": 7, "type": "ping"}).to_string(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let reply = tokio::time::timeout(std::time::Duration::from_secs(5), next_json(&mut ws))
|
||||
.await
|
||||
.expect("did not receive pong within 5s");
|
||||
assert_eq!(reply["type"], "pong");
|
||||
assert_eq!(reply["id"], 7);
|
||||
}
|
||||
@@ -3,15 +3,26 @@
|
||||
//! Implements the ADR-129 P1 action set: `service_call`, `delay`, `scene`,
|
||||
//! `wait_for_trigger`, `choose`. Complex variants (parallel, repeat, if,
|
||||
//! stop, fire_event, wait_template) land in P2.
|
||||
//!
|
||||
//! ## `choose` branch evaluation (ADR-161, HC-WS-06)
|
||||
//!
|
||||
//! `Action::Choose` evaluates each branch's `conditions` against the live
|
||||
//! [`EvalContext`] (deserialising the per-branch `serde_yaml::Value`
|
||||
//! conditions into [`Condition`]) and runs the FIRST matching branch's
|
||||
//! sequence. Only if no branch matches does it fall to `default`. Before
|
||||
//! this fix the branches were discarded and `default` always ran.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::time::sleep;
|
||||
|
||||
use homecore::{Context, HomeCore, ServiceCall, ServiceName};
|
||||
use homecore::{Context, HomeCore, ServiceCall, ServiceName, StateMachine};
|
||||
|
||||
use crate::condition::{Condition, EvalContext};
|
||||
use crate::error::AutomationError;
|
||||
use crate::template::TemplateEnvironment;
|
||||
|
||||
/// Runtime context passed into action execution.
|
||||
pub struct ExecutionContext {
|
||||
@@ -21,14 +32,40 @@ pub struct ExecutionContext {
|
||||
pub context: Context,
|
||||
/// Automation ID for tracing/logging.
|
||||
pub automation_id: String,
|
||||
/// Condition-evaluation context for `Choose` branches. Carries the
|
||||
/// state-machine snapshot + optional template environment so branch
|
||||
/// conditions (incl. `template:`) evaluate against live state.
|
||||
pub eval: EvalContext,
|
||||
}
|
||||
|
||||
impl ExecutionContext {
|
||||
/// Build a context whose `Choose` branches evaluate against the
|
||||
/// HomeCore state machine (no template env — `template:` branch
|
||||
/// conditions evaluate false; use [`Self::with_templates`] to wire
|
||||
/// one).
|
||||
pub fn new(hc: HomeCore, automation_id: impl Into<String>) -> Self {
|
||||
let sm = Arc::new(hc.states().clone());
|
||||
Self {
|
||||
hc,
|
||||
context: Context::new(),
|
||||
automation_id: automation_id.into(),
|
||||
eval: EvalContext::new(sm),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a context with a template environment wired into the
|
||||
/// `Choose` branch-condition evaluator.
|
||||
pub fn with_templates(
|
||||
hc: HomeCore,
|
||||
automation_id: impl Into<String>,
|
||||
states: Arc<StateMachine>,
|
||||
templates: Arc<TemplateEnvironment>,
|
||||
) -> Self {
|
||||
Self {
|
||||
hc,
|
||||
context: Context::new(),
|
||||
automation_id: automation_id.into(),
|
||||
eval: EvalContext::with_templates(states, templates),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -72,6 +109,27 @@ pub struct ChoiceBranch {
|
||||
pub sequence: Vec<Action>,
|
||||
}
|
||||
|
||||
impl ChoiceBranch {
|
||||
/// Does this branch match? All of its `conditions` must evaluate
|
||||
/// true (HA `choose` semantics are AND-over-conditions). Each raw
|
||||
/// `serde_yaml::Value` is deserialised into a [`Condition`]; a
|
||||
/// condition that fails to parse is treated as non-matching (the
|
||||
/// branch is skipped) rather than silently passing. An empty
|
||||
/// `conditions` list matches (an unconditional branch).
|
||||
pub async fn matches(&self, eval: &EvalContext) -> bool {
|
||||
for raw in &self.conditions {
|
||||
let cond: Condition = match serde_yaml::from_value(raw.clone()) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return false,
|
||||
};
|
||||
if !cond.evaluate(eval).await {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
impl Action {
|
||||
/// Execute this action using the provided context.
|
||||
///
|
||||
@@ -118,9 +176,18 @@ impl Action {
|
||||
}
|
||||
Ok(serde_json::Value::Null)
|
||||
}
|
||||
Action::Choose { choices: _, default } => {
|
||||
// P1 stub — condition evaluation for choices lands in P2;
|
||||
// for now, fall through to default branch.
|
||||
Action::Choose { choices, default } => {
|
||||
// Evaluate each branch's conditions against live state;
|
||||
// run the first branch whose conditions ALL pass. Fall
|
||||
// to `default` only if no branch matches (HC-WS-06).
|
||||
for branch in choices {
|
||||
if branch.matches(&ctx.eval).await {
|
||||
for a in &branch.sequence {
|
||||
a.execute(ctx).await?;
|
||||
}
|
||||
return Ok(serde_json::Value::Null);
|
||||
}
|
||||
}
|
||||
for a in default {
|
||||
a.execute(ctx).await?;
|
||||
}
|
||||
@@ -188,4 +255,100 @@ mod tests {
|
||||
let err = action.execute(&mut exec_ctx).await.unwrap_err();
|
||||
assert!(matches!(err, AutomationError::ServiceCall(ServiceError::NotRegistered { .. })));
|
||||
}
|
||||
|
||||
/// Register two recording handlers and return their call logs.
|
||||
async fn two_recorders(
|
||||
hc: &HomeCore,
|
||||
) -> (Arc<Mutex<Vec<serde_json::Value>>>, Arc<Mutex<Vec<serde_json::Value>>>) {
|
||||
use homecore::EntityId;
|
||||
let _ = EntityId::parse("light.x"); // touch import path
|
||||
let mk = |hc: &HomeCore, svc: &'static str| {
|
||||
let log: Arc<Mutex<Vec<serde_json::Value>>> = Arc::new(Mutex::new(vec![]));
|
||||
let log2 = Arc::clone(&log);
|
||||
let hc = hc.clone();
|
||||
async move {
|
||||
hc.services()
|
||||
.register(
|
||||
ServiceName::new("light", svc),
|
||||
FnHandler(move |call: ServiceCall| {
|
||||
let l = Arc::clone(&log2);
|
||||
async move {
|
||||
l.lock().unwrap().push(call.data.clone());
|
||||
Ok(serde_json::Value::Null)
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
log
|
||||
}
|
||||
};
|
||||
let branch_log = mk(hc, "branch_service").await;
|
||||
let default_log = mk(hc, "default_service").await;
|
||||
(branch_log, default_log)
|
||||
}
|
||||
|
||||
fn choose_with_match() -> Action {
|
||||
// A `Choose` whose first branch requires light.gate == "open".
|
||||
let branch_conditions = vec![serde_yaml::from_str::<serde_yaml::Value>(
|
||||
"condition: state\nentity_id: light.gate\nstate: open",
|
||||
)
|
||||
.unwrap()];
|
||||
Action::Choose {
|
||||
choices: vec![ChoiceBranch {
|
||||
conditions: branch_conditions,
|
||||
sequence: vec![Action::ServiceCall {
|
||||
domain: "light".into(),
|
||||
service: "branch_service".into(),
|
||||
data: serde_json::json!({"branch": true}),
|
||||
}],
|
||||
}],
|
||||
default: vec![Action::ServiceCall {
|
||||
domain: "light".into(),
|
||||
service: "default_service".into(),
|
||||
data: serde_json::json!({"default": true}),
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn choose_runs_matching_branch_not_default() {
|
||||
// HC-WS-06: with the branch condition satisfied, the branch
|
||||
// sequence runs and `default` does NOT. On the pre-fix code
|
||||
// (choices discarded) `default` ran instead → this fails on old.
|
||||
use homecore::{Context, EntityId};
|
||||
let hc = HomeCore::new();
|
||||
let (branch_log, default_log) = two_recorders(&hc).await;
|
||||
hc.states().set(
|
||||
EntityId::parse("light.gate").unwrap(),
|
||||
"open",
|
||||
serde_json::json!({}),
|
||||
Context::new(),
|
||||
);
|
||||
|
||||
let mut ctx = ExecutionContext::new(hc, "choose_auto");
|
||||
choose_with_match().execute(&mut ctx).await.unwrap();
|
||||
|
||||
assert_eq!(branch_log.lock().unwrap().len(), 1, "matching branch must run");
|
||||
assert_eq!(default_log.lock().unwrap().len(), 0, "default must NOT run when a branch matches");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn choose_falls_to_default_when_no_branch_matches() {
|
||||
use homecore::{Context, EntityId};
|
||||
let hc = HomeCore::new();
|
||||
let (branch_log, default_log) = two_recorders(&hc).await;
|
||||
// gate is "closed" → branch condition (== "open") fails.
|
||||
hc.states().set(
|
||||
EntityId::parse("light.gate").unwrap(),
|
||||
"closed",
|
||||
serde_json::json!({}),
|
||||
Context::new(),
|
||||
);
|
||||
|
||||
let mut ctx = ExecutionContext::new(hc, "choose_auto");
|
||||
choose_with_match().execute(&mut ctx).await.unwrap();
|
||||
|
||||
assert_eq!(branch_log.lock().unwrap().len(), 0, "branch must not run when condition fails");
|
||||
assert_eq!(default_log.lock().unwrap().len(), 1, "default must run when no branch matches");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,56 +2,130 @@
|
||||
//! triggers, and runs automation action sequences.
|
||||
//!
|
||||
//! ADR-129 §2 design: one Tokio task per running automation instance.
|
||||
//! RunMode::Single is enforced via a per-automation `AtomicBool` flag.
|
||||
//!
|
||||
//! ## Run modes (ADR-161 §A5 → completed in ADR-162)
|
||||
//!
|
||||
//! 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)
|
||||
//!
|
||||
//! `Trigger::Time { at: "HH:MM:SS" }` is evaluated by a wall-clock timer
|
||||
//! task (1 Hz tokio interval) — `Trigger::matches_sync` returns false for
|
||||
//! `Time` because it has no clock. The timer fires each `time:`
|
||||
//! automation once when the local wall-clock second equals its `at`.
|
||||
//!
|
||||
//! ## Template conditions (ADR-161, HC-WS-07)
|
||||
//!
|
||||
//! The engine builds a real [`TemplateEnvironment`] over the state
|
||||
//! machine and passes it into every `EvalContext` (via
|
||||
//! `EvalContext::with_templates`), so `template:` conditions evaluate
|
||||
//! against live state instead of always returning false.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use chrono::{Local, Timelike};
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
use homecore::HomeCore;
|
||||
|
||||
use crate::action::ExecutionContext;
|
||||
use crate::automation::Automation;
|
||||
use crate::condition::EvalContext;
|
||||
use crate::trigger::TriggerContext;
|
||||
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>,
|
||||
/// 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
|
||||
/// automations. Call `start()` to begin listening for events.
|
||||
pub struct AutomationEngine {
|
||||
hc: HomeCore,
|
||||
automations: Arc<Mutex<Vec<Arc<Automation>>>>,
|
||||
automations: Arc<Mutex<Vec<Registered>>>,
|
||||
templates: Arc<TemplateEnvironment>,
|
||||
}
|
||||
|
||||
impl AutomationEngine {
|
||||
/// Create a new engine backed by the given HOMECORE handle.
|
||||
pub fn new(hc: HomeCore) -> Self {
|
||||
let templates = Arc::new(TemplateEnvironment::new(Arc::new(hc.states().clone())));
|
||||
Self {
|
||||
hc,
|
||||
automations: Arc::new(Mutex::new(vec![])),
|
||||
templates,
|
||||
}
|
||||
}
|
||||
|
||||
/// Register an automation. Can be called before or after `start()`.
|
||||
pub fn register(&self, automation: Automation) {
|
||||
self.automations.lock().unwrap().push(Arc::new(automation));
|
||||
let run_state = RunState::new(&automation);
|
||||
self.automations.lock().unwrap().push(Registered {
|
||||
auto: Arc::new(automation),
|
||||
run_state,
|
||||
});
|
||||
}
|
||||
|
||||
/// Number of registered automations.
|
||||
pub fn len(&self) -> usize {
|
||||
self.automations.lock().unwrap().len()
|
||||
}
|
||||
|
||||
/// Is the engine holding zero automations?
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.len() == 0
|
||||
}
|
||||
|
||||
/// Build an `EvalContext` with the engine's template environment
|
||||
/// wired in, over a fresh snapshot of the state machine.
|
||||
fn eval_ctx(&self) -> EvalContext {
|
||||
EvalContext::with_templates(
|
||||
Arc::new(self.hc.states().clone()),
|
||||
Arc::clone(&self.templates),
|
||||
)
|
||||
}
|
||||
|
||||
/// Subscribe to the state-machine broadcast channel and start
|
||||
/// evaluating triggers. Returns a join handle for the background task.
|
||||
/// evaluating triggers. Also starts the wall-clock timer task that
|
||||
/// evaluates `time:` triggers. Returns a join handle for the event
|
||||
/// task (the timer task is detached and tied to the engine handle's
|
||||
/// lifetime via the broadcast channel close).
|
||||
///
|
||||
/// The task runs until the broadcast sender is dropped (i.e. the
|
||||
/// `HomeCore` instance is destroyed).
|
||||
pub fn start(&self) -> tokio::task::JoinHandle<()> {
|
||||
self.start_timer();
|
||||
self.start_event_loop()
|
||||
}
|
||||
|
||||
/// Event-driven loop: state/numeric/event triggers.
|
||||
fn start_event_loop(&self) -> tokio::task::JoinHandle<()> {
|
||||
let mut rx = self.hc.states().subscribe();
|
||||
let automations = Arc::clone(&self.automations);
|
||||
let hc = self.hc.clone();
|
||||
let templates = Arc::clone(&self.templates);
|
||||
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Ok(event) => {
|
||||
let autos = automations.lock().unwrap().clone();
|
||||
for automation in autos {
|
||||
let snapshot: Vec<(Arc<Automation>, RunState)> = automations
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|r| (Arc::clone(&r.auto), r.run_state.clone()))
|
||||
.collect();
|
||||
for (automation, run_state) in snapshot {
|
||||
if !automation.enabled {
|
||||
continue;
|
||||
}
|
||||
@@ -60,7 +134,6 @@ impl AutomationEngine {
|
||||
event.old_state.clone(),
|
||||
event.new_state.clone(),
|
||||
);
|
||||
// Check all triggers — fire on first match
|
||||
let triggered = automation
|
||||
.trigger
|
||||
.iter()
|
||||
@@ -68,36 +141,15 @@ impl AutomationEngine {
|
||||
if !triggered {
|
||||
continue;
|
||||
}
|
||||
// Evaluate conditions
|
||||
let sm = Arc::new(hc.states().clone());
|
||||
let eval_ctx = EvalContext::new(sm);
|
||||
let mut conditions_pass = true;
|
||||
for cond in &automation.condition {
|
||||
if !cond.evaluate(&eval_ctx).await {
|
||||
conditions_pass = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if !conditions_pass {
|
||||
// Conditions (with template env wired in — HC-WS-07).
|
||||
let eval_ctx = EvalContext::with_templates(
|
||||
Arc::new(hc.states().clone()),
|
||||
Arc::clone(&templates),
|
||||
);
|
||||
if !conditions_pass(&automation, &eval_ctx).await {
|
||||
continue;
|
||||
}
|
||||
// Execute actions in a spawned task (non-blocking)
|
||||
let auto_clone = Arc::clone(&automation);
|
||||
let hc_clone = hc.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut exec_ctx =
|
||||
ExecutionContext::new(hc_clone, auto_clone.id.clone());
|
||||
for action in &auto_clone.action {
|
||||
if let Err(e) = action.execute(&mut exec_ctx).await {
|
||||
// P1: log errors to stderr; structured logging in P2
|
||||
eprintln!(
|
||||
"[homecore-automation] action error in {}: {e}",
|
||||
auto_clone.id
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
run_state.dispatch(&hc, automation);
|
||||
}
|
||||
}
|
||||
Err(broadcast::error::RecvError::Closed) => break,
|
||||
@@ -108,6 +160,126 @@ impl AutomationEngine {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Wall-clock timer task: fires `time:` triggers (HC-WS-04). Ticks at
|
||||
/// 1 Hz and runs each matching automation once when the local
|
||||
/// wall-clock `HH:MM:SS` equals the trigger's `at`. The task exits
|
||||
/// when the state-machine broadcast channel closes (engine teardown).
|
||||
fn start_timer(&self) -> tokio::task::JoinHandle<()> {
|
||||
let automations = Arc::clone(&self.automations);
|
||||
let hc = self.hc.clone();
|
||||
let templates = Arc::clone(&self.templates);
|
||||
// A receiver that lets the timer notice engine teardown.
|
||||
let mut teardown_rx = self.hc.states().subscribe();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(std::time::Duration::from_millis(1000));
|
||||
// Track the last second we fired, to fire once per match.
|
||||
let mut last_fired_sec: Option<String> = None;
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = interval.tick() => {
|
||||
let now = Local::now();
|
||||
let hhmmss = format!("{:02}:{:02}:{:02}", now.hour(), now.minute(), now.second());
|
||||
if last_fired_sec.as_deref() == Some(hhmmss.as_str()) {
|
||||
continue;
|
||||
}
|
||||
let snapshot: Vec<(Arc<Automation>, RunState)> = automations
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|r| (Arc::clone(&r.auto), r.run_state.clone()))
|
||||
.collect();
|
||||
let mut fired_any = false;
|
||||
for (automation, run_state) in snapshot {
|
||||
if !automation.enabled {
|
||||
continue;
|
||||
}
|
||||
let time_match = automation.trigger.iter().any(|t| match t {
|
||||
Trigger::Time { at } => time_at_matches(at, &hhmmss),
|
||||
_ => false,
|
||||
});
|
||||
if !time_match {
|
||||
continue;
|
||||
}
|
||||
let eval_ctx = EvalContext::with_templates(
|
||||
Arc::new(hc.states().clone()),
|
||||
Arc::clone(&templates),
|
||||
);
|
||||
if !conditions_pass(&automation, &eval_ctx).await {
|
||||
continue;
|
||||
}
|
||||
run_state.dispatch(&hc, automation);
|
||||
fired_any = true;
|
||||
}
|
||||
if fired_any {
|
||||
last_fired_sec = Some(hhmmss);
|
||||
}
|
||||
}
|
||||
r = teardown_rx.recv() => {
|
||||
if let Err(broadcast::error::RecvError::Closed) = r {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Manually fire any `time:` automations whose `at` equals `hhmmss`
|
||||
/// (`"HH:MM:SS"`). Bypasses the 1 Hz clock so tests can assert the
|
||||
/// time-trigger path deterministically without waiting for a
|
||||
/// 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>, RunState)> = self
|
||||
.automations
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|r| (Arc::clone(&r.auto), r.run_state.clone()))
|
||||
.collect();
|
||||
let mut fired = 0usize;
|
||||
for (automation, run_state) in snapshot {
|
||||
if !automation.enabled {
|
||||
continue;
|
||||
}
|
||||
let time_match = automation.trigger.iter().any(|t| match t {
|
||||
Trigger::Time { at } => time_at_matches(at, hhmmss),
|
||||
_ => false,
|
||||
});
|
||||
if !time_match {
|
||||
continue;
|
||||
}
|
||||
let eval_ctx = self.eval_ctx();
|
||||
if !conditions_pass(&automation, &eval_ctx).await {
|
||||
continue;
|
||||
}
|
||||
run_state.dispatch(&self.hc, automation);
|
||||
fired += 1;
|
||||
}
|
||||
fired
|
||||
}
|
||||
}
|
||||
|
||||
/// Evaluate all of an automation's conditions (AND). Empty → pass.
|
||||
async fn conditions_pass(automation: &Automation, eval_ctx: &EvalContext) -> bool {
|
||||
for cond in &automation.condition {
|
||||
if !cond.evaluate(eval_ctx).await {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Does a `Time` trigger `at` value match the current `HH:MM:SS`?
|
||||
/// Accepts `HH:MM` (matches at :00 seconds) and `HH:MM:SS`.
|
||||
fn time_at_matches(at: &str, hhmmss: &str) -> bool {
|
||||
let normalized = match at.matches(':').count() {
|
||||
1 => format!("{at}:00"),
|
||||
_ => at.to_string(),
|
||||
};
|
||||
normalized == hhmmss
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -166,7 +338,6 @@ mod tests {
|
||||
|
||||
let _handle = engine.start();
|
||||
|
||||
// Fire a matching state change
|
||||
hc.states().set(
|
||||
EntityId::parse("switch.living").unwrap(),
|
||||
"on",
|
||||
@@ -174,7 +345,6 @@ mod tests {
|
||||
Context::new(),
|
||||
);
|
||||
|
||||
// Give the async task time to run
|
||||
sleep(Duration::from_millis(50)).await;
|
||||
|
||||
assert_eq!(log.lock().unwrap().len(), 1);
|
||||
@@ -203,7 +373,6 @@ mod tests {
|
||||
|
||||
let _handle = engine.start();
|
||||
|
||||
// Fire on a DIFFERENT entity
|
||||
hc.states().set(
|
||||
EntityId::parse("switch.bedroom").unwrap(),
|
||||
"on",
|
||||
@@ -249,4 +418,16 @@ mod tests {
|
||||
sleep(Duration::from_millis(50)).await;
|
||||
assert_eq!(log.lock().unwrap().len(), 0, "disabled automation should not fire");
|
||||
}
|
||||
|
||||
// Behavioral tests for the timer / run-mode / template paths
|
||||
// (HC-WS-04/05/07) live in `tests/engine_behaviors.rs` to keep this
|
||||
// file under the 500-line guideline; they use only the public API.
|
||||
|
||||
#[test]
|
||||
fn time_at_matches_handles_hh_mm_and_hh_mm_ss() {
|
||||
assert!(time_at_matches("07:30", "07:30:00"));
|
||||
assert!(time_at_matches("07:30:15", "07:30:15"));
|
||||
assert!(!time_at_matches("07:30", "07:30:01"));
|
||||
assert!(!time_at_matches("07:30:15", "07:30:16"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -150,7 +150,12 @@ impl Trigger {
|
||||
true
|
||||
}
|
||||
Trigger::Time { .. } => {
|
||||
// Time triggers are evaluated by the engine's timer task, not here.
|
||||
// Time triggers are wall-clock based and have no state-change
|
||||
// context to match here. They are evaluated by the engine's
|
||||
// 1 Hz timer task (`AutomationEngine::start_timer`, HC-WS-04 /
|
||||
// ADR-161), which compares the trigger's `at` against the local
|
||||
// wall-clock second. `matches_sync` therefore returns false for
|
||||
// `Time` on the state-change path by design.
|
||||
false
|
||||
}
|
||||
Trigger::Event { event_type } => {
|
||||
|
||||
@@ -0,0 +1,418 @@
|
||||
//! Engine behavioral integration tests (ADR-161, HC-WS-04/05/07).
|
||||
//!
|
||||
//! These exercise the `AutomationEngine` runtime through its public API
|
||||
//! only (extracted from the inline module to keep `engine.rs` under the
|
||||
//! 500-line file guideline):
|
||||
//!
|
||||
//! - HC-WS-04 — `time:` triggers fire via the engine timer path.
|
||||
//! - HC-WS-05 — `RunMode::Single` does not double-fire; `Parallel` does.
|
||||
//! - HC-WS-07 — `template:` conditions evaluate against live state in the
|
||||
//! engine path (no longer always-false).
|
||||
//!
|
||||
//! Each fails on the pre-fix engine (no timer task, unbounded-parallel
|
||||
//! regardless of mode, `template_env: None`).
|
||||
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use homecore::service::FnHandler;
|
||||
use homecore::{Context, EntityId, HomeCore, ServiceCall, ServiceName};
|
||||
use homecore_automation::{Action, Automation, AutomationEngine, Condition, RunMode, Trigger};
|
||||
use tokio::time::{sleep, Duration};
|
||||
|
||||
async fn register_recorder(
|
||||
hc: &HomeCore,
|
||||
domain: &str,
|
||||
service: &str,
|
||||
) -> Arc<Mutex<Vec<serde_json::Value>>> {
|
||||
let log: Arc<Mutex<Vec<serde_json::Value>>> = Arc::new(Mutex::new(vec![]));
|
||||
let log2 = Arc::clone(&log);
|
||||
hc.services()
|
||||
.register(
|
||||
ServiceName::new(domain, service),
|
||||
FnHandler(move |call: ServiceCall| {
|
||||
let l = Arc::clone(&log2);
|
||||
async move {
|
||||
l.lock().unwrap().push(call.data.clone());
|
||||
Ok(serde_json::Value::Null)
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
log
|
||||
}
|
||||
|
||||
// ── HC-WS-04: time triggers fire ───────────────────────────────────
|
||||
#[tokio::test]
|
||||
async fn time_trigger_fires_via_timer_path() {
|
||||
let hc = HomeCore::new();
|
||||
let log = register_recorder(&hc, "light", "turn_on").await;
|
||||
|
||||
let engine = AutomationEngine::new(hc.clone());
|
||||
engine.register(Automation::new(
|
||||
"time_auto",
|
||||
vec![Trigger::Time { at: "07:30:00".into() }],
|
||||
vec![Action::ServiceCall {
|
||||
domain: "light".into(),
|
||||
service: "turn_on".into(),
|
||||
data: serde_json::json!({"by": "time"}),
|
||||
}],
|
||||
));
|
||||
|
||||
// Deterministically fire the timer path for the matching second.
|
||||
let fired = engine.fire_time_for_test("07:30:00").await;
|
||||
assert_eq!(fired, 1, "time automation should fire for matching HH:MM:SS");
|
||||
sleep(Duration::from_millis(50)).await;
|
||||
assert_eq!(log.lock().unwrap().len(), 1, "time trigger should run its action");
|
||||
|
||||
// A non-matching second must NOT fire.
|
||||
let none = engine.fire_time_for_test("09:00:00").await;
|
||||
assert_eq!(none, 0);
|
||||
}
|
||||
|
||||
// ── HC-WS-05: RunMode::Single does not double-fire ─────────────────
|
||||
#[tokio::test]
|
||||
async fn single_mode_does_not_double_fire_on_rapid_triggers() {
|
||||
let hc = HomeCore::new();
|
||||
let count = Arc::new(AtomicUsize::new(0));
|
||||
let count2 = Arc::clone(&count);
|
||||
hc.services()
|
||||
.register(
|
||||
ServiceName::new("light", "slow"),
|
||||
FnHandler(move |_call: ServiceCall| {
|
||||
let c = Arc::clone(&count2);
|
||||
async move {
|
||||
c.fetch_add(1, Ordering::SeqCst);
|
||||
sleep(Duration::from_millis(200)).await;
|
||||
Ok(serde_json::Value::Null)
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
let engine = AutomationEngine::new(hc.clone());
|
||||
let mut auto = Automation::new(
|
||||
"single_auto",
|
||||
vec![Trigger::State {
|
||||
entity_id: EntityId::parse("switch.s").unwrap(),
|
||||
from: None,
|
||||
to: None,
|
||||
}],
|
||||
vec![Action::ServiceCall {
|
||||
domain: "light".into(),
|
||||
service: "slow".into(),
|
||||
data: serde_json::json!({}),
|
||||
}],
|
||||
);
|
||||
auto.mode = RunMode::Single;
|
||||
engine.register(auto);
|
||||
let _handle = engine.start();
|
||||
|
||||
// Two rapid triggers while the first run is still sleeping.
|
||||
hc.states().set(EntityId::parse("switch.s").unwrap(), "a", serde_json::json!({}), Context::new());
|
||||
sleep(Duration::from_millis(20)).await;
|
||||
hc.states().set(EntityId::parse("switch.s").unwrap(), "b", serde_json::json!({}), Context::new());
|
||||
|
||||
sleep(Duration::from_millis(350)).await;
|
||||
assert_eq!(
|
||||
count.load(Ordering::SeqCst),
|
||||
1,
|
||||
"Single-mode automation must not double-fire while already running"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn parallel_mode_does_fire_concurrently() {
|
||||
let hc = HomeCore::new();
|
||||
let count = Arc::new(AtomicUsize::new(0));
|
||||
let count2 = Arc::clone(&count);
|
||||
hc.services()
|
||||
.register(
|
||||
ServiceName::new("light", "slow"),
|
||||
FnHandler(move |_call: ServiceCall| {
|
||||
let c = Arc::clone(&count2);
|
||||
async move {
|
||||
c.fetch_add(1, Ordering::SeqCst);
|
||||
sleep(Duration::from_millis(150)).await;
|
||||
Ok(serde_json::Value::Null)
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
let engine = AutomationEngine::new(hc.clone());
|
||||
let mut auto = Automation::new(
|
||||
"parallel_auto",
|
||||
vec![Trigger::State {
|
||||
entity_id: EntityId::parse("switch.p").unwrap(),
|
||||
from: None,
|
||||
to: None,
|
||||
}],
|
||||
vec![Action::ServiceCall {
|
||||
domain: "light".into(),
|
||||
service: "slow".into(),
|
||||
data: serde_json::json!({}),
|
||||
}],
|
||||
);
|
||||
auto.mode = RunMode::Parallel;
|
||||
engine.register(auto);
|
||||
let _handle = engine.start();
|
||||
|
||||
hc.states().set(EntityId::parse("switch.p").unwrap(), "a", serde_json::json!({}), Context::new());
|
||||
sleep(Duration::from_millis(20)).await;
|
||||
hc.states().set(EntityId::parse("switch.p").unwrap(), "b", serde_json::json!({}), Context::new());
|
||||
|
||||
sleep(Duration::from_millis(300)).await;
|
||||
assert_eq!(
|
||||
count.load(Ordering::SeqCst),
|
||||
2,
|
||||
"Parallel-mode automation should fire on every trigger"
|
||||
);
|
||||
}
|
||||
|
||||
// ── HC-WS-07: template conditions evaluate in the engine path ──────
|
||||
#[tokio::test]
|
||||
async fn template_condition_evaluates_true_in_engine() {
|
||||
let hc = HomeCore::new();
|
||||
let log = register_recorder(&hc, "light", "turn_on").await;
|
||||
|
||||
hc.states().set(
|
||||
EntityId::parse("sensor.flag").unwrap(),
|
||||
"on",
|
||||
serde_json::json!({}),
|
||||
Context::new(),
|
||||
);
|
||||
|
||||
let engine = AutomationEngine::new(hc.clone());
|
||||
let mut auto = Automation::new(
|
||||
"tmpl_auto",
|
||||
vec![Trigger::State {
|
||||
entity_id: EntityId::parse("switch.trigger").unwrap(),
|
||||
from: None,
|
||||
to: None,
|
||||
}],
|
||||
vec![Action::ServiceCall {
|
||||
domain: "light".into(),
|
||||
service: "turn_on".into(),
|
||||
data: serde_json::json!({}),
|
||||
}],
|
||||
);
|
||||
auto.condition = vec![Condition::Template {
|
||||
value_template: "{{ is_state('sensor.flag', 'on') }}".into(),
|
||||
}];
|
||||
engine.register(auto);
|
||||
let _handle = engine.start();
|
||||
|
||||
hc.states().set(
|
||||
EntityId::parse("switch.trigger").unwrap(),
|
||||
"go",
|
||||
serde_json::json!({}),
|
||||
Context::new(),
|
||||
);
|
||||
sleep(Duration::from_millis(50)).await;
|
||||
assert_eq!(
|
||||
log.lock().unwrap().len(),
|
||||
1,
|
||||
"template condition should evaluate true and let the action run (HC-WS-07)"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn template_condition_evaluates_false_blocks_action() {
|
||||
let hc = HomeCore::new();
|
||||
let log = register_recorder(&hc, "light", "turn_on").await;
|
||||
hc.states().set(
|
||||
EntityId::parse("sensor.flag").unwrap(),
|
||||
"off",
|
||||
serde_json::json!({}),
|
||||
Context::new(),
|
||||
);
|
||||
|
||||
let engine = AutomationEngine::new(hc.clone());
|
||||
let mut auto = Automation::new(
|
||||
"tmpl_auto_false",
|
||||
vec![Trigger::State {
|
||||
entity_id: EntityId::parse("switch.trigger").unwrap(),
|
||||
from: None,
|
||||
to: None,
|
||||
}],
|
||||
vec![Action::ServiceCall {
|
||||
domain: "light".into(),
|
||||
service: "turn_on".into(),
|
||||
data: serde_json::json!({}),
|
||||
}],
|
||||
);
|
||||
auto.condition = vec![Condition::Template {
|
||||
value_template: "{{ is_state('sensor.flag', 'on') }}".into(),
|
||||
}];
|
||||
engine.register(auto);
|
||||
let _handle = engine.start();
|
||||
|
||||
hc.states().set(
|
||||
EntityId::parse("switch.trigger").unwrap(),
|
||||
"go",
|
||||
serde_json::json!({}),
|
||||
Context::new(),
|
||||
);
|
||||
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)
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
# homecore-migrate — Migration tooling from Python Home Assistant.
|
||||
# Implements ADR-134 (HOMECORE-MIGRATE), P1 scaffold:
|
||||
# Implements ADR-165 (HOMECORE-MIGRATE), P1 scaffold:
|
||||
# (was cited as "ADR-134"; renumbered to ADR-165 — on-disk ADR-134 is CIR. See ADR-164/ADR-165.)
|
||||
# - HaStorageDir + HaStorageEnvelope: reads `.storage/*.json` files
|
||||
# - Versioned format parsers under `storage_format::v<N>`
|
||||
# - entity_registry, device_registry, config_entries parsers
|
||||
@@ -14,7 +15,7 @@ version = "0.1.0-alpha.0"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
authors = ["rUv <ruv@ruv.net>", "HOMECORE Contributors"]
|
||||
description = "Migration tooling from Python Home Assistant to HOMECORE (ADR-134 P1 scaffold)"
|
||||
description = "Migration tooling from Python Home Assistant to HOMECORE (ADR-165 P1 scaffold)"
|
||||
repository = "https://github.com/ruvnet/RuView"
|
||||
|
||||
[[bin]]
|
||||
|
||||
@@ -6,7 +6,7 @@ Migration tooling for importing Home Assistant configuration, entities, and secr
|
||||

|
||||

|
||||
[](https://github.com/ruvnet/RuView)
|
||||
[](../../docs/adr/ADR-134-homecore-migration-from-python-ha.md)
|
||||
[](../../docs/adr/ADR-165-homecore-migrate-from-home-assistant.md)
|
||||
|
||||
Parse and inspect Home Assistant's `.storage/` directory, entity registry, device registry, secrets, and automations. Convert existing HA configurations for import into HOMECORE (full conversion in P2).
|
||||
|
||||
@@ -22,7 +22,7 @@ Parse and inspect Home Assistant's `.storage/` directory, entity registry, devic
|
||||
- **Automations parser** — reads `automations.yaml` and counts/lists automations (full conversion in P2)
|
||||
- **CLI binary** — `homecore-migrate inspect` to preview what will be migrated
|
||||
|
||||
The tool enforces version schema compatibility: unknown HA schema versions are rejected (hard error per ADR-134 §6 Q5) rather than silently corrupting data.
|
||||
The tool enforces version schema compatibility: unknown HA schema versions are rejected (hard error per ADR-165 §6 Q5) rather than silently corrupting data.
|
||||
|
||||
## Features
|
||||
|
||||
@@ -136,7 +136,7 @@ homecore-migrate (import from HA)
|
||||
|
||||
## References
|
||||
|
||||
- [ADR-134: HOMECORE Migration from Python Home Assistant](../../docs/adr/ADR-134-homecore-migration-from-python-ha.md)
|
||||
- [ADR-165: HOMECORE Migration from Python Home Assistant](../../docs/adr/ADR-165-homecore-migrate-from-home-assistant.md)
|
||||
- [ADR-126: HOMECORE Home Assistant Port (master)](../../docs/adr/ADR-126-homecore-home-assistant-port.md)
|
||||
- [Home Assistant .storage/ format](https://developers.home-assistant.io/docs/storage/)
|
||||
- [homecore-migrate CLI source](src/main.rs)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Parser for `core.config_entries` (HA storage schema v1, minor_version varies).
|
||||
//!
|
||||
//! Per ADR-134 §6 Q5, `.storage/core.config_entries` format is undocumented
|
||||
//! Per ADR-165 §6 Q5, `.storage/core.config_entries` format is undocumented
|
||||
//! and version-gated. P1 reads the envelope and emits:
|
||||
//! - count of config entries
|
||||
//! - list of integration domains represented
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
//! homecore-migrate — Migration tooling from Python Home Assistant.
|
||||
//!
|
||||
//! Implements [ADR-134](../../docs/adr/ADR-134-homecore-migration-from-python-ha.md)
|
||||
//! (referenced via ADR-126 §4, series map row ADR-134 HOMECORE-MIGRATE).
|
||||
//! Implements [ADR-165](../../docs/adr/ADR-165-homecore-migrate-from-home-assistant.md)
|
||||
//! (HOMECORE-MIGRATE; ADR-126 §4 series map labels the role "ADR-134 HOMECORE-MIGRATE",
|
||||
//! but on-disk ADR-134 is CIR — the migrate decision was renumbered to ADR-165. See ADR-164).
|
||||
//!
|
||||
//! ## P1 scope
|
||||
//!
|
||||
@@ -56,7 +57,7 @@ pub enum MigrateError {
|
||||
|
||||
/// Fired when the outer `{version, minor_version}` envelope version is
|
||||
/// known but the `minor_version` is not supported by any compiled parser.
|
||||
/// Per ADR-134 §6 Q5: hard error on unknown minor_version.
|
||||
/// Per ADR-165 §6 Q5: hard error on unknown minor_version.
|
||||
#[error(
|
||||
"unsupported schema version in {file}: \
|
||||
version={version} minor_version={minor_version}. \
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! adding a new `v<N>.rs` module; the dispatch function in each parser module
|
||||
//! routes to the right implementation.
|
||||
//!
|
||||
//! Per ADR-134 §6 Q5: unknown `minor_version` values produce a hard
|
||||
//! Per ADR-165 §6 Q5: unknown `minor_version` values produce a hard
|
||||
//! `MigrateError::UnsupportedSchemaVersion` — we do NOT silently fall back
|
||||
//! to an older parser, because schema changes can be load-bearing (new fields,
|
||||
//! renamed keys, semantic reinterpretations).
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -83,15 +83,28 @@ pub struct PluginManifest {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub wasm_module: Option<String>,
|
||||
|
||||
/// [HOMECORE] `sha256:<hex>` hash of the wasm binary; verified before execution.
|
||||
/// [HOMECORE] `sha256:<hex>` hash of the wasm binary.
|
||||
///
|
||||
/// **(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 — 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 — 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>,
|
||||
|
||||
@@ -104,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"));
|
||||
}
|
||||
}
|
||||
@@ -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)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,8 +121,21 @@ async fn main() -> Result<()> {
|
||||
let _ = plugin_registry; // wired-but-empty at boot; integrations register here
|
||||
|
||||
// ── 4. Automation engine ────────────────────────────────────────
|
||||
let _automation_engine = AutomationEngine::new(hc.clone());
|
||||
info!("Automation engine ready (no automations loaded yet)");
|
||||
// Construct AND start the engine (HC-WS-03, ADR-161). `start()`
|
||||
// spawns the state-change event loop + the 1 Hz wall-clock timer
|
||||
// task so state/numeric/event AND time triggers all fire. The
|
||||
// engine is kept alive for the process lifetime (it is moved into a
|
||||
// long-lived binding); its background tasks run until the HomeCore
|
||||
// broadcast channel closes at shutdown. No automations are loaded at
|
||||
// boot yet (YAML loader is P-next); integrations register via
|
||||
// `engine.register(..)`.
|
||||
let automation_engine = AutomationEngine::new(hc.clone());
|
||||
let _automation_task = automation_engine.start();
|
||||
info!(
|
||||
"Automation engine started ({} automations registered) — \
|
||||
state/numeric/event + time triggers active",
|
||||
automation_engine.len()
|
||||
);
|
||||
|
||||
// ── 5. Assist pipeline ──────────────────────────────────────────
|
||||
let recognizer = RegexIntentRecognizer::new();
|
||||
|
||||
@@ -1,16 +1,38 @@
|
||||
//! ASTM F3411 Remote ID broadcast (Basic ID + Location/Vector message).
|
||||
//! ASTM F3411 Remote ID — **Basic ID message only** (ADR-159 §A3).
|
||||
//!
|
||||
//! Only the Basic ID message (`encode_basic_id`) is implemented. The
|
||||
//! Location/Vector message is **not** encoded yet because the drone position is
|
||||
//! tracked in a local NED frame (north/east metres relative to a takeoff datum),
|
||||
//! and a compliant Location/Vector message requires WGS84 latitude/longitude.
|
||||
//! Broadcasting NED metres in lat/lon fields would emit physically-impossible
|
||||
//! coordinates (e.g. "latitude = 12.4 metres"), so we deliberately keep the
|
||||
//! drone position in honest `drone_north_m` / `drone_east_m` fields until a real
|
||||
//! local-tangent-plane NED→WGS84 transform (with an operator datum) lands. See
|
||||
//! the `ACCEPTED-FUTURE` note in ADR-159 §A3.
|
||||
|
||||
use crate::types::DroneState;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Remote ID broadcast state for one drone.
|
||||
///
|
||||
/// Drone position is stored as **NED metres** (`drone_north_m` / `drone_east_m`)
|
||||
/// relative to the operator/takeoff datum — *not* WGS84 lat/lon — because no
|
||||
/// datum-anchored geodetic transform is wired yet. The operator position is true
|
||||
/// WGS84 (it comes from the operator's GNSS, not the local frame).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RemoteIdBroadcast {
|
||||
pub uas_id: [u8; 20], // 20-byte UAS ID (ANSI/CTA-2063-A)
|
||||
/// Operator latitude (WGS84 degrees) — real geodetic position.
|
||||
pub operator_lat: f64,
|
||||
/// Operator longitude (WGS84 degrees) — real geodetic position.
|
||||
pub operator_lon: f64,
|
||||
pub drone_lat: f64,
|
||||
pub drone_lon: f64,
|
||||
/// Drone north offset in **metres** from the operator/takeoff datum (NED x).
|
||||
/// NOT a latitude. See module docs — Location/Vector encoding is deferred
|
||||
/// until a real NED→WGS84 transform exists.
|
||||
pub drone_north_m: f64,
|
||||
/// Drone east offset in **metres** from the operator/takeoff datum (NED y).
|
||||
/// NOT a longitude.
|
||||
pub drone_east_m: f64,
|
||||
pub altitude_msl_m: f32,
|
||||
pub speed_ms: f32,
|
||||
pub heading_deg: f32,
|
||||
@@ -24,8 +46,8 @@ impl RemoteIdBroadcast {
|
||||
uas_id,
|
||||
operator_lat: 0.0,
|
||||
operator_lon: 0.0,
|
||||
drone_lat: 0.0,
|
||||
drone_lon: 0.0,
|
||||
drone_north_m: 0.0,
|
||||
drone_east_m: 0.0,
|
||||
altitude_msl_m: 0.0,
|
||||
speed_ms: 0.0,
|
||||
heading_deg: 0.0,
|
||||
@@ -35,11 +57,15 @@ impl RemoteIdBroadcast {
|
||||
}
|
||||
|
||||
/// Update from a drone state and operator position.
|
||||
///
|
||||
/// The drone position is stored as honest NED metres — we do **not** fake a
|
||||
/// lat/lon from a local-frame offset. The operator position is true WGS84.
|
||||
pub fn update(&mut self, state: &DroneState, operator_pos: (f64, f64)) {
|
||||
// Convert NED position to approximate lat/lon (placeholder — real impl uses WGS84).
|
||||
// We store the NED metres as placeholder values here.
|
||||
self.drone_lat = state.position.x; // placeholder: x ≈ north offset
|
||||
self.drone_lon = state.position.y; // placeholder: y ≈ east offset
|
||||
// NED metres, stored as-is in metre-typed fields (no fabricated geodetic
|
||||
// coordinates). A future Location/Vector encoder must transform these
|
||||
// through a datum-anchored NED→WGS84 projection before broadcast.
|
||||
self.drone_north_m = state.position.x; // NED x = north offset, metres
|
||||
self.drone_east_m = state.position.y; // NED y = east offset, metres
|
||||
self.altitude_msl_m = state.altitude_agl_m as f32;
|
||||
self.speed_ms = state.velocity.magnitude() as f32;
|
||||
self.heading_deg = state.heading_rad.to_degrees() as f32;
|
||||
@@ -80,4 +106,38 @@ mod tests {
|
||||
let buf = rid.encode_basic_id();
|
||||
assert_eq!(buf[2], 0xFF);
|
||||
}
|
||||
|
||||
/// ADR-159 §A3 — a known NED offset must land in honest **metre** fields,
|
||||
/// never in WGS84 lat/lon fields (which would broadcast physically-impossible
|
||||
/// coordinates like "latitude = 37.5 m"). Fails on old code, where the same
|
||||
/// values were stored into `drone_lat`/`drone_lon`.
|
||||
#[test]
|
||||
fn test_ned_offset_stored_as_metres_not_latlon() {
|
||||
use crate::types::{DroneState, NodeId, Position3D};
|
||||
|
||||
let mut state = DroneState::default_at_origin(NodeId(7));
|
||||
// 37.5 m north, -12.0 m east of the takeoff datum.
|
||||
state.position = Position3D {
|
||||
x: 37.5,
|
||||
y: -12.0,
|
||||
z: 5.0,
|
||||
};
|
||||
let mut rid = RemoteIdBroadcast::new([0x41u8; 20]);
|
||||
// Operator at a real WGS84 fix (San Francisco-ish).
|
||||
rid.update(&state, (37.7749, -122.4194));
|
||||
|
||||
// Drone offset is honest NED metres.
|
||||
assert_eq!(rid.drone_north_m, 37.5);
|
||||
assert_eq!(rid.drone_east_m, -12.0);
|
||||
|
||||
// Operator position is the real geodetic fix and is plausibly a lat/lon.
|
||||
assert!((-90.0..=90.0).contains(&rid.operator_lat));
|
||||
assert!((-180.0..=180.0).contains(&rid.operator_lon));
|
||||
assert!((rid.operator_lat - 37.7749).abs() < 1e-9);
|
||||
|
||||
// The drone NED metres would have been an out-of-range "latitude" only
|
||||
// if a value happened to exceed 90 — but the contract is the field name
|
||||
// itself: these are metres, not degrees. A future Location/Vector
|
||||
// encoder must project them through a real NED→WGS84 transform.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "wifi-densepose-hardware"
|
||||
version.workspace = true
|
||||
version = "0.3.1"
|
||||
edition.workspace = true
|
||||
description = "Hardware interface abstractions for WiFi CSI sensors (ESP32, Intel 5300, Atheros)"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "wifi-densepose-mat"
|
||||
version = "0.3.0"
|
||||
version = "0.3.1"
|
||||
edition = "2021"
|
||||
authors = ["rUv <ruv@ruv.net>", "WiFi-DensePose Contributors"]
|
||||
description = "Mass Casualty Assessment Tool - WiFi-based disaster survivor detection"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "wifi-densepose-nn"
|
||||
version.workspace = true
|
||||
version = "0.3.1"
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "wifi-densepose-ruvector"
|
||||
version = "0.3.1" # ADR-138: ClockQualityGate / clock-quality coherence gate
|
||||
version = "0.3.2"
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
@@ -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.1"
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -894,7 +894,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn file_round_trip() {
|
||||
let dir = std::env::temp_dir().join("rvf_test");
|
||||
let dir = std::env::temp_dir().join(format!("rvf_test_{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("test_model.rvf");
|
||||
|
||||
|
||||
@@ -1002,7 +1002,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn rvf_model_file_round_trip() {
|
||||
let dir = std::env::temp_dir().join("rvf_pipeline_test");
|
||||
let dir = std::env::temp_dir().join(format!("rvf_pipeline_test_{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("pipeline_model.rvf");
|
||||
|
||||
|
||||
@@ -1318,7 +1318,7 @@ mod tests {
|
||||
let mut t = Trainer::new(TrainerConfig::default());
|
||||
t.train_epoch(&[sample()]);
|
||||
let ckpt = t.checkpoint();
|
||||
let dir = std::env::temp_dir().join("trainer_ckpt_test");
|
||||
let dir = std::env::temp_dir().join(format!("trainer_ckpt_test_{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("ckpt.json");
|
||||
ckpt.save_to_file(&path).unwrap();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "wifi-densepose-signal"
|
||||
version = "0.3.2" # ADR-137/138/142/143: fuse_scored_calibrated, ArrayCoordinator, evolution, rf_slam, calibration apply
|
||||
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() }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "wifi-densepose-train"
|
||||
version = "0.3.1"
|
||||
version = "0.3.2"
|
||||
edition = "2021"
|
||||
authors = ["rUv <ruv@ruv.net>", "WiFi-DensePose Contributors"]
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "wifi-densepose-vitals"
|
||||
version.workspace = true
|
||||
version = "0.3.1"
|
||||
edition.workspace = true
|
||||
description = "ESP32 CSI-grade vital sign extraction (ADR-021): heart rate and respiratory rate from WiFi Channel State Information"
|
||||
license.workspace = true
|
||||
|
||||
+577
@@ -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,10 +32,24 @@ 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
|
||||
std = ["sha2/std"]
|
||||
# Experimental medical skills (med_seizure_detect, med_cardiac_arrhythmia,
|
||||
# med_respiratory_distress, med_sleep_apnea, med_gait_analysis).
|
||||
#
|
||||
# ⚠️ NON-DEFAULT BY DESIGN. These modules run real DSP but are NOT validated
|
||||
# against clinical data and are NOT medical devices (ADR-160 §A1). They are
|
||||
# gated behind this feature so they cannot be silently built into a shipping
|
||||
# artifact. Build/test with:
|
||||
# cargo test -p wifi-densepose-wasm-edge --features std,medical-experimental
|
||||
medical-experimental = []
|
||||
# Include the default combined pipeline (gesture+coherence+adversarial) entry points.
|
||||
# Disable this when building standalone module binaries (ghost_hunter, etc.)
|
||||
default-pipeline = []
|
||||
|
||||
@@ -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, &s, &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(&s),
|
||||
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, &s, &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(&s),
|
||||
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: &s,
|
||||
variances: &vars,
|
||||
prev_phases: &prev,
|
||||
presence: if (t / 30) % 3 == 0 { 0 } else { 1 },
|
||||
n_persons: ((t / 50) % 3) as i32,
|
||||
motion_energy: 0.3 + 0.2 * (tf * 0.07).sin().abs(),
|
||||
breathing_bpm: 18.0 + 2.0 * (tf * 0.01).sin(),
|
||||
heartrate_bpm: 72.0 + 5.0 * (tf * 0.02).sin(),
|
||||
coherence: 0.5 + 0.4 * (tf * 0.03).cos(),
|
||||
variance_mean: vmean,
|
||||
};
|
||||
|
||||
for e in pipeline.on_frame(&v) {
|
||||
*counts.entry(e.skill).or_insert(0) += 1;
|
||||
// Print the first few events from the last frame to show liveness.
|
||||
if t == frames - 1 {
|
||||
println!(
|
||||
" frame {} | {:<26} event {:>3} = {:.4}",
|
||||
t, e.skill, e.event_id, e.value
|
||||
);
|
||||
}
|
||||
}
|
||||
prev.copy_from_slice(&phases);
|
||||
}
|
||||
|
||||
println!();
|
||||
println!("=== per-skill event totals over {} synthetic frames ===", frames);
|
||||
let total: usize = counts.values().sum();
|
||||
let active = counts.values().filter(|&&c| c > 0).count();
|
||||
for (name, c) in &counts {
|
||||
println!(" {:<28} {}", name, c);
|
||||
}
|
||||
println!();
|
||||
println!(
|
||||
"TOTAL events: {} skills that emitted at least once: {}/{}",
|
||||
total,
|
||||
active,
|
||||
pipeline.skill_count()
|
||||
);
|
||||
}
|
||||
@@ -111,6 +111,8 @@ pub struct BehavioralProfiler {
|
||||
obs_cycles: u32,
|
||||
cooldown: u16,
|
||||
anomaly_count: u32,
|
||||
/// Per-call event scratch buffer (owned; replaces former `static mut`).
|
||||
events: [(i32, f32); 4],
|
||||
}
|
||||
|
||||
impl BehavioralProfiler {
|
||||
@@ -118,6 +120,7 @@ impl BehavioralProfiler {
|
||||
Self {
|
||||
stats: [Welford::new(); N_DIM], obs: ObsWindow::new(),
|
||||
mature: false, frame_count: 0, obs_cycles: 0, cooldown: 0, anomaly_count: 0,
|
||||
events: [(0, 0.0); 4],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,7 +130,6 @@ impl BehavioralProfiler {
|
||||
self.cooldown = self.cooldown.saturating_sub(1);
|
||||
self.obs.push(present, motion, n_persons);
|
||||
|
||||
static mut EV: [(i32, f32); 4] = [(0, 0.0); 4];
|
||||
let mut ne = 0usize;
|
||||
|
||||
if self.frame_count % (OBS_WIN as u32) == 0 && self.obs.len == OBS_WIN {
|
||||
@@ -139,7 +141,7 @@ impl BehavioralProfiler {
|
||||
if self.obs_cycles >= LEARNING_FRAMES / (OBS_WIN as u32) {
|
||||
self.mature = true;
|
||||
let days = self.frame_count as f32 / (20.0 * 86400.0);
|
||||
unsafe { EV[ne] = (EVENT_PROFILE_MATURITY, days); }
|
||||
self.events[ne] = (EVENT_PROFILE_MATURITY, days);
|
||||
ne += 1;
|
||||
}
|
||||
} else {
|
||||
@@ -159,12 +161,12 @@ impl BehavioralProfiler {
|
||||
if self.cooldown == 0 {
|
||||
if cz > ANOMALY_Z {
|
||||
self.anomaly_count += 1;
|
||||
unsafe { EV[ne] = (EVENT_BEHAVIOR_ANOMALY, cz); } ne += 1;
|
||||
if ne < 4 { unsafe { EV[ne] = (EVENT_PROFILE_DEVIATION, max_d as f32); } ne += 1; }
|
||||
self.events[ne] = (EVENT_BEHAVIOR_ANOMALY, cz); ne += 1;
|
||||
if ne < 4 { self.events[ne] = (EVENT_PROFILE_DEVIATION, max_d as f32); ne += 1; }
|
||||
self.cooldown = COOLDOWN;
|
||||
}
|
||||
if hi_z >= NOVEL_MIN && ne < 4 {
|
||||
unsafe { EV[ne] = (EVENT_NOVEL_PATTERN, hi_z as f32); } ne += 1;
|
||||
self.events[ne] = (EVENT_NOVEL_PATTERN, hi_z as f32); ne += 1;
|
||||
if self.cooldown == 0 { self.cooldown = COOLDOWN; }
|
||||
}
|
||||
}
|
||||
@@ -173,10 +175,10 @@ impl BehavioralProfiler {
|
||||
|
||||
// Periodic maturity report.
|
||||
if self.mature && self.frame_count % MATURITY_INTERVAL == 0 && ne < 4 {
|
||||
unsafe { EV[ne] = (EVENT_PROFILE_MATURITY, self.frame_count as f32 / (20.0 * 86400.0)); }
|
||||
self.events[ne] = (EVENT_PROFILE_MATURITY, self.frame_count as f32 / (20.0 * 86400.0));
|
||||
ne += 1;
|
||||
}
|
||||
unsafe { &EV[..ne] }
|
||||
&self.events[..ne]
|
||||
}
|
||||
|
||||
pub fn is_mature(&self) -> bool { self.mature }
|
||||
|
||||
@@ -48,6 +48,8 @@ pub struct PromptShield {
|
||||
cd_replay: u16,
|
||||
cd_inject: u16,
|
||||
cd_jam: u16,
|
||||
/// Per-call event scratch buffer (owned; replaces former `static mut`).
|
||||
events: [(i32, f32); 4],
|
||||
}
|
||||
|
||||
impl PromptShield {
|
||||
@@ -58,6 +60,7 @@ impl PromptShield {
|
||||
baseline_snr: 0.0, cal_amp: 0.0, cal_var: 0.0, cal_n: 0,
|
||||
calibrated: false, low_snr_run: 0, frame_count: 0,
|
||||
cd_replay: 0, cd_inject: 0, cd_jam: 0,
|
||||
events: [(0, 0.0); 4],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,7 +73,6 @@ impl PromptShield {
|
||||
self.cd_inject = self.cd_inject.saturating_sub(1);
|
||||
self.cd_jam = self.cd_jam.saturating_sub(1);
|
||||
|
||||
static mut EV: [(i32, f32); 4] = [(0, 0.0); 4];
|
||||
let mut ne = 0usize;
|
||||
|
||||
// Frame features: mean phase, mean amp, amp variance.
|
||||
@@ -98,7 +100,7 @@ impl PromptShield {
|
||||
}
|
||||
let h = self.fnv1a(m_ph, m_a, a_var);
|
||||
self.push_hash(h);
|
||||
return unsafe { &EV[..0] };
|
||||
return &self.events[..0];
|
||||
}
|
||||
|
||||
// ── 1. Replay ───────────────────────────────────────────────────
|
||||
@@ -106,7 +108,7 @@ impl PromptShield {
|
||||
let replay = self.has_hash(h);
|
||||
self.push_hash(h);
|
||||
if replay && self.cd_replay == 0 {
|
||||
unsafe { EV[ne] = (EVENT_REPLAY_ATTACK, 1.0); }
|
||||
self.events[ne] = (EVENT_REPLAY_ATTACK, 1.0);
|
||||
ne += 1; self.cd_replay = COOLDOWN;
|
||||
}
|
||||
|
||||
@@ -121,7 +123,7 @@ impl PromptShield {
|
||||
jc as f32 / n as f32
|
||||
} else { 0.0 };
|
||||
if inj_f >= INJECTION_FRAC && self.cd_inject == 0 && ne < 4 {
|
||||
unsafe { EV[ne] = (EVENT_INJECTION_DETECTED, inj_f); }
|
||||
self.events[ne] = (EVENT_INJECTION_DETECTED, inj_f);
|
||||
ne += 1; self.cd_inject = COOLDOWN;
|
||||
}
|
||||
|
||||
@@ -133,7 +135,7 @@ impl PromptShield {
|
||||
} else { self.low_snr_run = 0; }
|
||||
if self.low_snr_run >= JAMMING_CONSEC && self.cd_jam == 0 && ne < 4 {
|
||||
let r = if cur_snr > 0.0001 { self.baseline_snr / cur_snr } else { 1000.0 };
|
||||
unsafe { EV[ne] = (EVENT_JAMMING_DETECTED, 10.0 * log10f(r)); }
|
||||
self.events[ne] = (EVENT_JAMMING_DETECTED, 10.0 * log10f(r));
|
||||
ne += 1; self.cd_jam = COOLDOWN;
|
||||
}
|
||||
|
||||
@@ -146,12 +148,12 @@ impl PromptShield {
|
||||
let r = cur_snr / self.baseline_snr;
|
||||
if r < 0.5 { s -= (1.0 - r * 2.0).min(0.3); }
|
||||
}
|
||||
unsafe { EV[ne] = (EVENT_SIGNAL_INTEGRITY, if s < 0.0 { 0.0 } else { s }); }
|
||||
self.events[ne] = (EVENT_SIGNAL_INTEGRITY, if s < 0.0 { 0.0 } else { s });
|
||||
ne += 1;
|
||||
}
|
||||
|
||||
for i in 0..n { self.prev_amps[i] = amps[i]; }
|
||||
unsafe { &EV[..ne] }
|
||||
&self.events[..ne]
|
||||
}
|
||||
|
||||
fn fnv1a(&self, ph: f32, amp: f32, var: f32) -> u32 {
|
||||
|
||||
@@ -290,6 +290,8 @@ static KNOWLEDGE_BASE: [Rule; MAX_RULES] = build_knowledge_base();
|
||||
|
||||
/// Psycho-symbolic inference engine.
|
||||
pub struct PsychoSymbolicEngine {
|
||||
/// Per-call event scratch buffer (owned; replaces former `static mut`).
|
||||
events: [(i32, f32); MAX_EVENTS],
|
||||
/// Bitmap of rules that fired in the current frame.
|
||||
fired_rules: u16,
|
||||
/// Previous frame's winning conclusion ID.
|
||||
@@ -307,6 +309,7 @@ pub struct PsychoSymbolicEngine {
|
||||
impl PsychoSymbolicEngine {
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
events: [(0, 0.0); MAX_EVENTS],
|
||||
fired_rules: 0,
|
||||
prev_conclusion: 0,
|
||||
contradiction_count: 0,
|
||||
@@ -340,7 +343,6 @@ impl PsychoSymbolicEngine {
|
||||
n_persons: f32,
|
||||
time_bucket: f32,
|
||||
) -> &[(i32, f32)] {
|
||||
static mut EVENTS: [(i32, f32); MAX_EVENTS] = [(0, 0.0); MAX_EVENTS];
|
||||
let mut n_events = 0usize;
|
||||
|
||||
self.frame_count += 1;
|
||||
@@ -372,7 +374,7 @@ impl PsychoSymbolicEngine {
|
||||
|
||||
// Emit RULE_FIRED event (up to budget).
|
||||
if n_events < MAX_EVENTS {
|
||||
unsafe { EVENTS[n_events] = (EVENT_RULE_FIRED, i as f32); }
|
||||
self.events[n_events] = (EVENT_RULE_FIRED, i as f32);
|
||||
n_events += 1;
|
||||
}
|
||||
|
||||
@@ -394,7 +396,7 @@ impl PsychoSymbolicEngine {
|
||||
self.contradiction_count += 1;
|
||||
if n_events < MAX_EVENTS {
|
||||
let encoded = (a as f32) * 100.0 + (b as f32);
|
||||
unsafe { EVENTS[n_events] = (EVENT_CONTRADICTION, encoded); }
|
||||
self.events[n_events] = (EVENT_CONTRADICTION, encoded);
|
||||
n_events += 1;
|
||||
}
|
||||
// Suppress the weaker conclusion.
|
||||
@@ -414,10 +416,10 @@ impl PsychoSymbolicEngine {
|
||||
|
||||
// Emit winning inference.
|
||||
if best_confidence > 0.0 && n_events < MAX_EVENTS {
|
||||
unsafe { EVENTS[n_events] = (EVENT_INFERENCE_RESULT, best_conclusion as f32); }
|
||||
self.events[n_events] = (EVENT_INFERENCE_RESULT, best_conclusion as f32);
|
||||
n_events += 1;
|
||||
if n_events < MAX_EVENTS {
|
||||
unsafe { EVENTS[n_events] = (EVENT_INFERENCE_CONFIDENCE, best_confidence); }
|
||||
self.events[n_events] = (EVENT_INFERENCE_CONFIDENCE, best_confidence);
|
||||
n_events += 1;
|
||||
}
|
||||
}
|
||||
@@ -426,7 +428,7 @@ impl PsychoSymbolicEngine {
|
||||
self.prev_motion = motion;
|
||||
self.prev_conclusion = best_conclusion;
|
||||
|
||||
unsafe { &EVENTS[..n_events] }
|
||||
&self.events[..n_events]
|
||||
}
|
||||
|
||||
/// Get the bitmap of rules that fired in the last frame.
|
||||
|
||||
@@ -28,6 +28,8 @@ pub const EVENT_HEALING_COMPLETE: i32 = 888;
|
||||
|
||||
/// Self-healing mesh monitor with Stoer-Wagner min-cut analysis.
|
||||
pub struct SelfHealingMesh {
|
||||
/// Per-call event scratch buffer (owned; replaces former `static mut`).
|
||||
events: [(i32, f32); MAX_EVENTS],
|
||||
/// EMA-smoothed quality score per node [0, 1].
|
||||
node_quality: [f32; MAX_NODES],
|
||||
/// Whether each node quality has received its first sample.
|
||||
@@ -49,6 +51,7 @@ pub struct SelfHealingMesh {
|
||||
impl SelfHealingMesh {
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
events: [(0, 0.0); MAX_EVENTS],
|
||||
node_quality: [0.0; MAX_NODES],
|
||||
node_init: [false; MAX_NODES],
|
||||
adj: [[0.0; MAX_NODES]; MAX_NODES],
|
||||
@@ -76,7 +79,6 @@ impl SelfHealingMesh {
|
||||
/// per active node (length clamped to 8).
|
||||
/// Returns a slice of (event_id, value) pairs.
|
||||
pub fn process_frame(&mut self, node_qualities: &[f32]) -> &[(i32, f32)] {
|
||||
static mut EVENTS: [(i32, f32); MAX_EVENTS] = [(0, 0.0); MAX_EVENTS];
|
||||
let mut ne = 0usize;
|
||||
self.frame_count += 1;
|
||||
|
||||
@@ -84,7 +86,7 @@ impl SelfHealingMesh {
|
||||
self.n_active = n;
|
||||
for i in 0..n { self.update_node_quality(i, node_qualities[i]); }
|
||||
|
||||
if n < 2 { return unsafe { &EVENTS[..0] }; }
|
||||
if n < 2 { return &self.events[..0]; }
|
||||
|
||||
// Build adjacency: edge weight = min(quality_i, quality_j).
|
||||
for i in 0..n {
|
||||
@@ -101,7 +103,7 @@ impl SelfHealingMesh {
|
||||
for i in 0..n { sum += self.node_quality[i]; }
|
||||
let coverage = sum / (n as f32);
|
||||
if ne < MAX_EVENTS {
|
||||
unsafe { EVENTS[ne] = (EVENT_COVERAGE_SCORE, coverage); }
|
||||
self.events[ne] = (EVENT_COVERAGE_SCORE, coverage);
|
||||
ne += 1;
|
||||
}
|
||||
|
||||
@@ -112,24 +114,24 @@ impl SelfHealingMesh {
|
||||
if !self.healing { self.healing = true; }
|
||||
self.weakest = cut_node;
|
||||
if ne < MAX_EVENTS {
|
||||
unsafe { EVENTS[ne] = (EVENT_NODE_DEGRADED, cut_node as f32); }
|
||||
self.events[ne] = (EVENT_NODE_DEGRADED, cut_node as f32);
|
||||
ne += 1;
|
||||
}
|
||||
if ne < MAX_EVENTS {
|
||||
unsafe { EVENTS[ne] = (EVENT_MESH_RECONFIGURE, mincut); }
|
||||
self.events[ne] = (EVENT_MESH_RECONFIGURE, mincut);
|
||||
ne += 1;
|
||||
}
|
||||
} else if self.healing && mincut >= MINCUT_HEALTHY {
|
||||
self.healing = false;
|
||||
self.weakest = NO_NODE;
|
||||
if ne < MAX_EVENTS {
|
||||
unsafe { EVENTS[ne] = (EVENT_HEALING_COMPLETE, mincut); }
|
||||
self.events[ne] = (EVENT_HEALING_COMPLETE, mincut);
|
||||
ne += 1;
|
||||
}
|
||||
}
|
||||
|
||||
self.prev_mincut = mincut;
|
||||
unsafe { &EVENTS[..ne] }
|
||||
&self.events[..ne]
|
||||
}
|
||||
|
||||
/// Simplified Stoer-Wagner min-cut for n <= 8 nodes.
|
||||
|
||||
@@ -59,6 +59,8 @@ pub enum DoorState {
|
||||
|
||||
/// Elevator occupancy counter.
|
||||
pub struct ElevatorCounter {
|
||||
/// Per-call event scratch buffer (owned; replaces former `static mut`).
|
||||
events: [(i32, f32); 4],
|
||||
/// Baseline amplitude per subcarrier (empty cabin).
|
||||
baseline_amp: [f32; MAX_SC],
|
||||
/// Baseline variance per subcarrier.
|
||||
@@ -93,6 +95,7 @@ pub struct ElevatorCounter {
|
||||
impl ElevatorCounter {
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
events: [(0, 0.0); 4],
|
||||
baseline_amp: [0.0; MAX_SC],
|
||||
baseline_var: [0.0; MAX_SC],
|
||||
prev_amp: [0.0; MAX_SC],
|
||||
@@ -268,15 +271,12 @@ impl ElevatorCounter {
|
||||
}
|
||||
|
||||
// ── Build events ────────────────────────────────────────────────
|
||||
static mut EVENTS: [(i32, f32); 4] = [(0, 0.0); 4];
|
||||
let mut n_events = 0usize;
|
||||
|
||||
// Door events (immediate).
|
||||
if let Some(evt) = door_event {
|
||||
if n_events < 4 {
|
||||
unsafe {
|
||||
EVENTS[n_events] = (evt, self.count as f32);
|
||||
}
|
||||
self.events[n_events] = (evt, self.count as f32);
|
||||
n_events += 1;
|
||||
}
|
||||
}
|
||||
@@ -284,22 +284,18 @@ impl ElevatorCounter {
|
||||
// Periodic count and overload.
|
||||
if self.frame_count % EMIT_INTERVAL == 0 {
|
||||
if n_events < 4 {
|
||||
unsafe {
|
||||
EVENTS[n_events] = (EVENT_ELEVATOR_COUNT, self.count as f32);
|
||||
}
|
||||
self.events[n_events] = (EVENT_ELEVATOR_COUNT, self.count as f32);
|
||||
n_events += 1;
|
||||
}
|
||||
|
||||
// Overload warning.
|
||||
if self.count >= self.overload_thresh && n_events < 4 {
|
||||
unsafe {
|
||||
EVENTS[n_events] = (EVENT_OVERLOAD_WARNING, self.count as f32);
|
||||
}
|
||||
self.events[n_events] = (EVENT_OVERLOAD_WARNING, self.count as f32);
|
||||
n_events += 1;
|
||||
}
|
||||
}
|
||||
|
||||
unsafe { &EVENTS[..n_events] }
|
||||
&self.events[..n_events]
|
||||
}
|
||||
|
||||
/// Get current occupant count estimate.
|
||||
|
||||
@@ -77,6 +77,8 @@ impl HourBin {
|
||||
|
||||
/// Energy audit analyzer.
|
||||
pub struct EnergyAuditor {
|
||||
/// Per-call event scratch buffer (owned; replaces former `static mut`).
|
||||
events: [(i32, f32); 3],
|
||||
/// Weekly histogram: [day][hour].
|
||||
histogram: [[HourBin; HOURS_PER_DAY]; DAYS_PER_WEEK],
|
||||
/// Current simulated hour (0-23). In production, derived from host timestamp.
|
||||
@@ -98,6 +100,7 @@ impl EnergyAuditor {
|
||||
const BIN_INIT: HourBin = HourBin::new();
|
||||
const DAY_INIT: [HourBin; HOURS_PER_DAY] = [BIN_INIT; HOURS_PER_DAY];
|
||||
Self {
|
||||
events: [(0, 0.0); 3],
|
||||
histogram: [DAY_INIT; DAYS_PER_WEEK],
|
||||
current_hour: 8, // Default start: 8 AM.
|
||||
current_day: 0, // Monday.
|
||||
@@ -161,14 +164,11 @@ impl EnergyAuditor {
|
||||
}
|
||||
|
||||
// Build events.
|
||||
static mut EVENTS: [(i32, f32); 3] = [(0, 0.0); 3];
|
||||
let mut n_events = 0usize;
|
||||
|
||||
// After-hours alert.
|
||||
if self.after_hours_presence >= AFTER_HOURS_ALERT_FRAMES && n_events < 3 {
|
||||
unsafe {
|
||||
EVENTS[n_events] = (EVENT_AFTER_HOURS_ALERT, self.current_hour as f32);
|
||||
}
|
||||
self.events[n_events] = (EVENT_AFTER_HOURS_ALERT, self.current_hour as f32);
|
||||
n_events += 1;
|
||||
}
|
||||
|
||||
@@ -177,23 +177,19 @@ impl EnergyAuditor {
|
||||
// Emit current hour's occupancy rate.
|
||||
let rate = self.histogram[d][h].occupancy_rate();
|
||||
if n_events < 3 {
|
||||
unsafe {
|
||||
EVENTS[n_events] = (EVENT_SCHEDULE_SUMMARY, rate);
|
||||
}
|
||||
self.events[n_events] = (EVENT_SCHEDULE_SUMMARY, rate);
|
||||
n_events += 1;
|
||||
}
|
||||
|
||||
// Emit overall utilization rate.
|
||||
if n_events < 3 {
|
||||
let util = self.utilization_rate();
|
||||
unsafe {
|
||||
EVENTS[n_events] = (EVENT_UTILIZATION_RATE, util);
|
||||
}
|
||||
self.events[n_events] = (EVENT_UTILIZATION_RATE, util);
|
||||
n_events += 1;
|
||||
}
|
||||
}
|
||||
|
||||
unsafe { &EVENTS[..n_events] }
|
||||
&self.events[..n_events]
|
||||
}
|
||||
|
||||
/// Check if a given hour is after-hours.
|
||||
|
||||
@@ -57,6 +57,8 @@ pub enum ActivityLevel {
|
||||
|
||||
/// HVAC-optimized presence detector.
|
||||
pub struct HvacPresenceDetector {
|
||||
/// Per-call event scratch buffer (owned; replaces former `static mut`).
|
||||
events: [(i32, f32); 3],
|
||||
state: HvacState,
|
||||
/// Smoothed motion energy (EMA).
|
||||
motion_ema: f32,
|
||||
@@ -73,6 +75,7 @@ pub struct HvacPresenceDetector {
|
||||
impl HvacPresenceDetector {
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
events: [(0, 0.0); 3],
|
||||
state: HvacState::Vacant,
|
||||
motion_ema: 0.0,
|
||||
activity: ActivityLevel::Sedentary,
|
||||
@@ -159,7 +162,6 @@ impl HvacPresenceDetector {
|
||||
}
|
||||
|
||||
// Build output events.
|
||||
static mut EVENTS: [(i32, f32); 3] = [(0, 0.0); 3];
|
||||
let mut n = 0usize;
|
||||
|
||||
if self.frame_count % EMIT_INTERVAL == 0 {
|
||||
@@ -168,9 +170,7 @@ impl HvacPresenceDetector {
|
||||
HvacState::Occupied | HvacState::DeparturePending => 1.0,
|
||||
_ => 0.0,
|
||||
};
|
||||
unsafe {
|
||||
EVENTS[n] = (EVENT_HVAC_OCCUPIED, occupied_val);
|
||||
}
|
||||
self.events[n] = (EVENT_HVAC_OCCUPIED, occupied_val);
|
||||
n += 1;
|
||||
|
||||
// Activity level: 0.0 = sedentary, 1.0 = active, plus raw EMA.
|
||||
@@ -178,9 +178,7 @@ impl HvacPresenceDetector {
|
||||
ActivityLevel::Sedentary => 0.0 + self.motion_ema.min(0.99),
|
||||
ActivityLevel::Active => 1.0,
|
||||
};
|
||||
unsafe {
|
||||
EVENTS[n] = (EVENT_ACTIVITY_LEVEL, activity_val);
|
||||
}
|
||||
self.events[n] = (EVENT_ACTIVITY_LEVEL, activity_val);
|
||||
n += 1;
|
||||
}
|
||||
|
||||
@@ -191,13 +189,11 @@ impl HvacPresenceDetector {
|
||||
{
|
||||
let remaining = DEPARTURE_TIMEOUT.saturating_sub(self.absence_frames);
|
||||
let fraction = remaining as f32 / DEPARTURE_TIMEOUT as f32;
|
||||
unsafe {
|
||||
EVENTS[n] = (EVENT_DEPARTURE_COUNTDOWN, fraction);
|
||||
}
|
||||
self.events[n] = (EVENT_DEPARTURE_COUNTDOWN, fraction);
|
||||
n += 1;
|
||||
}
|
||||
|
||||
unsafe { &EVENTS[..n] }
|
||||
&self.events[..n]
|
||||
}
|
||||
|
||||
/// Get current HVAC state.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user