Compare commits

..

22 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-12 01:33:52 -04:00
rUv b8e870b314 Merge pull request #1025 from ruvnet/feat/v2-beyond-sota-sweep-m7
Beyond-SOTA sweep M7 (ADR-161): HOMECORE WS auth-bypass fix + automation engine + security
2026-06-12 01:15:42 -04:00
ruv d1328b0299 test(homecore-api): serialize HOMECORE_CORS_ORIGINS env tests (fix parallel race)
env_override_* and env_empty_* both set_var/remove_var the same process-global
HOMECORE_CORS_ORIGINS; under full-workspace parallelism they raced (one's
remove_var wiped the other's value mid-assert). Serialize via a poison-tolerant
module Mutex. Test-only.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-12 01:00:58 -04:00
ruv d0da5888e3 docs(adr): ADR-161 — HOMECORE server-layer security & honest-labeling sweep (M7)
Records the Milestone 7 audit: library cores are real (anti-slop positive) but
the network boundary had a CRITICAL WS auth bypass (A1) + reply-theater (A2) +
documented-but-no-op automation (A3-A7) + a network-exposed dev bin (A8), all
fixed and graded MEASURED with failing-on-old tests. Cites the NO-ACTION
security positives (uuid::v4 CSPRNG refuted-suspicion, hardened CORS,
no-traversal migrate, no-secrets-in-logs, honest HAP stub) and the deferred
backlog (plugin authority-isolation P5, sig-verification P4, HAP real pairing
P2, bounded run-modes, YAML load-at-boot).

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-12 00:55:52 -04:00
ruv e51704cd25 docs(homecore-plugins): label sig/hash fields '(P4 - not yet enforced)' (ADR-161 B5)
manifest.rs documented wasm_module_hash as 'verified before execution' but
wasm_module_hash/wasm_module_sig/publisher_key are never read for verification
(only set to None in tests). Re-doc'd the three fields as P4-not-yet-enforced
so the doc matches the code. No verification code added (that is P4); no false
capability claimed.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-12 00:55:51 -04:00
ruv dff75a479e fix(homecore-automation): start engine + implement time/run-mode/choose/template (ADR-161 A3-A7)
A3 (HIGH): homecore-server constructed AutomationEngine then dropped it
immediately while the doc claimed automation was active. Now .start()s the
engine into a long-lived binding (event loop + timer task).

A4 (HIGH): Trigger::Time was hard-coded false with no timer. Added a 1 Hz
wall-clock timer task that fires time: automations when local HH:MM:SS matches
'at' (HH:MM or HH:MM:SS); matches_sync(Time)=false is now correct + documented.

A5 (HIGH): RunMode was documented as AtomicBool-enforced but every trigger
spawned unbounded parallel. Each automation now carries a running AtomicBool;
Single/IgnoreFirst skip re-entrant triggers, Parallel fires every time.
(Bounded Queued/Restart/max → ACCEPTED-FUTURE, honestly stated in the doc.)

A6 (HIGH): Action::Choose discarded choices and always ran default. Now
deserialises each branch's conditions, evaluates them, and runs the first
matching branch; default only if none match.

A7 (MEDIUM): template: conditions were always false in the engine path
(EvalContext built with template_env: None). The engine now builds a
TemplateEnvironment over the state machine and threads it into every
EvalContext (event loop, timer, Choose).

Tests (fail on old source):
- engine_behaviors::time_trigger_fires_via_timer_path (A4)
- engine_behaviors::single_mode_does_not_double_fire_on_rapid_triggers (A5; old fired 2x)
- engine_behaviors::parallel_mode_does_fire_concurrently (A5)
- action::choose_runs_matching_branch_not_default (A6; old ran default)
- engine_behaviors::template_condition_evaluates_true_in_engine (A7; old always false)

engine.rs kept <500 lines; behavioral tests moved to tests/engine_behaviors.rs.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-12 00:55:34 -04:00
ruv 9d52d49c0b fix(homecore-api): close WS auth bypass + reply-theater, harden dev bin (ADR-161 A1/A2/A8)
A1 (CRITICAL): the /api/websocket handshake accepted any non-empty token,
ignoring the LongLivedTokenStore whitelist the REST path enforces — a full
WS auth bypass. Now validates via state.tokens().is_valid() before auth_ok;
wrong tokens get auth_invalid + close.

A2 (HIGH): WS command replies were pushed into an mpsc whose only consumer
logged and discarded them — no result/pong/event reached the client. Split
the socket with futures StreamExt::split; a dedicated writer task drains the
response channel onto the wire.

A8 (HIGH): the homecore-api dev bin bound 0.0.0.0 with unconditional
allow-any auth and no env path. Wired the HOMECORE_TOKENS env path (dev
fallback warn-logged when unset) and defaulted the bind to 127.0.0.1
(HOMECORE_BIND to opt into LAN).

Tests (fail on old source):
- ws_handshake::wrong_token_is_rejected (old → auth_ok)
- ws_handshake::result_reply_is_received / ping_pong_reply_is_received (old → timeout)
- server_bin_auth::provisioned_bin_rejects_wrong_bearer / from_env_path_enforces_whitelist

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-12 00:55:16 -04:00
rUv d0a7690f8f Merge pull request #1024 from ruvnet/feat/v2-beyond-sota-sweep-m5
Beyond-SOTA sweep M5–M6 (ADR-159/160): appliance + edge-skill honesty + crates.io publish
2026-06-12 00:39:21 -04:00
ruv 8487192d0f docs(proof): PROOF.md capstone + scripts/prove.sh reproduction harness
One-command harness: clone, run scripts/prove.sh, and every headline claim is
either verified on your machine (re-runs the bug-catching tests) or printed as
'CLAIMED — not reproduced here' with the exact prerequisite. Hard gate =
workspace tests + deterministic Python proof; section 3 re-runs 7 anti-slop
assertion tests (each fails on pre-fix code); gated claims (GPU/dataset/hardware/
trained-checkpoint/named-identity) are honestly listed, never faked.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-12 00:19:43 -04:00
ruv d120cc2278 test(sensing-server): unique per-process temp dirs (deterministic under concurrent runs)
checkpoint_round_trip / rvf_test / rvf_pipeline_test shared fixed temp_dir paths
and remove_dir at teardown, so two concurrent/repeated test runs raced (one's
teardown wiped the other's file -> NotFound). Make each dir process-unique.
Test-only; no public API change.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-12 00:11:24 -04:00
ruv 8ad0d0f91c test+docs(wasm-edge): honest-labeling presence tests + ADR-160 (ADR-159 backlog now TRUE)
- tests/honest_labeling.rs: 10 source-presence tests asserting the A1-A5 claim
  invariants (disclaimers present, uncited stat removed, WEAPON_ALERT no longer
  exported, med_* feature-gated, no static-mut event buffers). Each is designed to
  FAIL on the pre-fix source (ADR-159 A5 manifest-roundtrip style).
- ADR-160: records the headline (0 stubs/0 theater, all real DSP -> claim-surface
  honesty debt), the graded A1-A5 fixes, NO-ACTION positives, per-prefix
  classification, and the DATA-GATED deferred backlog (criterion benches,
  per-skill accuracy validation, wasm32 static_mut_refs CI confirmation).
- ADR-159: its deferred-backlog line "wasm-edge ... honestly labelled, not claimed"
  is now actually TRUE.

Validation (all 0 failed, host --features std):
  DEFAULT 615 | MEDICAL (+medical-experimental) 653 | NO-DEFAULT 615; 0 warnings.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-12 00:01:22 -04:00
ruv 36af09a4a8 feat(wasm-edge): honest labeling + static-mut soundness for edge skills (ADR-160)
The wasm-edge skill library runs real DSP with 0 stubs / 0 theater; the exposure
is an over-confident claim surface on unvalidated skills plus a latent static-mut
soundness issue. Make the labels TRUE (do not pretend to validate the capability)
and fix the soundness mechanically:

- A1 (HIGH): med_seizure/cardiac/respiratory/sleep_apnea/gait -- add mandatory
  "EXPERIMENTAL / NOT VALIDATED AGAINST CLINICAL DATA / NOT A MEDICAL DEVICE"
  disclaimers, soften assertive verbs to "flags candidate <X>-like signatures",
  and gate all 5 behind a NON-default medical-experimental cargo feature so they
  cannot be silently shipped. DSP kept.
- A2 (HIGH): exo_happiness_score/exo_emotion_detect -- delete the uncited
  "~12% faster" stat, add "speculative, unvalidated affect heuristic; outputs are
  NOT measurements of emotion" disclaimers, reframe HAPPINESS_SCORE as a
  gait-energy proxy. Math kept.
- A3 (MEDIUM): sec_weapon_detect -- rename EVENT_WEAPON_ALERT ->
  EVENT_HIGH_METAL_REFLECTIVITY and WEAPON_RATIO_THRESH -> HIGH_REFLECTIVITY_THRESH
  (a variance ratio measures reflectivity, not weapons). Registry updated.
- A4 (MEDIUM): exo_dream_stage/exo_gesture_language -- add experimental
  disclaimers, promote the Exotic/Research tag into the header.
- A5 (MEDIUM, soundness): replace ~61 `static mut EVENTS`/EV/TE/EMPTY per-call
  scratch buffers (60 modules) with owned per-instance `events` fields returned as
  `&self.events[..n]`. Public signature unchanged; behavior preserved. Only the
  two legitimate single-threaded WASM module singletons (lib.rs STATE,
  ghost_hunter DETECTOR) remain as static mut. Removes the static_mut_refs source.

NO-ACTION positives (cited, labels untouched): qnt_* (quantum-/Grover-inspired,
disclosed), exo_time_crystal, exo_ghost_hunter, sig_*/lrn_* algorithm-named skills.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-12 00:01:04 -04:00
ruv 772ece4568 docs(adr): ADR-159 Cognitum appliance beyond-SOTA sweep
Records the anti-AI-slop sweep over cog-person-count, cog-pose-estimation,
cog-ha-matter, ruview-swarm. HEADLINE: the "never identified anyone"
accusation is REFUTED (real SHA-pinned Ed25519-signed trained Candle
models, honest 34%/3% accuracy in manifests). Documents claim-surface
fixes A1-A5 (MEASURED), NO-ACTION positives (witness chain, fusion, PPO +
randn audit), graded SOTA landscape (counting/pose DATA-GATED, swarm MARL
untrained-at-runtime by design), and the deferred backlog (benches,
Location/Vector, Matter v0.8, wasm-edge accuracy).

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-11 23:10:03 -04:00
ruv 48b002fa7e docs(cog-ha-matter): stop claiming Matter until it exists (ADR-159 A5)
Matter commissioning is deferred to v0.8 (TlsConfig::Off, LAN-only, per
tls_defaults_to_off_for_v1_lan_only). Soften the Cargo.toml description
from "Home Assistant + Matter integration" to "Home Assistant (MQTT)
integration ... Matter Bridge commissioning is deferred to v0.8 and not
yet implemented" (honest-absence, ADR-158 pattern). No code change.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-11 23:10:02 -04:00
ruv 8d9c5994db fix(ruview-swarm): honest NED metres in Remote ID, not WGS84 (ADR-159 A3)
RemoteIdBroadcast::update stored NED metres (state.position.x/.y) into
drone_lat/drone_lon, so the ASTM F3411 broadcast would carry physically
-impossible coordinates ("latitude = 37.5 m"). The module doc claimed a
Location/Vector message but only encode_basic_id() exists.

- Rename drone_lat/drone_lon -> drone_north_m/drone_east_m (NED metres
  relative to the operator/takeoff datum), documented as non-geodetic.
  operator_lat/lon stay true WGS84.
- Correct the module doc to claim Basic ID only; Location/Vector encoding
  is deferred until a datum-anchored NED->WGS84 transform lands.

Never broadcast physically-impossible coordinates.

Failing-on-old test:
security::remote_id::tests::test_ned_offset_stored_as_metres_not_latlon.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-11 23:10:02 -04:00
ruv 6b5fd3cf25 fix(cog-person-count): emit real signed manifest from CLI (ADR-159 A4)
cmd_manifest emitted a null skeleton (binary_sha256: null) while the
real signed manifest existed on disk at
cog/artifacts/manifests/<arch>/manifest.json.

- New manifest module include_str!-embeds the real signed manifests
  (x86_64 + arm), selected by build target arch.
- cmd_manifest parses-then-emits the embedded signed manifest, mirroring
  cog-pose-estimation manifest_roundtrips. 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 (+
embedded_manifest_is_signed, embedded_manifest_id_matches_cog).
Verified end-to-end: cog-person-count manifest -> non-null sha256.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-11 23:10:01 -04:00
ruv 2400216920 fix(cog-person-count): flag untrained-class counts low_confidence (ADR-159 A2)
The count head has 8 classes but count_train_results.json only has
support for classes 0/1 (presence, not multi-occupant counting). An
argmax on classes 2..=7 is out-of-distribution, yet the cog emitted it
as a confident headcount and the crate billed itself a "multi-person
counter".

- Add MAX_TRAINED_CLASS=1, CountPrediction::is_low_confidence() and
  clamped_count().
- person.count events now carry low_confidence + raw_count, downgrade to
  level "warn" when OOD, and clamp the reported count to the trained
  range (no fabricated headcount).
- run.started discloses count_max_trained_class / count_classes.
- Cargo.toml description: "multi-person counter" ->
  "presence detector + (data-gated) person count".

Multi-occupant accuracy stays DATA-GATED (not fabricated).

Failing-on-old test: untrained_class_argmax_is_flagged_low_confidence.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-11 23:10:01 -04:00
ruv 98bf8c4726 fix(cog-pose-estimation): emit frames under default config (ADR-159 A1)
pose_v1 has no confidence head, so infer() emits a constant 0.185 per
frame. The config default_min_confidence was 0.3 and the runtime gates
on confidence >= min_confidence, so a default install silently emitted
ZERO pose.frame events while health reported healthy.

- Add inference::MODEL_TYPICAL_CONFIDENCE (0.185, the validation PCK@50)
  as the single published per-frame confidence.
- Pin default_min_confidence() to MODEL_TYPICAL_CONFIDENCE so a default
  install clears its own gate and emits.
- Warn at run.started when min_confidence exceeds the model typical
  confidence (disclosed, not silent); document the trade-off in the
  config field, the JSON schema, and inference.rs.

Failing-on-old test: default_config_emits_frames_with_real_model
(with old 0.3 it panics: "default install would emit zero pose.frame
events").

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-11 23:10:00 -04:00
ruv 2e4461d64d release: bump 9 crates changed in the beyond-SOTA sweep for crates.io
vitals/wifiscan/hardware/nn 0.3.0->0.3.1, ruvector 0.3.1->0.3.2,
signal 0.3.2->0.3.3, train 0.3.1->0.3.2, mat 0.3.0->0.3.1,
sensing-server 0.3.1->0.3.2.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-11 22:41:21 -04:00
118 changed files with 4807 additions and 915 deletions
+75
View File
@@ -0,0 +1,75 @@
# 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.03.1×, DTW band 2.44.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` |
## 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-160`), a
test, a criterion bench, or `benchmarks/wiflow-std/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,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 (A1A4) 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,228 @@
# 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** — **DATA-GATED**. Validating any med_*/affect/
sign-language claim requires labelled clinical/affective/ASL data and reference
standards that do not exist in this repo. The disclaimers + feature gate are the
honest stand-in. Nothing is claimed that is not measured.
- **Criterion benches for `process_frame` budget claims** — **ACCEPTED-FUTURE**.
`tests/budget_compliance.rs` asserts L/S/H tier wall-clock budgets (25 tests,
passing), but a regression-grade criterion bench is not yet wired.
- **`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 # A1A5 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 A4A7
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 (A3A7).
- 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).
+146
View File
@@ -0,0 +1,146 @@
#!/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"
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
+15 -9
View File
@@ -3472,6 +3472,7 @@ dependencies = [
"axum",
"chrono",
"dashmap",
"futures-util",
"homecore",
"http-body-util",
"hyper 1.8.1",
@@ -3479,6 +3480,7 @@ dependencies = [
"serde_json",
"thiserror 1.0.69",
"tokio",
"tokio-tungstenite",
"tower 0.5.3",
"tower-http",
"tracing",
@@ -3552,9 +3554,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 +10939,7 @@ dependencies = [
[[package]]
name = "wifi-densepose-hardware"
version = "0.3.0"
version = "0.3.1"
dependencies = [
"approx",
"byteorder",
@@ -10953,7 +10959,7 @@ dependencies = [
[[package]]
name = "wifi-densepose-mat"
version = "0.3.0"
version = "0.3.1"
dependencies = [
"anyhow",
"approx",
@@ -10985,7 +10991,7 @@ dependencies = [
[[package]]
name = "wifi-densepose-nn"
version = "0.3.0"
version = "0.3.1"
dependencies = [
"anyhow",
"candle-core 0.4.1",
@@ -11039,7 +11045,7 @@ dependencies = [
[[package]]
name = "wifi-densepose-ruvector"
version = "0.3.1"
version = "0.3.2"
dependencies = [
"approx",
"criterion",
@@ -11059,7 +11065,7 @@ dependencies = [
[[package]]
name = "wifi-densepose-sensing-server"
version = "0.3.1"
version = "0.3.2"
dependencies = [
"axum",
"chrono",
@@ -11093,7 +11099,7 @@ dependencies = [
[[package]]
name = "wifi-densepose-signal"
version = "0.3.2"
version = "0.3.3"
dependencies = [
"chrono",
"criterion",
@@ -11120,7 +11126,7 @@ dependencies = [
[[package]]
name = "wifi-densepose-train"
version = "0.3.1"
version = "0.3.2"
dependencies = [
"anyhow",
"approx",
@@ -11158,7 +11164,7 @@ dependencies = [
[[package]]
name = "wifi-densepose-vitals"
version = "0.3.0"
version = "0.3.1"
dependencies = [
"criterion",
"serde",
@@ -11190,7 +11196,7 @@ dependencies = [
[[package]]
name = "wifi-densepose-wifiscan"
version = "0.3.0"
version = "0.3.1"
dependencies = [
"serde",
"tokio",
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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"
@@ -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;
+1
View File
@@ -9,6 +9,7 @@
pub mod fusion;
pub mod inference;
pub mod manifest;
pub mod publisher;
pub mod runtime;
+5 -14
View File
@@ -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));
}
}
+17 -2
View File
@@ -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,
+46 -1
View File
@@ -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];
@@ -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"]
+10 -1
View File
@@ -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 {
+17 -4
View File
@@ -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,
})
}
}
+12
View File
@@ -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
);
}
}
+4
View File
@@ -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 }
+7
View File
@@ -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("");
+48 -8
View File
@@ -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?;
+79 -45
View File
@@ -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);
}
+167 -4
View File
@@ -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");
}
}
+221 -40
View File
@@ -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"));
}
}
+1
View File
@@ -19,6 +19,7 @@ pub mod condition;
pub mod action;
pub mod template;
pub mod engine;
pub mod runmode;
pub mod error;
pub use automation::{Automation, RunMode};
@@ -0,0 +1,153 @@
//! Per-automation run-mode machinery (ADR-162, completes ADR-161 §A5).
//!
//! ADR-161 implemented `RunMode::Single` (a per-automation `AtomicBool`
//! re-entrancy guard) and `Parallel`, but honestly left `Restart`, `Queued`
//! and `max: N` as "ACCEPTED-FUTURE / unbounded parallel" — every non-Single
//! mode spawned an unbounded task. This module makes them real:
//!
//! | Mode | Semantics implemented |
//! |------|-----------------------|
//! | `Single` / `IgnoreFirst` | re-entrancy guard: skip while a run is in flight (ADR-161). |
//! | `Restart` | **cancel** the in-flight run (`tokio::task::AbortHandle`) and start a fresh one. |
//! | `Queued` | **serialize**: runs execute sequentially in arrival order via a per-automation async mutex — nothing is dropped. |
//! | `Parallel` | spawn on every trigger (optionally capped, see below). |
//! | `max: N` | cap concurrency at **N** via a per-automation semaphore; triggers beyond N **queue** (await a permit) rather than running concurrently — matching HA's bounded `parallel`/`queued`. |
//!
//! Each registered automation owns one [`RunState`]; the engine calls
//! [`RunState::dispatch`] on every (trigger + conditions-passed) event.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use tokio::sync::{Mutex as AsyncMutex, Semaphore};
use homecore::HomeCore;
use crate::action::ExecutionContext;
use crate::automation::{Automation, RunMode};
/// Per-automation runtime state backing the run-mode dispatch.
///
/// Cheap to clone (all fields are `Arc`); the engine clones it into each
/// spawned run so the machinery (abort handle, queue mutex, semaphore) is
/// shared across all triggers of the same automation.
#[derive(Clone)]
pub struct RunState {
/// `Single`/`IgnoreFirst` re-entrancy guard (ADR-161 §A5).
running: Arc<AtomicBool>,
/// `Restart`: handle to the currently-running action task, so a new
/// trigger can abort it before starting a fresh one.
current: Arc<Mutex<Option<tokio::task::AbortHandle>>>,
/// `Queued`: serializes runs in arrival order (one at a time, FIFO via
/// fair async mutex acquisition).
queue_lock: Arc<AsyncMutex<()>>,
/// `max: N` (and bounded `Parallel`): caps concurrent runs at N.
/// `None` when no cap applies.
semaphore: Option<Arc<Semaphore>>,
}
impl RunState {
/// Build run-state for an automation, sizing the concurrency semaphore
/// from its `max:` field (only meaningful for `Queued`/`Parallel`).
pub fn new(automation: &Automation) -> Self {
let semaphore = automation
.max
.filter(|n| *n > 0)
.map(|n| Arc::new(Semaphore::new(n)));
Self {
running: Arc::new(AtomicBool::new(false)),
current: Arc::new(Mutex::new(None)),
queue_lock: Arc::new(AsyncMutex::new(())),
semaphore,
}
}
/// Dispatch one trigger for `automation` according to its `RunMode`.
/// Honors Single re-entrancy, Restart cancel-and-replace, Queued
/// serialization, and `max:` concurrency capping.
pub fn dispatch(&self, hc: &HomeCore, automation: Arc<Automation>) {
match automation.mode {
RunMode::Single | RunMode::IgnoreFirst => self.dispatch_single(hc, automation),
RunMode::Restart => self.dispatch_restart(hc, automation),
RunMode::Queued => self.dispatch_queued(hc, automation),
RunMode::Parallel => self.dispatch_parallel(hc, automation),
}
}
/// `Single`: skip if a run is already in flight; clear the flag on done.
fn dispatch_single(&self, hc: &HomeCore, automation: Arc<Automation>) {
if self
.running
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
.is_err()
{
return; // already running — skip re-entrant trigger.
}
let hc = hc.clone();
let running = Arc::clone(&self.running);
tokio::spawn(async move {
run_actions(&hc, &automation).await;
running.store(false, Ordering::SeqCst);
});
}
/// `Restart`: abort the in-flight run (if any), then start a fresh one
/// and record its abort handle.
fn dispatch_restart(&self, hc: &HomeCore, automation: Arc<Automation>) {
// Abort any prior run before starting the new one.
if let Some(prev) = self.current.lock().unwrap().take() {
prev.abort();
}
let hc = hc.clone();
let slot = Arc::clone(&self.current);
let handle = tokio::spawn(async move {
run_actions(&hc, &automation).await;
});
*slot.lock().unwrap() = Some(handle.abort_handle());
}
/// `Queued`: serialize via the per-automation async mutex. Each trigger
/// spawns a task that waits its turn, so all triggers run in arrival
/// order, one at a time — nothing is dropped.
fn dispatch_queued(&self, hc: &HomeCore, automation: Arc<Automation>) {
let hc = hc.clone();
let lock = Arc::clone(&self.queue_lock);
let sem = self.semaphore.clone();
tokio::spawn(async move {
// Optional `max:` cap still applies on top of serialization.
let _permit = match &sem {
Some(s) => Some(s.acquire().await.expect("semaphore not closed")),
None => None,
};
let _guard = lock.lock().await; // FIFO turn — sequential execution.
run_actions(&hc, &automation).await;
});
}
/// `Parallel`: spawn on every trigger, capped at `max:` if set.
fn dispatch_parallel(&self, hc: &HomeCore, automation: Arc<Automation>) {
let hc = hc.clone();
let sem = self.semaphore.clone();
tokio::spawn(async move {
let _permit = match &sem {
Some(s) => Some(s.acquire().await.expect("semaphore not closed")),
None => None,
};
run_actions(&hc, &automation).await;
});
}
}
/// Execute an automation's action sequence once.
async fn run_actions(hc: &HomeCore, automation: &Automation) {
let mut exec_ctx = ExecutionContext::new(hc.clone(), automation.id.clone());
for action in &automation.action {
if let Err(e) = action.execute(&mut exec_ctx).await {
eprintln!(
"[homecore-automation] action error in {}: {e}",
automation.id
);
break;
}
}
}
+6 -1
View File
@@ -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)
);
}
+9
View File
@@ -50,6 +50,15 @@ serde_json = "1"
# UUIDs for config entry IDs in host_abi.rs.
uuid = { version = "1", features = ["v4"] }
# ── ADR-162 P4: plugin signature + integrity verification ──────────────────
# Reuses the same in-repo crypto stack as cog-ha-matter (witness_signing.rs):
# Ed25519 over a SHA-256 module digest. All four are already in the workspace
# Cargo.lock (cog-ha-matter / bfld pull them in) — no new external dep tree.
ed25519-dalek = "2.1"
sha2 = { workspace = true }
hex = "0.4"
base64 = "0.22"
# Optional Wasmtime runtime (P2, default-off — 30 MB dep).
# Bumped from 25.0.3 → 42 to remediate RUSTSEC-2026-0095 and RUSTSEC-2026-0096
# (Cranelift/Winch sandbox-escape CVEs, CVSS 9.0 — iter-11 security sprint HC-03/04).
+12
View File
@@ -25,6 +25,18 @@ pub enum PluginError {
#[error("plugin setup failed: {0}")]
SetupFailed(String),
/// The plugin failed signature/integrity verification (ADR-162 P4):
/// hash mismatch, bad signature, untrusted publisher, or unsigned
/// module under a non-dev trust policy.
#[error("plugin signature rejected: {0}")]
SignatureRejected(String),
/// A plugin attempted a host call (e.g. `hc_state_set`) on an entity
/// it did not declare in `homecore_permissions` (ADR-162 P5 authority
/// isolation).
#[error("plugin permission denied: {0}")]
PermissionDenied(String),
/// The plugin's `unload` hook returned an error.
#[error("plugin unload failed: {0}")]
UnloadFailed(String),
+14 -2
View File
@@ -22,8 +22,16 @@
//! - Host ABI wiring: `hc_state_get`, `hc_state_set`, `hc_event_fire`, etc.
//! (P2 — requires ADR-127 state machine API freeze first).
//! - Config entry lifecycle + hot-load (P3).
//! - Cog registry distribution + Ed25519 signature verification (P4).
//! - Permission enforcement (P5).
//!
//! ## Now enforced (ADR-162)
//!
//! - **Ed25519 signature + SHA-256 integrity verification (P4)** — see
//! [`verify`]: the plugin load path hashes the real `.wasm` bytes, checks
//! the manifest `wasm_module_hash`, verifies `wasm_module_sig` against
//! `publisher_key`, and enforces a [`verify::PluginPolicy`] allowlist.
//! - **Permission / authority isolation (P5)** — see [`permissions`]: a
//! plugin's `hc_state_set` writes are gated against the entity domains/
//! globs it declared in `homecore_permissions`.
//!
//! ## Feature flags
//!
@@ -35,9 +43,11 @@
pub mod error;
pub mod host_abi;
pub mod manifest;
pub mod permissions;
pub mod plugin;
pub mod registry;
pub mod runtime;
pub mod verify;
#[cfg(feature = "wasmtime")]
pub mod wasmtime_runtime;
@@ -45,9 +55,11 @@ pub mod wasmtime_runtime;
pub use error::PluginError;
pub use host_abi::{ConfigEntryJson, StateChangedEventJson};
pub use manifest::{IotClass, IntegrationType, PluginManifest};
pub use permissions::PermissionSet;
pub use plugin::{HomeCorePlugin, PluginId};
pub use registry::PluginRegistry;
pub use runtime::{InProcessRuntime, LoadedPlugin, PluginRuntime};
pub use verify::{verify_module, PluginPolicy};
#[cfg(feature = "wasmtime")]
pub use wasmtime_runtime::{WasmPlugin, WasmtimeRuntime};
+20 -1
View File
@@ -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"));
}
}
+397
View File
@@ -0,0 +1,397 @@
//! Plugin signature & integrity verification (ADR-162, P4).
//!
//! ADR-161/B5 honestly relabelled the manifest's `wasm_module_hash` /
//! `wasm_module_sig` / `publisher_key` fields as "(P4 — not yet enforced)":
//! they were parsed and round-tripped but **never checked** before a plugin
//! ran. This module makes that claim TRUE — it is the real verification gate
//! the plugin load path runs before instantiating any `.wasm` module.
//!
//! ## What is verified, in order
//!
//! 1. **Module hash** — SHA-256 of the actual `.wasm` bytes must equal the
//! manifest's `wasm_module_hash` (`sha256:<hex>`). A tampered module
//! (one byte changed) fails here.
//! 2. **Ed25519 signature** — `wasm_module_sig` (`ed25519:<base64>`, 64-byte
//! raw signature) must verify over the **32-byte SHA-256 digest** under
//! the `publisher_key` (`ed25519:<base64>`, 32-byte raw verifying key).
//! 3. **Trust policy** — the `publisher_key` must be on the configured
//! allowlist, unless [`PluginPolicy::AllowUnsigned`] is in force (a loud
//! dev escape hatch).
//!
//! The crypto mirrors the in-repo Ed25519 pattern from
//! `cog-ha-matter::witness_signing` (same `ed25519-dalek` 2.x API, same
//! deterministic-test-key convention). SHA-256 matches the `sha256:` prefix
//! the manifest doc already declared for `wasm_module_hash`, and the
//! `cog-ha-matter` cog manifest's `binary_sha256` hex convention.
//!
//! ## Secure default
//!
//! [`PluginPolicy::trusted`] (the production constructor) **rejects**:
//! * an unsigned module (no hash / sig / key),
//! * a signature from a key not on the allowlist,
//! * any hash or signature mismatch.
//!
//! Only [`PluginPolicy::AllowUnsigned`] loosens this, and every load it
//! waves through emits a `warn`-level log line so it cannot pass silently.
use base64::Engine as _;
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
use sha2::{Digest, Sha256};
use crate::error::PluginError;
use crate::manifest::PluginManifest;
/// Trust policy governing which plugins may load.
///
/// The production path uses [`PluginPolicy::trusted`] with an explicit
/// allowlist of publisher verifying keys. [`PluginPolicy::AllowUnsigned`]
/// is the dev escape hatch — it loads anything (even unsigned modules) but
/// logs a loud warning per load.
#[derive(Debug, Clone)]
pub enum PluginPolicy {
/// Secure default: a plugin loads only if its module hash matches, its
/// Ed25519 signature verifies, AND its publisher key is in this
/// allowlist. Each entry is the 32-byte raw Ed25519 verifying key.
Trusted { allowlist: Vec<[u8; 32]> },
/// Dev-only: skip signature/allowlist enforcement. Hash is still
/// checked when a `wasm_module_hash` is present (cheap integrity), but
/// unsigned / unknown-publisher modules are allowed. Every load logs a
/// loud `warn`.
AllowUnsigned,
}
impl PluginPolicy {
/// Construct the secure (production) policy from a list of trusted
/// publisher keys, each encoded as `ed25519:<base64>` (the same form
/// the manifest `publisher_key` uses).
pub fn trusted(publisher_keys: &[&str]) -> Result<Self, PluginError> {
let mut allowlist = Vec::with_capacity(publisher_keys.len());
for k in publisher_keys {
allowlist.push(decode_verifying_key(k)?.to_bytes());
}
Ok(PluginPolicy::Trusted { allowlist })
}
/// Secure policy that trusts no publisher at all — every signed or
/// unsigned module is rejected. Useful as a strict default.
pub fn deny_all() -> Self {
PluginPolicy::Trusted { allowlist: vec![] }
}
fn is_dev(&self) -> bool {
matches!(self, PluginPolicy::AllowUnsigned)
}
fn allows(&self, key: &VerifyingKey) -> bool {
match self {
PluginPolicy::AllowUnsigned => true,
PluginPolicy::Trusted { allowlist } => {
allowlist.iter().any(|k| k == &key.to_bytes())
}
}
}
}
/// Verify a `.wasm` module's integrity and signature against its manifest,
/// under the given trust `policy`. Returns `Ok(())` only if the module may
/// be instantiated.
///
/// On [`PluginPolicy::AllowUnsigned`] this still checks any present hash,
/// but waves through missing/untrusted signatures with a loud `warn`.
pub fn verify_module(
manifest: &PluginManifest,
wasm_bytes: &[u8],
policy: &PluginPolicy,
) -> Result<(), PluginError> {
let signed = manifest.wasm_module_hash.is_some()
|| manifest.wasm_module_sig.is_some()
|| manifest.publisher_key.is_some();
if !signed {
// No integrity material at all.
if policy.is_dev() {
eprintln!(
"[PLUGIN WARN] loading UNSIGNED plugin `{}` — no wasm_module_hash/sig/publisher_key. \
AllowUnsigned dev policy is active; this is INSECURE and must not be used in production.",
manifest.domain
);
return Ok(());
}
return Err(PluginError::SignatureRejected(format!(
"plugin `{}` is unsigned (no wasm_module_hash/sig/publisher_key) and the trust policy \
rejects unsigned modules; set PluginPolicy::AllowUnsigned to override in dev",
manifest.domain
)));
}
// (1) Hash check — always enforced when a hash is declared.
let digest = sha256_digest(wasm_bytes);
if let Some(declared) = &manifest.wasm_module_hash {
let expected = parse_sha256(declared)?;
if expected != digest {
return Err(PluginError::SignatureRejected(format!(
"plugin `{}` wasm hash mismatch: module does not match manifest wasm_module_hash \
(tampered or wrong binary)",
manifest.domain
)));
}
} else if !policy.is_dev() {
return Err(PluginError::SignatureRejected(format!(
"plugin `{}` carries a signature/publisher_key but no wasm_module_hash to bind it to",
manifest.domain
)));
}
// (2) Signature check + (3) allowlist.
match (&manifest.wasm_module_sig, &manifest.publisher_key) {
(Some(sig_str), Some(key_str)) => {
let key = decode_verifying_key(key_str)?;
let sig = decode_signature(sig_str)?;
key.verify(&digest, &sig).map_err(|_| {
PluginError::SignatureRejected(format!(
"plugin `{}` Ed25519 signature does not verify over the module hash under \
publisher_key",
manifest.domain
))
})?;
if !policy.allows(&key) {
if policy.is_dev() {
eprintln!(
"[PLUGIN WARN] plugin `{}` is validly signed but its publisher_key is NOT on \
the trust allowlist; AllowUnsigned dev policy loads it anyway.",
manifest.domain
);
return Ok(());
}
return Err(PluginError::SignatureRejected(format!(
"plugin `{}` is validly signed but its publisher_key is not on the trust \
allowlist (untrusted publisher)",
manifest.domain
)));
}
Ok(())
}
_ => {
// Hash present but signature/key incomplete.
if policy.is_dev() {
eprintln!(
"[PLUGIN WARN] plugin `{}` has a hash but no complete Ed25519 signature; \
AllowUnsigned dev policy loads it anyway.",
manifest.domain
);
return Ok(());
}
Err(PluginError::SignatureRejected(format!(
"plugin `{}` is missing a complete wasm_module_sig + publisher_key pair; the trust \
policy requires a valid signature",
manifest.domain
)))
}
}
}
/// SHA-256 of `bytes` as a 32-byte digest.
fn sha256_digest(bytes: &[u8]) -> [u8; 32] {
let mut hasher = Sha256::new();
hasher.update(bytes);
hasher.finalize().into()
}
/// Parse a `sha256:<hex>` manifest hash into a 32-byte digest.
fn parse_sha256(s: &str) -> Result<[u8; 32], PluginError> {
let hex_part = s.strip_prefix("sha256:").ok_or_else(|| {
PluginError::InvalidManifest(format!(
"wasm_module_hash must be `sha256:<hex>`, got {s:?}"
))
})?;
let raw = hex::decode(hex_part).map_err(|e| {
PluginError::InvalidManifest(format!("wasm_module_hash hex decode: {e}"))
})?;
raw.try_into().map_err(|v: Vec<u8>| {
PluginError::InvalidManifest(format!(
"wasm_module_hash must decode to 32 bytes, got {}",
v.len()
))
})
}
/// Decode an `ed25519:<base64>` 32-byte verifying key.
fn decode_verifying_key(s: &str) -> Result<VerifyingKey, PluginError> {
let b64 = s.strip_prefix("ed25519:").ok_or_else(|| {
PluginError::InvalidManifest(format!(
"publisher_key must be `ed25519:<base64>`, got {s:?}"
))
})?;
let raw = base64::engine::general_purpose::STANDARD
.decode(b64)
.map_err(|e| PluginError::InvalidManifest(format!("publisher_key base64: {e}")))?;
let bytes: [u8; 32] = raw.try_into().map_err(|v: Vec<u8>| {
PluginError::InvalidManifest(format!(
"publisher_key must decode to 32 bytes, got {}",
v.len()
))
})?;
VerifyingKey::from_bytes(&bytes)
.map_err(|e| PluginError::InvalidManifest(format!("publisher_key not a valid Ed25519 point: {e}")))
}
/// Decode an `ed25519:<base64>` 64-byte signature.
fn decode_signature(s: &str) -> Result<Signature, PluginError> {
let b64 = s.strip_prefix("ed25519:").ok_or_else(|| {
PluginError::InvalidManifest(format!(
"wasm_module_sig must be `ed25519:<base64>`, got {s:?}"
))
})?;
let raw = base64::engine::general_purpose::STANDARD
.decode(b64)
.map_err(|e| PluginError::InvalidManifest(format!("wasm_module_sig base64: {e}")))?;
let bytes: [u8; 64] = raw.try_into().map_err(|v: Vec<u8>| {
PluginError::InvalidManifest(format!(
"wasm_module_sig must decode to 64 bytes, got {}",
v.len()
))
})?;
Ok(Signature::from_bytes(&bytes))
}
/// Encode a SHA-256 digest as the manifest `sha256:<hex>` form. Exposed so
/// tooling (and tests) can produce a manifest hash for real `.wasm` bytes.
pub fn encode_sha256(wasm_bytes: &[u8]) -> String {
format!("sha256:{}", hex::encode(sha256_digest(wasm_bytes)))
}
/// Encode an Ed25519 verifying key as the manifest `ed25519:<base64>` form.
pub fn encode_verifying_key(key: &VerifyingKey) -> String {
format!(
"ed25519:{}",
base64::engine::general_purpose::STANDARD.encode(key.to_bytes())
)
}
/// Encode an Ed25519 signature as the manifest `ed25519:<base64>` form.
pub fn encode_signature(sig: &Signature) -> String {
format!(
"ed25519:{}",
base64::engine::general_purpose::STANDARD.encode(sig.to_bytes())
)
}
#[cfg(test)]
mod tests {
use super::*;
use ed25519_dalek::{Signer, SigningKey};
/// Deterministic publisher key (mirrors witness_signing's fixed-bytes
/// seed convention — DO NOT use in production).
fn publisher() -> SigningKey {
SigningKey::from_bytes(b"homecore-plugins-pub-test-seed--")
}
fn attacker() -> SigningKey {
SigningKey::from_bytes(b"homecore-plugins-attacker-seed--")
}
/// Sign `wasm_bytes` with `key` and produce a manifest carrying the real
/// hash + signature + publisher key.
fn signed_manifest(wasm_bytes: &[u8], key: &SigningKey) -> PluginManifest {
let digest = sha256_digest(wasm_bytes);
let sig = key.sign(&digest);
PluginManifest {
domain: "demo".into(),
name: "Demo".into(),
version: "1.0.0".into(),
documentation: None,
iot_class: None,
config_flow: false,
integration_type: None,
dependencies: vec![],
requirements: vec![],
wasm_module: Some("demo.wasm".into()),
wasm_module_hash: Some(encode_sha256(wasm_bytes)),
wasm_module_sig: Some(encode_signature(&sig)),
publisher_key: Some(encode_verifying_key(&key.verifying_key())),
min_homecore_version: None,
host_imports_required: vec![],
homecore_permissions: vec![],
cog_id: None,
}
}
#[test]
fn valid_sig_from_trusted_key_passes() {
let wasm = b"\0asm\x01\0\0\0fake module bytes";
let key = publisher();
let manifest = signed_manifest(wasm, &key);
let policy =
PluginPolicy::trusted(&[&encode_verifying_key(&key.verifying_key())]).unwrap();
verify_module(&manifest, wasm, &policy).expect("trusted signed module should load");
}
#[test]
fn tampered_module_is_rejected() {
let wasm = b"\0asm\x01\0\0\0fake module bytes";
let key = publisher();
let manifest = signed_manifest(wasm, &key);
let policy =
PluginPolicy::trusted(&[&encode_verifying_key(&key.verifying_key())]).unwrap();
// Flip a byte: hash no longer matches.
let tampered = b"\0asm\x01\0\0\0FAKE module bytes";
let err = verify_module(&manifest, tampered, &policy).unwrap_err();
assert!(matches!(err, PluginError::SignatureRejected(_)), "got {err:?}");
}
#[test]
fn valid_sig_from_untrusted_key_is_rejected() {
let wasm = b"\0asm\x01\0\0\0fake module bytes";
// Signed correctly by the attacker, but the attacker is not trusted.
let manifest = signed_manifest(wasm, &attacker());
let policy =
PluginPolicy::trusted(&[&encode_verifying_key(&publisher().verifying_key())]).unwrap();
let err = verify_module(&manifest, wasm, &policy).unwrap_err();
assert!(matches!(err, PluginError::SignatureRejected(_)), "got {err:?}");
}
#[test]
fn forged_signature_is_rejected() {
// Manifest claims the trusted publisher_key but the signature was
// produced by the attacker (a forged sig under a trusted identity).
let wasm = b"\0asm\x01\0\0\0fake module bytes";
let digest = sha256_digest(wasm);
let forged = attacker().sign(&digest);
let mut manifest = signed_manifest(wasm, &publisher());
manifest.wasm_module_sig = Some(encode_signature(&forged));
let policy =
PluginPolicy::trusted(&[&encode_verifying_key(&publisher().verifying_key())]).unwrap();
let err = verify_module(&manifest, wasm, &policy).unwrap_err();
assert!(matches!(err, PluginError::SignatureRejected(_)), "got {err:?}");
}
#[test]
fn unsigned_module_rejected_under_default_policy() {
let wasm = b"\0asm\x01\0\0\0unsigned";
let manifest = PluginManifest {
domain: "u".into(),
name: "U".into(),
version: "1".into(),
documentation: None,
iot_class: None,
config_flow: false,
integration_type: None,
dependencies: vec![],
requirements: vec![],
wasm_module: Some("u.wasm".into()),
wasm_module_hash: None,
wasm_module_sig: None,
publisher_key: None,
min_homecore_version: None,
host_imports_required: vec![],
homecore_permissions: vec![],
cog_id: None,
};
let err = verify_module(&manifest, wasm, &PluginPolicy::deny_all()).unwrap_err();
assert!(matches!(err, PluginError::SignatureRejected(_)), "got {err:?}");
// ...but AllowUnsigned loads it (with a warn).
verify_module(&manifest, wasm, &PluginPolicy::AllowUnsigned)
.expect("AllowUnsigned should load an unsigned module");
}
}
@@ -30,16 +30,27 @@ use wasmtime::{Engine, Linker, Module, Store};
use crate::error::PluginError;
use crate::host_abi::{LogLevel, StateChangedEventJson, MAX_ABI_BUFFER_BYTES};
use crate::manifest::PluginManifest;
use crate::permissions::PermissionSet;
use crate::verify::{verify_module, PluginPolicy};
// ── Store data ─────────────────────────────────────────────────────────────
/// Per-plugin state stored inside the Wasmtime [`Store`].
///
/// Wasmtime's `Store<T>` exposes `T` to host functions via `caller.data()`.
/// We store the `HomeCore` handle and a list of subscribed entity IDs here.
/// We store the `HomeCore` handle, a list of subscribed entity IDs, and the
/// plugin's write-permission set (ADR-162 P5 authority isolation).
pub struct PluginStoreData {
pub hc: HomeCore,
pub subscriptions: Vec<String>,
/// Entity-write authority distilled from the manifest's
/// `homecore_permissions`. Consulted by `hc_state_set`. The
/// permission-free [`WasmtimeRuntime::load_wasm`] path installs an
/// all-allowing set for backward compatibility; the
/// [`WasmtimeRuntime::load_plugin`] path installs the manifest's
/// declared set.
pub permissions: PermissionSet,
}
// ── WasmtimeRuntime ────────────────────────────────────────────────────────
@@ -59,14 +70,53 @@ impl WasmtimeRuntime {
Ok(Self { engine })
}
/// Compile and instantiate a WASM plugin from raw bytes.
/// Compile and instantiate a WASM plugin from raw bytes, **without**
/// signature verification or permission gating (the plugin gets
/// all-write authority).
///
/// Returns a [`WasmPlugin`] handle that owns the `Store` and the
/// `Instance`. The handle can be used to call into the WASM module.
/// Retained for the legacy/test path and first-party trusted modules.
/// Production plugin loading should go through [`Self::load_plugin`],
/// which verifies the module (ADR-162 P4) and scopes its write
/// authority to the manifest (P5).
pub fn load_wasm(
&self,
wasm_bytes: &[u8],
hc: HomeCore,
) -> Result<WasmPlugin, PluginError> {
self.instantiate(wasm_bytes, hc, PermissionSet::allow_all())
}
/// Verify and instantiate a WASM plugin from its manifest + raw bytes.
///
/// This is the secure load path (ADR-162):
/// 1. **P4** — [`verify_module`] checks the SHA-256 module hash and
/// Ed25519 signature against the manifest under `policy`. A
/// tampered module, bad/forged signature, untrusted publisher, or
/// (under the secure default) an unsigned module is rejected
/// **before** any guest code runs.
/// 2. **P5** — the plugin's `homecore_permissions` are distilled into
/// a [`PermissionSet`] installed in the store, so `hc_state_set`
/// can only write entities the plugin declared.
pub fn load_plugin(
&self,
manifest: &PluginManifest,
wasm_bytes: &[u8],
hc: HomeCore,
policy: &PluginPolicy,
) -> Result<WasmPlugin, PluginError> {
// P4: verify before instantiation.
verify_module(manifest, wasm_bytes, policy)?;
// P5: scope write authority to the manifest's declared permissions.
let permissions = PermissionSet::from_manifest(manifest);
self.instantiate(wasm_bytes, hc, permissions)
}
/// Shared compile + instantiate, installing the given permission set.
fn instantiate(
&self,
wasm_bytes: &[u8],
hc: HomeCore,
permissions: PermissionSet,
) -> Result<WasmPlugin, PluginError> {
let module = Module::new(&self.engine, wasm_bytes)
.map_err(|e| PluginError::RuntimeError(format!("WASM compile: {e}")))?;
@@ -77,6 +127,7 @@ impl WasmtimeRuntime {
let store_data = PluginStoreData {
hc,
subscriptions: Vec::new(),
permissions,
};
let mut store = Store::new(&self.engine, store_data);
@@ -183,7 +234,9 @@ fn register_hc_state_get(
/// Sets the state for the entity whose UTF-8 ID is at `[eid_ptr,eid_ptr+eid_len)`.
/// The new state string is at `[state_ptr,state_ptr+state_len)`.
/// The attributes JSON is at `[attrs_ptr,attrs_ptr+attrs_len)`.
/// Returns 0 on success, negative on error.
/// Returns 0 on success, negative on error: -1 (bad memory/args), -2
/// (invalid entity id), -3 (permission denied — entity not in the
/// plugin's declared `homecore_permissions`, ADR-162 P5).
fn register_hc_state_set(
linker: &mut Linker<PluginStoreData>,
) -> Result<(), PluginError> {
@@ -224,6 +277,20 @@ fn register_hc_state_set(
Ok(id) => id,
Err(_) => return -2,
};
// ── P5 authority isolation (ADR-162) ──────────────────────
// Reject a write to an entity the plugin did not declare in
// `homecore_permissions`. Return a typed error code to the
// guest (-3); do NOT panic the host.
if !caller.data().permissions.may_write(entity_id.as_str()) {
eprintln!(
"[PLUGIN WARN] denied hc_state_set on `{}` — not in plugin's declared \
homecore_permissions (P5 authority isolation)",
entity_id.as_str()
);
return -3;
}
let attrs: serde_json::Value =
serde_json::from_str(&attrs_str).unwrap_or(serde_json::json!({}));
@@ -371,4 +371,259 @@ mod wasmtime_tests {
let r = plugin.call_setup("{}").expect("setup");
assert_eq!(r, 0);
}
// ── ADR-162 P4: signature/integrity verification ────────────────────────
//
// Each of these FAILS on the pre-ADR-162 code, which had no
// `load_plugin` / `verify_module` at all — the manifest hash/sig/key
// were parsed and discarded. They drive the real verification gate.
use ed25519_dalek::{Signer, SigningKey};
use homecore_plugins::manifest::PluginManifest;
use homecore_plugins::verify::{encode_sha256, encode_signature, encode_verifying_key};
use homecore_plugins::PluginPolicy;
/// Deterministic publisher key (fixed seed — never use in production;
/// mirrors the cog-ha-matter witness_signing test-key convention).
fn publisher_key() -> SigningKey {
SigningKey::from_bytes(b"hc-plugins-integration-pub-seed-")
}
fn untrusted_key() -> SigningKey {
SigningKey::from_bytes(b"hc-plugins-integration-evil-seed")
}
/// A minimal valid module that writes `light.kitchen` on setup, plus a
/// `light.*` permission grant. Returns the WAT source.
const WRITE_LIGHT_WAT: &str = r#"
(module
(import "env" "hc_state_get" (func $hc_state_get (param i32 i32 i32 i32) (result i32)))
(import "env" "hc_state_set" (func $hc_state_set (param i32 i32 i32 i32 i32 i32) (result i32)))
(import "env" "hc_state_subscribe" (func $hc_state_subscribe (param i32 i32) (result i32)))
(import "env" "hc_log" (func $hc_log (param i32 i32 i32)))
(memory (export "memory") 1)
(global $bump (mut i32) (i32.const 512))
(data (i32.const 0) "light.kitchen")
(data (i32.const 64) "on")
(data (i32.const 128) "{}")
(func (export "alloc") (param i32) (result i32)
(local $p i32)
(local.set $p (global.get $bump))
(global.set $bump (i32.add (global.get $bump) (local.get 0)))
(local.get $p))
(func (export "dealloc") (param i32 i32))
(func (export "plugin_setup") (param i32 i32) (result i32)
(call $hc_state_set
(i32.const 0) (i32.const 13) ;; "light.kitchen"
(i32.const 64) (i32.const 2) ;; "on"
(i32.const 128) (i32.const 2)) ;; "{}"
drop
(i32.const 0))
(func (export "plugin_handle_state_changed") (param i32 i32) (result i32) (i32.const 0))
)
"#;
/// Build a manifest signed by `key` over the SHA-256 of `wasm_bytes`,
/// with the given write-permission grants.
fn signed_manifest(
wasm_bytes: &[u8],
key: &SigningKey,
perms: &[&str],
) -> PluginManifest {
use sha2::{Digest, Sha256};
let digest: [u8; 32] = Sha256::digest(wasm_bytes).into();
let sig = key.sign(&digest);
let mut m = PluginManifest::parse_json(
r#"{"domain":"demo","name":"Demo","version":"1.0.0"}"#,
)
.unwrap();
m.wasm_module = Some("demo.wasm".into());
m.wasm_module_hash = Some(encode_sha256(wasm_bytes));
m.wasm_module_sig = Some(encode_signature(&sig));
m.publisher_key = Some(encode_verifying_key(&key.verifying_key()));
m.homecore_permissions = perms.iter().map(|s| s.to_string()).collect();
m
}
#[test]
fn p4_valid_sig_from_trusted_key_loads() {
let wasm = wat::parse_str(WRITE_LIGHT_WAT).expect("WAT");
let key = publisher_key();
let manifest = signed_manifest(&wasm, &key, &["light.*"]);
let policy =
PluginPolicy::trusted(&[&encode_verifying_key(&key.verifying_key())]).unwrap();
let rt = WasmtimeRuntime::new().expect("rt");
let hc = HomeCore::new();
rt.load_plugin(&manifest, &wasm, hc, &policy)
.expect("a validly-signed, trusted plugin must load");
}
#[test]
fn p4_tampered_module_is_rejected() {
let wasm = wat::parse_str(WRITE_LIGHT_WAT).expect("WAT");
let key = publisher_key();
// Manifest signs the original bytes; we then load DIFFERENT bytes.
let manifest = signed_manifest(&wasm, &key, &["light.*"]);
let policy =
PluginPolicy::trusted(&[&encode_verifying_key(&key.verifying_key())]).unwrap();
// Re-compile a byte-different module (writes "off" not "on").
let tampered_src = WRITE_LIGHT_WAT.replace(r#""on""#, r#""of""#);
let tampered = wat::parse_str(&tampered_src).expect("WAT");
assert_ne!(wasm, tampered, "test bug: bytes must differ");
let rt = WasmtimeRuntime::new().expect("rt");
let hc = HomeCore::new();
match rt.load_plugin(&manifest, &tampered, hc, &policy) {
Err(homecore_plugins::PluginError::SignatureRejected(_)) => {}
Ok(_) => panic!("tampered module must be rejected (hash mismatch), but it loaded"),
Err(e) => panic!("expected SignatureRejected, got {e:?}"),
}
}
#[test]
fn p4_valid_sig_from_untrusted_key_is_rejected() {
let wasm = wat::parse_str(WRITE_LIGHT_WAT).expect("WAT");
// Correctly signed by the untrusted key — but it is not on the allowlist.
let manifest = signed_manifest(&wasm, &untrusted_key(), &["light.*"]);
let policy =
PluginPolicy::trusted(&[&encode_verifying_key(&publisher_key().verifying_key())])
.unwrap();
let rt = WasmtimeRuntime::new().expect("rt");
let hc = HomeCore::new();
match rt.load_plugin(&manifest, &wasm, hc, &policy) {
Err(homecore_plugins::PluginError::SignatureRejected(_)) => {}
Ok(_) => panic!("untrusted publisher must be rejected, but it loaded"),
Err(e) => panic!("expected SignatureRejected, got {e:?}"),
}
}
#[test]
fn p4_unsigned_module_rejected_by_default_loads_only_under_allow_unsigned() {
let wasm = wat::parse_str(WRITE_LIGHT_WAT).expect("WAT");
let mut manifest = PluginManifest::parse_json(
r#"{"domain":"u","name":"U","version":"1"}"#,
)
.unwrap();
manifest.wasm_module = Some("u.wasm".into());
manifest.homecore_permissions = vec!["light.*".into()];
// No hash/sig/key → unsigned.
let rt = WasmtimeRuntime::new().expect("rt");
// Secure default: rejected.
match rt.load_plugin(&manifest, &wasm, HomeCore::new(), &PluginPolicy::deny_all()) {
Err(homecore_plugins::PluginError::SignatureRejected(_)) => {}
Ok(_) => panic!("unsigned module must be rejected under the secure default"),
Err(e) => panic!("expected SignatureRejected, got {e:?}"),
}
// Dev escape hatch: loads (with a loud warn).
rt.load_plugin(
&manifest,
&wasm,
HomeCore::new(),
&PluginPolicy::AllowUnsigned,
)
.expect("AllowUnsigned dev policy must load an unsigned module");
}
// ── ADR-162 P5: authority / capability isolation ────────────────────────
//
// FAILS on the pre-ADR-162 code, where `hc_state_set` ignored
// `homecore_permissions` entirely and let any plugin write any entity.
/// Module that writes `lock.front_door` on setup (an over-privileged
/// write a `light.*` plugin must NOT be allowed to perform).
const WRITE_LOCK_WAT: &str = r#"
(module
(import "env" "hc_state_get" (func $hc_state_get (param i32 i32 i32 i32) (result i32)))
(import "env" "hc_state_set" (func $hc_state_set (param i32 i32 i32 i32 i32 i32) (result i32)))
(import "env" "hc_state_subscribe" (func $hc_state_subscribe (param i32 i32) (result i32)))
(import "env" "hc_log" (func $hc_log (param i32 i32 i32)))
(memory (export "memory") 1)
(global $bump (mut i32) (i32.const 512))
(data (i32.const 0) "lock.front_door")
(data (i32.const 64) "unlocked")
(data (i32.const 128) "{}")
(func (export "alloc") (param i32) (result i32)
(local $p i32)
(local.set $p (global.get $bump))
(global.set $bump (i32.add (global.get $bump) (local.get 0)))
(local.get $p))
(func (export "dealloc") (param i32 i32))
;; plugin_setup returns the hc_state_set result code so the host test can
;; assert the guest saw the typed permission-denied error (-3).
(func (export "plugin_setup") (param i32 i32) (result i32)
(call $hc_state_set
(i32.const 0) (i32.const 15) ;; "lock.front_door"
(i32.const 64) (i32.const 8) ;; "unlocked"
(i32.const 128) (i32.const 2))) ;; "{}"
(func (export "plugin_handle_state_changed") (param i32 i32) (result i32) (i32.const 0))
)
"#;
#[test]
fn p5_declared_light_plugin_may_write_light_but_not_lock() {
let key = publisher_key();
let trusted = PluginPolicy::trusted(&[&encode_verifying_key(&key.verifying_key())]).unwrap();
let rt = WasmtimeRuntime::new().expect("rt");
// (a) A `light.*` plugin writing `light.kitchen` → ALLOWED.
let light_wasm = wat::parse_str(WRITE_LIGHT_WAT).expect("WAT");
let light_manifest = signed_manifest(&light_wasm, &key, &["light.*"]);
let hc_a = HomeCore::new();
let plugin_a = rt
.load_plugin(&light_manifest, &light_wasm, hc_a.clone(), &trusted)
.expect("light plugin loads");
let r = plugin_a.call_setup("{}").expect("setup");
assert_eq!(r, 0, "write to declared light.kitchen should succeed");
let kitchen = homecore::EntityId::parse("light.kitchen").unwrap();
assert_eq!(
hc_a.states().get(&kitchen).expect("light.kitchen written").state,
"on"
);
// (b) The SAME `light.*` plugin attempting to write `lock.front_door`
// → REJECTED with the typed -3 code, and the lock is NOT written.
let lock_wasm = wat::parse_str(WRITE_LOCK_WAT).expect("WAT");
let lock_manifest = signed_manifest(&lock_wasm, &key, &["light.*"]);
let hc_b = HomeCore::new();
let plugin_b = rt
.load_plugin(&lock_manifest, &lock_wasm, hc_b.clone(), &trusted)
.expect("module loads (verification ok); the WRITE is what's gated");
let denied = plugin_b.call_setup("{}").expect("setup runs without trapping host");
assert_eq!(
denied, -3,
"over-privileged write to lock.front_door must return -3 (permission denied)"
);
let lock = homecore::EntityId::parse("lock.front_door").unwrap();
assert!(
hc_b.states().get(&lock).is_none(),
"lock.front_door must NOT have been written by a light-only plugin"
);
}
#[test]
fn p5_plugin_with_no_permissions_can_write_nothing() {
let key = publisher_key();
let trusted = PluginPolicy::trusted(&[&encode_verifying_key(&key.verifying_key())]).unwrap();
let rt = WasmtimeRuntime::new().expect("rt");
let wasm = wat::parse_str(WRITE_LIGHT_WAT).expect("WAT");
// No permissions declared at all.
let manifest = signed_manifest(&wasm, &key, &[]);
let hc = HomeCore::new();
let plugin = rt
.load_plugin(&manifest, &wasm, hc.clone(), &trusted)
.expect("module loads; the write is gated");
// WRITE_LIGHT_WAT drops the host-import result and returns 0, so we
// assert the denial via the side-effect: the write must NOT land.
plugin.call_setup("{}").expect("setup runs without trapping host");
let kitchen = homecore::EntityId::parse("light.kitchen").unwrap();
assert!(
hc.states().get(&kitchen).is_none(),
"no-permission plugin must not write light.kitchen (P5 authority isolation)"
);
}
}
+15 -2
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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
+1 -1
View File
@@ -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
@@ -1,6 +1,6 @@
[package]
name = "wifi-densepose-sensing-server"
version = "0.3.1"
version = "0.3.2"
edition.workspace = true
description = "Lightweight Axum server for WiFi sensing UI with RuVector signal processing"
license.workspace = true
@@ -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 -1
View File
@@ -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.3"
edition.workspace = true
description = "WiFi CSI signal processing for DensePose estimation"
license.workspace = true
+1 -1
View File
@@ -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 -1
View File
@@ -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
@@ -22,6 +22,15 @@ sha2 = { version = "0.10", optional = true, default-features = false }
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 = []
@@ -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.
@@ -76,6 +76,8 @@ struct ZoneLight {
/// Lighting zone controller.
pub struct LightingZoneController {
/// Per-call event scratch buffer (owned; replaces former `static mut`).
events: [(i32, f32); 8],
zones: [ZoneLight; MAX_ZONES],
n_zones: usize,
/// Calibration accumulators.
@@ -99,6 +101,7 @@ impl LightingZoneController {
vacant_frames: 0,
};
Self {
events: [(0, 0.0); 8],
zones: [ZONE_INIT; MAX_ZONES],
n_zones: 0,
calib_sum: [0.0; MAX_ZONES],
@@ -230,7 +233,6 @@ impl LightingZoneController {
}
// Build output events.
static mut EVENTS: [(i32, f32); 8] = [(0, 0.0); 8];
let mut n_events = 0usize;
// Emit transitions immediately.
@@ -241,9 +243,7 @@ impl LightingZoneController {
LightState::Dim => EVENT_LIGHT_DIM,
LightState::Off => EVENT_LIGHT_OFF,
};
unsafe {
EVENTS[n_events] = (event_id, z as f32);
}
self.events[n_events] = (event_id, z as f32);
n_events += 1;
}
}
@@ -259,15 +259,13 @@ impl LightingZoneController {
};
// Encode zone_id + confidence in value.
let val = z as f32 + self.zones[z].score.min(0.99);
unsafe {
EVENTS[n_events] = (event_id, val);
}
self.events[n_events] = (event_id, val);
n_events += 1;
}
}
}
unsafe { &EVENTS[..n_events] }
&self.events[..n_events]
}
/// Get the lighting state of a specific zone.
@@ -54,6 +54,8 @@ pub enum MeetingState {
/// Meeting room tracker.
pub struct MeetingRoomTracker {
/// Per-call event scratch buffer (owned; replaces former `static mut`).
events: [(i32, f32); 4],
state: MeetingState,
/// Frames in current state.
state_frames: u32,
@@ -76,6 +78,7 @@ pub struct MeetingRoomTracker {
impl MeetingRoomTracker {
pub const fn new() -> Self {
Self {
events: [(0, 0.0); 4],
state: MeetingState::Empty,
state_frames: 0,
n_persons: 0,
@@ -116,7 +119,6 @@ impl MeetingRoomTracker {
self.multi_person_frames += 1;
}
static mut EVENTS: [(i32, f32); 4] = [(0, 0.0); 4];
let mut n_events = 0usize;
let _prev_state = self.state;
@@ -146,9 +148,7 @@ impl MeetingRoomTracker {
self.meeting_count += 1;
if n_events < 4 {
unsafe {
EVENTS[n_events] = (EVENT_MEETING_START, self.n_persons as f32);
}
self.events[n_events] = (EVENT_MEETING_START, self.n_persons as f32);
n_events += 1;
}
} else if self.state_frames >= PRE_MEETING_TIMEOUT {
@@ -175,17 +175,13 @@ impl MeetingRoomTracker {
// Emit meeting end with duration.
let duration_mins = self.total_meeting_frames as f32 / (20.0 * 60.0);
if n_events < 4 {
unsafe {
EVENTS[n_events] = (EVENT_MEETING_END, duration_mins);
}
self.events[n_events] = (EVENT_MEETING_END, duration_mins);
n_events += 1;
}
// Emit peak headcount.
if n_events < 4 {
unsafe {
EVENTS[n_events] = (EVENT_PEAK_HEADCOUNT, self.peak_headcount as f32);
}
self.events[n_events] = (EVENT_PEAK_HEADCOUNT, self.peak_headcount as f32);
n_events += 1;
}
}
@@ -204,9 +200,7 @@ impl MeetingRoomTracker {
self.multi_person_frames = 0;
if n_events < 4 {
unsafe {
EVENTS[n_events] = (EVENT_ROOM_AVAILABLE, 1.0);
}
self.events[n_events] = (EVENT_ROOM_AVAILABLE, 1.0);
n_events += 1;
}
}
@@ -216,14 +210,12 @@ impl MeetingRoomTracker {
// Periodic status emission.
if self.frame_count % EMIT_INTERVAL == 0 && self.state == MeetingState::Active {
if n_events < 4 {
unsafe {
EVENTS[n_events] = (EVENT_PEAK_HEADCOUNT, self.peak_headcount as f32);
}
self.events[n_events] = (EVENT_PEAK_HEADCOUNT, self.peak_headcount as f32);
n_events += 1;
}
}
unsafe { &EVENTS[..n_events] }
&self.events[..n_events]
}
/// Get current meeting room state.
@@ -151,6 +151,8 @@ impl PairState {
/// group assignment, then computes pairwise cross-correlation to detect
/// phase-locked breathing.
pub struct BreathingSyncDetector {
/// Per-call event scratch buffer (owned; replaces former `static mut`).
events: [(i32, f32); 4],
/// Per-person breathing channels (max 4).
channels: [BreathingChannel; MAX_PERSONS],
/// Pairwise synchronization states (max 6).
@@ -170,6 +172,7 @@ pub struct BreathingSyncDetector {
impl BreathingSyncDetector {
pub const fn new() -> Self {
Self {
events: [(0, 0.0); 4],
channels: [
BreathingChannel::new(), BreathingChannel::new(),
BreathingChannel::new(), BreathingChannel::new(),
@@ -201,7 +204,6 @@ impl BreathingSyncDetector {
_breathing_bpm: f32,
n_persons: i32,
) -> &[(i32, f32)] {
static mut EVENTS: [(i32, f32); 4] = [(0, 0.0); 4];
let mut n_ev = 0usize;
self.frame_count += 1;
@@ -214,14 +216,12 @@ impl BreathingSyncDetector {
if n_pers < 2 {
// Reset pair states when fewer than 2 persons.
if self.any_synced {
unsafe {
EVENTS[n_ev] = (EVENT_SYNC_LOST, 1.0);
}
self.events[n_ev] = (EVENT_SYNC_LOST, 1.0);
n_ev += 1;
self.any_synced = false;
self.prev_sync_count = 0;
}
return unsafe { &EVENTS[..n_ev] };
return &self.events[..n_ev];
}
let n_sc = core::cmp::min(phases.len(), MAX_SC);
@@ -331,36 +331,28 @@ impl BreathingSyncDetector {
// Emit events.
if self.any_synced && !was_any_synced {
unsafe {
EVENTS[n_ev] = (EVENT_SYNC_DETECTED, 1.0);
}
self.events[n_ev] = (EVENT_SYNC_DETECTED, 1.0);
n_ev += 1;
}
if was_any_synced && !self.any_synced {
unsafe {
EVENTS[n_ev] = (EVENT_SYNC_LOST, 1.0);
}
self.events[n_ev] = (EVENT_SYNC_LOST, 1.0);
n_ev += 1;
}
if sync_count != self.prev_sync_count && sync_count > 0 {
unsafe {
EVENTS[n_ev] = (EVENT_SYNC_PAIR_COUNT, sync_count as f32);
}
self.events[n_ev] = (EVENT_SYNC_PAIR_COUNT, sync_count as f32);
n_ev += 1;
}
self.prev_sync_count = sync_count;
// Emit coherence periodically (every 10 frames).
if self.frame_count % 10 == 0 {
unsafe {
EVENTS[n_ev] = (EVENT_GROUP_COHERENCE, self.group_coherence);
}
self.events[n_ev] = (EVENT_GROUP_COHERENCE, self.group_coherence);
n_ev += 1;
}
unsafe { &EVENTS[..n_ev] }
&self.events[..n_ev]
}
/// Compute normalized cross-correlation between two person channels
@@ -1,4 +1,12 @@
//! Non-contact sleep stage classification — ADR-041 exotic module.
//! Non-contact sleep-stage-like classification — ADR-041 exotic / research module.
//!
//! ⚠️ EXPERIMENTAL RESEARCH MODULE — NOT VALIDATED. Quasi-medical sleep-stage
//! ⚠️ classification here is a *candidate* heuristic only: it has never been
//! ⚠️ compared against polysomnography or any sleep-staging reference standard,
//! ⚠️ and its accuracy is unproven (see ADR-160 §A4). NOT a medical device. Do
//! ⚠️ NOT use for sleep diagnosis or any clinical decision. (Registry tag:
//! ⚠️ Exotic / Research.) The DSP is real; the sleep-stage labels are not
//! ⚠️ validated.
//!
//! # Algorithm
//!
@@ -113,6 +121,8 @@ pub enum SleepStage {
/// Non-contact sleep stage classifier using WiFi CSI physiological signatures.
pub struct DreamStageDetector {
/// Per-call event scratch buffer (owned; replaces former `static mut`).
events: [(i32, f32); 4],
/// Rolling breathing BPM values.
breath_hist: CircularBuffer<BREATH_HIST_LEN>,
/// Rolling heart rate BPM values.
@@ -152,6 +162,7 @@ pub struct DreamStageDetector {
impl DreamStageDetector {
pub const fn new() -> Self {
Self {
events: [(0, 0.0); 4],
breath_hist: CircularBuffer::new(),
hr_hist: CircularBuffer::new(),
phase_buf: CircularBuffer::new(),
@@ -192,7 +203,6 @@ impl DreamStageDetector {
_variance: f32,
presence: i32,
) -> &[(i32, f32)] {
static mut EVENTS: [(i32, f32); 4] = [(0, 0.0); 4];
let mut n_ev = 0usize;
self.frame_count += 1;
@@ -282,33 +292,25 @@ impl DreamStageDetector {
};
// Emit events.
unsafe {
EVENTS[n_ev] = (EVENT_SLEEP_STAGE, self.current_stage as u8 as f32);
}
self.events[n_ev] = (EVENT_SLEEP_STAGE, self.current_stage as u8 as f32);
n_ev += 1;
// Emit quality periodically (every 20 frames).
if self.frame_count % 20 == 0 {
unsafe {
EVENTS[n_ev] = (EVENT_SLEEP_QUALITY, efficiency);
}
self.events[n_ev] = (EVENT_SLEEP_QUALITY, efficiency);
n_ev += 1;
unsafe {
EVENTS[n_ev] = (EVENT_DEEP_SLEEP_RATIO, deep_ratio);
}
self.events[n_ev] = (EVENT_DEEP_SLEEP_RATIO, deep_ratio);
n_ev += 1;
}
// Emit REM episode when in REM or just exited.
if rem_ep > 0 {
unsafe {
EVENTS[n_ev] = (EVENT_REM_EPISODE, rem_ep as f32);
}
self.events[n_ev] = (EVENT_REM_EPISODE, rem_ep as f32);
n_ev += 1;
}
unsafe { &EVENTS[..n_ev] }
&self.events[..n_ev]
}
/// Classify the sleep stage from physiological features.
@@ -1,4 +1,13 @@
//! Affect computing from physiological CSI signatures — ADR-041 exotic module.
//! Affect-proxy heuristic from physiological CSI signatures — ADR-041 exotic module.
//!
//! ⚠️ SPECULATIVE, UNVALIDATED AFFECT HEURISTIC. The outputs of this module
//! ⚠️ (`AROUSAL_LEVEL`, `STRESS_INDEX`, `CALM_DETECTED`, `AGITATION_DETECTED`)
//! ⚠️ are NOT measurements of emotion. They are threshold-based proxies over
//! ⚠️ breathing/motion/heart-rate estimates that have never been correlated
//! ⚠️ against self-report, physiological ground truth, or any reference standard
//! ⚠️ (see ADR-160 §A2). Do NOT use for affect inference, stress screening, or
//! ⚠️ any decision about a person's emotional state. The DSP (rolling statistics
//! ⚠️ + weighted scoring) is real; the affect interpretation of its output is not.
//!
//! # Algorithm
//!
@@ -153,6 +162,8 @@ pub struct EmotionDetector {
agitation_detected: bool,
/// Total frames processed.
frame_count: u32,
/// Per-call event scratch buffer (owned; replaces former `static mut`).
events: [(i32, f32); 4],
}
impl EmotionDetector {
@@ -171,6 +182,7 @@ impl EmotionDetector {
calm_detected: false,
agitation_detected: false,
frame_count: 0,
events: [(0, 0.0); 4],
}
}
@@ -192,7 +204,6 @@ impl EmotionDetector {
_phase: f32,
variance: f32,
) -> &[(i32, f32)] {
static mut EVENTS: [(i32, f32); 4] = [(0, 0.0); 4];
let mut n_ev = 0usize;
self.frame_count += 1;
@@ -251,31 +262,23 @@ impl EmotionDetector {
|| breath_cv > STRESS_BREATH_CV_THRESH);
// ── Emit events ──
unsafe {
EVENTS[n_ev] = (EVENT_AROUSAL_LEVEL, self.arousal);
}
self.events[n_ev] = (EVENT_AROUSAL_LEVEL, self.arousal);
n_ev += 1;
unsafe {
EVENTS[n_ev] = (EVENT_STRESS_INDEX, self.stress_index);
}
self.events[n_ev] = (EVENT_STRESS_INDEX, self.stress_index);
n_ev += 1;
if self.calm_detected {
unsafe {
EVENTS[n_ev] = (EVENT_CALM_DETECTED, 1.0);
}
self.events[n_ev] = (EVENT_CALM_DETECTED, 1.0);
n_ev += 1;
}
if self.agitation_detected {
unsafe {
EVENTS[n_ev] = (EVENT_AGITATION_DETECTED, 1.0);
}
self.events[n_ev] = (EVENT_AGITATION_DETECTED, 1.0);
n_ev += 1;
}
unsafe { &EVENTS[..n_ev] }
&self.events[..n_ev]
}
/// Compute breathing rate score [0, 1].
@@ -1,4 +1,13 @@
//! Sign language letter recognition from CSI signatures — ADR-041 exotic module.
//! Sign-language-letter-like recognition from CSI signatures — ADR-041 exotic / research module.
//!
//! ⚠️ EXPERIMENTAL RESEARCH MODULE — NOT VALIDATED. This is a *candidate*
//! ⚠️ coarse gesture-cluster classifier, NOT a validated sign-language
//! ⚠️ recognizer: it has never been evaluated against a labelled ASL (or any
//! ⚠️ sign-language) dataset, accuracy is unproven, and it does not recognize
//! ⚠️ true sign language (see ADR-160 §A4). Do NOT rely on its letter labels
//! ⚠️ for communication or accessibility. (Registry tag: Exotic / Research.)
//! ⚠️ The DSP (feature extraction + template matching) is real; the
//! ⚠️ sign-language interpretation is not validated.
//!
//! # Algorithm
//!
@@ -87,6 +96,8 @@ pub const EVENT_GESTURE_REJECTED: i32 = 623;
/// Supports up to 26 letter templates loaded via `set_template()`.
/// Uses DTW matching on compact feature sequences.
pub struct GestureLanguageDetector {
/// Per-call event scratch buffer (owned; replaces former `static mut`).
events: [(i32, f32); 4],
/// Template feature sequences: [template_idx][frame][feature].
templates: [[[f32; FEAT_DIM]; GESTURE_WIN_LEN]; MAX_TEMPLATES],
/// Length of each template (0 = not loaded).
@@ -118,6 +129,7 @@ pub struct GestureLanguageDetector {
impl GestureLanguageDetector {
pub const fn new() -> Self {
Self {
events: [(0, 0.0); 4],
templates: [[[0.0; FEAT_DIM]; GESTURE_WIN_LEN]; MAX_TEMPLATES],
template_lens: [0; MAX_TEMPLATES],
n_templates: 0,
@@ -201,7 +213,6 @@ impl GestureLanguageDetector {
motion_energy: f32,
presence: i32,
) -> &[(i32, f32)] {
static mut EVENTS: [(i32, f32); 4] = [(0, 0.0); 4];
let mut n_ev = 0usize;
self.frame_count += 1;
@@ -223,29 +234,21 @@ impl GestureLanguageDetector {
if self.gesture_fill >= MIN_GESTURE_FILL && self.gesture_active {
let (letter, confidence) = self.match_gesture();
if letter < MAX_TEMPLATES as u8 && self.since_last_letter >= DEBOUNCE_FRAMES {
unsafe {
EVENTS[n_ev] = (EVENT_LETTER_RECOGNIZED, letter as f32);
}
self.events[n_ev] = (EVENT_LETTER_RECOGNIZED, letter as f32);
n_ev += 1;
unsafe {
EVENTS[n_ev] = (EVENT_LETTER_CONFIDENCE, confidence);
}
self.events[n_ev] = (EVENT_LETTER_CONFIDENCE, confidence);
n_ev += 1;
self.last_letter = letter;
self.last_confidence = confidence;
self.since_last_letter = 0;
} else {
unsafe {
EVENTS[n_ev] = (EVENT_GESTURE_REJECTED, 1.0);
}
self.events[n_ev] = (EVENT_GESTURE_REJECTED, 1.0);
n_ev += 1;
}
}
// Emit word boundary.
unsafe {
EVENTS[n_ev] = (EVENT_WORD_BOUNDARY, 1.0);
}
self.events[n_ev] = (EVENT_WORD_BOUNDARY, 1.0);
n_ev += 1;
self.word_boundary_emitted = true;
self.reset_gesture();
@@ -264,7 +267,7 @@ impl GestureLanguageDetector {
}
}
unsafe { &EVENTS[..n_ev] }
&self.events[..n_ev]
}
/// Match the current gesture buffer against all loaded templates.
@@ -123,6 +123,8 @@ pub enum AnomalyClass {
/// Environmental anomaly detector for empty-room CSI monitoring.
pub struct GhostHunterDetector {
/// Per-call event scratch buffer (owned; replaces former `static mut`).
events: [(i32, f32); 4],
/// Noise floor per subcarrier group (slow EWMA of variance).
noise_floor: [Ema; N_GROUPS],
/// Anomaly energy buffer per group.
@@ -158,6 +160,7 @@ pub struct GhostHunterDetector {
impl GhostHunterDetector {
pub const fn new() -> Self {
Self {
events: [(0, 0.0); 4],
noise_floor: [
Ema::new(NOISE_ALPHA), Ema::new(NOISE_ALPHA),
Ema::new(NOISE_ALPHA), Ema::new(NOISE_ALPHA),
@@ -203,7 +206,6 @@ impl GhostHunterDetector {
presence: i32,
motion_energy: f32,
) -> &[(i32, f32)] {
static mut EVENTS: [(i32, f32); 4] = [(0, 0.0); 4];
let mut n_ev = 0usize;
self.frame_count += 1;
@@ -336,35 +338,27 @@ impl GhostHunterDetector {
let norm_energy = if energy > 1.0 { 1.0 } else { energy };
if anomaly_active {
unsafe {
EVENTS[n_ev] = (EVENT_ANOMALY_DETECTED, norm_energy);
}
self.events[n_ev] = (EVENT_ANOMALY_DETECTED, norm_energy);
n_ev += 1;
if self.current_class != AnomalyClass::None {
unsafe {
EVENTS[n_ev] = (EVENT_ANOMALY_CLASS, self.current_class as u8 as f32);
}
self.events[n_ev] = (EVENT_ANOMALY_CLASS, self.current_class as u8 as f32);
n_ev += 1;
}
}
if self.hidden_presence_score > HIDDEN_PRESENCE_THRESHOLD {
unsafe {
EVENTS[n_ev] = (EVENT_HIDDEN_PRESENCE, self.hidden_presence_score);
}
self.events[n_ev] = (EVENT_HIDDEN_PRESENCE, self.hidden_presence_score);
n_ev += 1;
}
if self.drift_frames >= DRIFT_MIN_FRAMES {
let drift_mag = fabsf(amp_delta) * self.drift_frames as f32;
unsafe {
EVENTS[n_ev] = (EVENT_ENVIRONMENTAL_DRIFT, drift_mag);
}
self.events[n_ev] = (EVENT_ENVIRONMENTAL_DRIFT, drift_mag);
n_ev += 1;
}
unsafe { &EVENTS[..n_ev] }
&self.events[..n_ev]
}
/// Check periodicity in the phase buffer via short autocorrelation.
@@ -1,12 +1,21 @@
//! Happiness score from WiFi CSI physiological proxies -- ADR-041 exotic module.
//! Gait-energy / affect-proxy scoring from WiFi CSI -- ADR-041 exotic module.
//!
//! ⚠️ SPECULATIVE, UNVALIDATED AFFECT HEURISTIC. The outputs of this module are
//! ⚠️ NOT measurements of emotion. `HAPPINESS_SCORE` is a gait-energy / movement
//! ⚠️ proxy, not a validated affect measure; it has never been correlated
//! ⚠️ against self-report, facial-affect, or any reference standard, and its
//! ⚠️ relationship to actual mood is unproven (see ADR-160 §A2). Do NOT use for
//! ⚠️ affect inference, screening, or any decision about a person's emotional
//! ⚠️ state. The DSP (rolling statistics + weighted scoring) is real; the affect
//! ⚠️ interpretation of its output is not.
//!
//! # Algorithm
//!
//! Combines six physiological proxies extracted from CSI into a composite
//! happiness score [0, 1]:
//! Combines six movement/physiology proxies extracted from CSI into a composite
//! gait-energy score [0, 1] (labelled `HAPPINESS_SCORE` for the event registry,
//! but it is a proxy, not an affect measurement):
//!
//! 1. **Gait speed** -- Doppler proxy from phase rate-of-change. Happy people
//! walk approximately 12% faster than neutral baseline.
//! 1. **Gait speed** -- Doppler proxy from phase rate-of-change.
//!
//! 2. **Stride regularity** -- Variance of step intervals from successive phase
//! differences. Regular strides correlate with confidence and positive affect.
@@ -31,7 +40,9 @@
//!
//! # Events (690-694: Exotic / Research)
//!
//! - `HAPPINESS_SCORE` (690): Composite happiness [0.0 = sad, 0.5 = neutral, 1.0 = happy].
//! - `HAPPINESS_SCORE` (690): Composite **gait-energy proxy** [0, 1], NOT a
//! validated affect measure. Higher = more energetic/fluid movement, which is
//! only speculatively (unvalidated) associated with positive affect.
//! - `GAIT_ENERGY` (691): Normalized gait speed/stride score [0, 1].
//! - `AFFECT_VALENCE` (692): Emotional valence from breathing + motion [0, 1].
//! - `SOCIAL_ENERGY` (693): Group animation/interaction level [0, 1].
@@ -97,7 +108,7 @@ const MAX_SC: usize = 32;
const EVENT_DECIMATION: u32 = 4;
/// Baseline gait speed (phase rate-of-change, arbitrary units).
/// Happy gait is ~12% above this.
/// Used only as a normalization reference for the gait-energy proxy.
const BASELINE_GAIT_SPEED: f32 = 0.5;
/// Maximum expected gait speed for normalization.
@@ -184,6 +195,9 @@ pub struct HappinessScoreDetector {
/// Total frames processed.
frame_count: u32,
/// Per-call event scratch buffer (owned; replaces former `static mut`).
events: [(i32, f32); 5],
}
impl HappinessScoreDetector {
@@ -209,6 +223,7 @@ impl HappinessScoreDetector {
happiness_vector: [0.0; HAPPINESS_VECTOR_DIM],
frame_count: 0,
events: [(0, 0.0); 5],
}
}
@@ -234,7 +249,6 @@ impl HappinessScoreDetector {
breathing_bpm: f32,
heart_rate_bpm: f32,
) -> &[(i32, f32)] {
static mut EVENTS: [(i32, f32); 5] = [(0, 0.0); 5];
let mut n_ev = 0usize;
self.frame_count += 1;
@@ -341,34 +355,24 @@ impl HappinessScoreDetector {
// ── Emit events (decimated for ESP32 bandwidth) ──
// Always emit happiness score; other events only every Nth frame.
unsafe {
EVENTS[n_ev] = (EVENT_HAPPINESS_SCORE, self.happiness);
}
self.events[n_ev] = (EVENT_HAPPINESS_SCORE, self.happiness);
n_ev += 1;
if self.frame_count % EVENT_DECIMATION == 0 {
unsafe {
EVENTS[n_ev] = (EVENT_GAIT_ENERGY, gait_energy);
}
self.events[n_ev] = (EVENT_GAIT_ENERGY, gait_energy);
n_ev += 1;
unsafe {
EVENTS[n_ev] = (EVENT_AFFECT_VALENCE, affect_valence);
}
self.events[n_ev] = (EVENT_AFFECT_VALENCE, affect_valence);
n_ev += 1;
unsafe {
EVENTS[n_ev] = (EVENT_SOCIAL_ENERGY, social_energy);
}
self.events[n_ev] = (EVENT_SOCIAL_ENERGY, social_energy);
n_ev += 1;
unsafe {
EVENTS[n_ev] = (EVENT_TRANSIT_DIRECTION, transit);
}
self.events[n_ev] = (EVENT_TRANSIT_DIRECTION, transit);
n_ev += 1;
}
unsafe { &EVENTS[..n_ev] }
&self.events[..n_ev]
}
/// Average phase rate-of-change over the rolling window.
@@ -88,6 +88,8 @@ pub const EVENT_LOCATION_LABEL: i32 = 687;
/// Pre-configured with 16 reference points (4 rooms, 12 zones) and a
/// linear projection from 8D CSI features to 2D Poincare disk.
pub struct HyperbolicEmbedder {
/// Per-call event scratch buffer (owned; replaces former `static mut`).
events: [(i32, f32); 3],
/// Reference embeddings on the Poincare disk [N_REFS][DIM].
references: [[f32; DIM]; N_REFS],
/// Linear projection matrix W: [DIM][FEAT_DIM] (2x8).
@@ -111,6 +113,7 @@ pub struct HyperbolicEmbedder {
impl HyperbolicEmbedder {
pub const fn new() -> Self {
Self {
events: [(0, 0.0); 3],
references: Self::default_references(),
projection_w: Self::default_projection(),
prev_label: 0,
@@ -166,7 +169,6 @@ impl HyperbolicEmbedder {
///
/// Returns events as `(event_id, value)` pairs.
pub fn process_frame(&mut self, amplitudes: &[f32]) -> &[(i32, f32)] {
static mut EVENTS: [(i32, f32); 3] = [(0, 0.0); 3];
let mut n_ev = 0usize;
if amplitudes.len() < FEAT_DIM {
@@ -250,22 +252,16 @@ impl HyperbolicEmbedder {
let level: u8 = if radius < LEVEL_RADIUS_THRESHOLD { 0 } else { 1 };
// Emit events.
unsafe {
EVENTS[n_ev] = (EVENT_HIERARCHY_LEVEL, level as f32);
}
self.events[n_ev] = (EVENT_HIERARCHY_LEVEL, level as f32);
n_ev += 1;
unsafe {
EVENTS[n_ev] = (EVENT_HYPERBOLIC_RADIUS, radius);
}
self.events[n_ev] = (EVENT_HYPERBOLIC_RADIUS, radius);
n_ev += 1;
unsafe {
EVENTS[n_ev] = (EVENT_LOCATION_LABEL, best_label as f32);
}
self.events[n_ev] = (EVENT_LOCATION_LABEL, best_label as f32);
n_ev += 1;
unsafe { &EVENTS[..n_ev] }
&self.events[..n_ev]
}
/// Set a reference embedding. `index` must be < N_REFS.
@@ -99,6 +99,8 @@ pub const EVENT_GESTURE_FERMATA: i32 = 634;
/// Extracts tempo, beat position, dynamics, and special gestures from
/// WiFi CSI motion patterns.
pub struct MusicConductorDetector {
/// Per-call event scratch buffer (owned; replaces former `static mut`).
events: [(i32, f32); 5],
/// Circular buffer of motion energy samples.
motion_buf: CircularBuffer<BUF_LEN>,
/// Autocorrelation values at lags MIN_LAG..MAX_LAG.
@@ -132,6 +134,7 @@ pub struct MusicConductorDetector {
impl MusicConductorDetector {
pub const fn new() -> Self {
Self {
events: [(0, 0.0); 5],
motion_buf: CircularBuffer::new(),
autocorr: [0.0; MAX_LAG],
tempo_ema: Ema::new(TEMPO_ALPHA),
@@ -165,7 +168,6 @@ impl MusicConductorDetector {
motion_energy: f32,
_variance: f32,
) -> &[(i32, f32)] {
static mut EVENTS: [(i32, f32); 5] = [(0, 0.0); 5];
let mut n_ev = 0usize;
self.frame_count += 1;
@@ -277,37 +279,27 @@ impl MusicConductorDetector {
// ── Emit events ──
if self.tempo_ema.is_initialized() {
unsafe {
EVENTS[n_ev] = (EVENT_CONDUCTOR_BPM, self.tempo_ema.value);
}
self.events[n_ev] = (EVENT_CONDUCTOR_BPM, self.tempo_ema.value);
n_ev += 1;
unsafe {
EVENTS[n_ev] = (EVENT_BEAT_POSITION, beat_position as f32);
}
self.events[n_ev] = (EVENT_BEAT_POSITION, beat_position as f32);
n_ev += 1;
}
unsafe {
EVENTS[n_ev] = (EVENT_DYNAMIC_LEVEL, dynamic_level);
}
self.events[n_ev] = (EVENT_DYNAMIC_LEVEL, dynamic_level);
n_ev += 1;
if self.cutoff_detected {
unsafe {
EVENTS[n_ev] = (EVENT_GESTURE_CUTOFF, 1.0);
}
self.events[n_ev] = (EVENT_GESTURE_CUTOFF, 1.0);
n_ev += 1;
}
if self.fermata_active {
unsafe {
EVENTS[n_ev] = (EVENT_GESTURE_FERMATA, 1.0);
}
self.events[n_ev] = (EVENT_GESTURE_FERMATA, 1.0);
n_ev += 1;
}
unsafe { &EVENTS[..n_ev] }
&self.events[..n_ev]
}
/// Compute buffer mean and variance (single-pass).
@@ -95,6 +95,8 @@ pub const EVENT_WATERING_EVENT: i32 = 643;
/// and phase to detect growth drift, circadian oscillation, wilting,
/// and watering events.
pub struct PlantGrowthDetector {
/// Per-call event scratch buffer (owned; replaces former `static mut`).
events: [(i32, f32); 4],
/// Slow EWMA of amplitude per subcarrier group.
amp_baseline: [Ema; N_GROUPS],
/// Fast EWMA of amplitude per subcarrier group.
@@ -124,6 +126,7 @@ pub struct PlantGrowthDetector {
impl PlantGrowthDetector {
pub const fn new() -> Self {
Self {
events: [(0, 0.0); 4],
amp_baseline: [
Ema::new(BASELINE_ALPHA), Ema::new(BASELINE_ALPHA),
Ema::new(BASELINE_ALPHA), Ema::new(BASELINE_ALPHA),
@@ -174,7 +177,6 @@ impl PlantGrowthDetector {
variance: &[f32],
presence: i32,
) -> &[(i32, f32)] {
static mut EVENTS: [(i32, f32); 4] = [(0, 0.0); 4];
let mut n_ev = 0usize;
self.frame_count += 1;
@@ -264,9 +266,7 @@ impl PlantGrowthDetector {
self.drift_interval_count = 0;
if fabsf(avg_drift) > GROWTH_THRESHOLD {
unsafe {
EVENTS[n_ev] = (EVENT_GROWTH_RATE, avg_drift);
}
self.events[n_ev] = (EVENT_GROWTH_RATE, avg_drift);
n_ev += 1;
}
}
@@ -288,9 +288,7 @@ impl PlantGrowthDetector {
if avg_osc > CIRCADIAN_MIN_MAGNITUDE {
// Normalize to [0, 1] range (cap at 1.0).
let normalized = if avg_osc > 1.0 { 1.0 } else { avg_osc };
unsafe {
EVENTS[n_ev] = (EVENT_CIRCADIAN_PHASE, normalized);
}
self.events[n_ev] = (EVENT_CIRCADIAN_PHASE, normalized);
n_ev += 1;
}
}
@@ -315,9 +313,7 @@ impl PlantGrowthDetector {
}
// Need majority of groups to agree.
if amp_rise_count >= (N_GROUPS / 2) as u8 && var_drop_count >= 2 {
unsafe {
EVENTS[n_ev] = (EVENT_WILT_DETECTED, 1.0);
}
self.events[n_ev] = (EVENT_WILT_DETECTED, 1.0);
n_ev += 1;
}
}
@@ -333,14 +329,12 @@ impl PlantGrowthDetector {
}
}
if drop_count >= (N_GROUPS / 2) as u8 {
unsafe {
EVENTS[n_ev] = (EVENT_WATERING_EVENT, 1.0);
}
self.events[n_ev] = (EVENT_WATERING_EVENT, 1.0);
n_ev += 1;
}
}
unsafe { &EVENTS[..n_ev] }
&self.events[..n_ev]
}
/// Get the number of empty-room frames accumulated.
@@ -99,6 +99,8 @@ pub enum RainIntensity {
/// Detects rain from broadband CSI phase variance perturbations.
pub struct RainDetector {
/// Per-call event scratch buffer (owned; replaces former `static mut`).
events: [(i32, f32); 3],
/// Baseline variance per subcarrier group (slow EWMA).
baseline_var: [Ema; N_GROUPS],
/// Short-term variance per subcarrier group (fast EWMA).
@@ -122,6 +124,7 @@ pub struct RainDetector {
impl RainDetector {
pub const fn new() -> Self {
Self {
events: [(0, 0.0); 3],
baseline_var: [
Ema::new(BASELINE_ALPHA), Ema::new(BASELINE_ALPHA),
Ema::new(BASELINE_ALPHA), Ema::new(BASELINE_ALPHA),
@@ -159,7 +162,6 @@ impl RainDetector {
amplitudes: &[f32],
presence: i32,
) -> &[(i32, f32)] {
static mut EVENTS: [(i32, f32); 3] = [(0, 0.0); 3];
let mut n_ev = 0usize;
self.frame_count += 1;
@@ -250,9 +252,7 @@ impl RainDetector {
// Onset: was not raining, now have enough consecutive rain frames.
if !self.raining && self.rain_frames >= ONSET_FRAMES {
self.raining = true;
unsafe {
EVENTS[n_ev] = (EVENT_RAIN_ONSET, 1.0);
}
self.events[n_ev] = (EVENT_RAIN_ONSET, 1.0);
n_ev += 1;
}
@@ -260,9 +260,7 @@ impl RainDetector {
if was_raining && self.quiet_frames >= CESSATION_FRAMES {
self.raining = false;
self.intensity = RainIntensity::None;
unsafe {
EVENTS[n_ev] = (EVENT_RAIN_CESSATION, 1.0);
}
self.events[n_ev] = (EVENT_RAIN_CESSATION, 1.0);
n_ev += 1;
}
@@ -277,13 +275,11 @@ impl RainDetector {
RainIntensity::Heavy
};
unsafe {
EVENTS[n_ev] = (EVENT_RAIN_INTENSITY, self.intensity as u8 as f32);
}
self.events[n_ev] = (EVENT_RAIN_INTENSITY, self.intensity as u8 as f32);
n_ev += 1;
}
unsafe { &EVENTS[..n_ev] }
&self.events[..n_ev]
}
/// Whether rain is currently detected.
@@ -74,6 +74,8 @@ pub const EVENT_COORDINATION_INDEX: i32 = 682;
/// Samples `motion_energy` into a circular buffer and runs autocorrelation
/// to detect period doubling and multi-person temporal coordination.
pub struct TimeCrystalDetector {
/// Per-call event scratch buffer (owned; replaces former `static mut`).
events: [(i32, f32); 3],
/// Circular buffer of motion energy samples.
motion_buf: CircularBuffer<BUF_LEN>,
/// Autocorrelation values at lags 1..MAX_LAG.
@@ -101,6 +103,7 @@ pub struct TimeCrystalDetector {
impl TimeCrystalDetector {
pub const fn new() -> Self {
Self {
events: [(0, 0.0); 3],
motion_buf: CircularBuffer::new(),
autocorr: [0.0; MAX_LAG],
last_multiplier: 0,
@@ -119,7 +122,6 @@ impl TimeCrystalDetector {
///
/// Returns events as `(event_id, value)` pairs in a static buffer.
pub fn process_frame(&mut self, motion_energy: f32) -> &[(i32, f32)] {
static mut EVENTS: [(i32, f32); 3] = [(0, 0.0); 3];
let mut n_ev = 0usize;
// Push sample into circular buffer.
@@ -216,25 +218,19 @@ impl TimeCrystalDetector {
// Emit events.
if detected_multiplier > 0 {
unsafe {
EVENTS[n_ev] = (EVENT_CRYSTAL_DETECTED, detected_multiplier as f32);
}
self.events[n_ev] = (EVENT_CRYSTAL_DETECTED, detected_multiplier as f32);
n_ev += 1;
}
unsafe {
EVENTS[n_ev] = (EVENT_CRYSTAL_STABILITY, self.stability_ema.value);
}
self.events[n_ev] = (EVENT_CRYSTAL_STABILITY, self.stability_ema.value);
n_ev += 1;
if coordination > 0 {
unsafe {
EVENTS[n_ev] = (EVENT_COORDINATION_INDEX, coordination as f32);
}
self.events[n_ev] = (EVENT_COORDINATION_INDEX, coordination as f32);
n_ev += 1;
}
unsafe { &EVENTS[..n_ev] }
&self.events[..n_ev]
}
/// Compute mean and variance of the circular buffer contents.
@@ -41,6 +41,8 @@ pub const EVENT_COMPLIANCE_REPORT: i32 = 523;
/// Clean room monitor.
pub struct CleanRoomMonitor {
/// Per-call event scratch buffer (owned; replaces former `static mut`).
events: [(i32, f32); 4],
/// Maximum allowed occupancy.
max_occupancy: u8,
/// Current smoothed person count.
@@ -70,6 +72,7 @@ pub struct CleanRoomMonitor {
impl CleanRoomMonitor {
pub const fn new() -> Self {
Self {
events: [(0, 0.0); 4],
max_occupancy: DEFAULT_MAX_OCCUPANCY,
current_count: 0,
prev_count: 0,
@@ -88,6 +91,7 @@ impl CleanRoomMonitor {
/// Create with custom maximum occupancy.
pub const fn with_max_occupancy(max: u8) -> Self {
Self {
events: [(0, 0.0); 4],
max_occupancy: max,
current_count: 0,
prev_count: 0,
@@ -146,12 +150,11 @@ impl CleanRoomMonitor {
}
}
static mut EVENTS: [(i32, f32); 4] = [(0, 0.0); 4];
let mut n_events = 0usize;
// --- Step 1: Emit count changes ---
if count != self.prev_count && n_events < 4 {
unsafe { EVENTS[n_events] = (EVENT_OCCUPANCY_COUNT, count as f32); }
self.events[n_events] = (EVENT_OCCUPANCY_COUNT, count as f32);
n_events += 1;
}
@@ -166,7 +169,7 @@ impl CleanRoomMonitor {
self.violation_cooldown = VIOLATION_COOLDOWN;
// Value encodes: count * 10 + max_allowed.
let val = count as f32;
unsafe { EVENTS[n_events] = (EVENT_OCCUPANCY_VIOLATION, val); }
self.events[n_events] = (EVENT_OCCUPANCY_VIOLATION, val);
n_events += 1;
}
} else {
@@ -182,7 +185,7 @@ impl CleanRoomMonitor {
{
self.total_turbulent += 1;
self.turbulent_cooldown = TURBULENT_COOLDOWN;
unsafe { EVENTS[n_events] = (EVENT_TURBULENT_MOTION, motion_energy); }
self.events[n_events] = (EVENT_TURBULENT_MOTION, motion_energy);
n_events += 1;
}
} else {
@@ -196,11 +199,11 @@ impl CleanRoomMonitor {
} else {
100.0
};
unsafe { EVENTS[n_events] = (EVENT_COMPLIANCE_REPORT, compliance_pct); }
self.events[n_events] = (EVENT_COMPLIANCE_REPORT, compliance_pct);
n_events += 1;
}
unsafe { &EVENTS[..n_events] }
&self.events[..n_events]
}
/// Current occupancy count.
@@ -55,6 +55,8 @@ pub enum WorkerState {
/// Confined space monitor.
pub struct ConfinedSpaceMonitor {
/// Per-call event scratch buffer (owned; replaces former `static mut`).
events: [(i32, f32); 4],
/// Current worker state.
state: WorkerState,
/// Presence debounce counters.
@@ -79,6 +81,7 @@ pub struct ConfinedSpaceMonitor {
impl ConfinedSpaceMonitor {
pub const fn new() -> Self {
Self {
events: [(0, 0.0); 4],
state: WorkerState::Empty,
present_count: 0,
absent_count: 0,
@@ -110,7 +113,6 @@ impl ConfinedSpaceMonitor {
) -> &[(i32, f32)] {
self.frame_count += 1;
static mut EVENTS: [(i32, f32); 4] = [(0, 0.0); 4];
let mut n_events = 0usize;
// --- Step 1: Debounced presence detection ---
@@ -141,7 +143,7 @@ impl ConfinedSpaceMonitor {
self.extraction_alerted = false;
self.immobile_alerted = false;
if n_events < 4 {
unsafe { EVENTS[n_events] = (EVENT_WORKER_ENTRY, 1.0); }
self.events[n_events] = (EVENT_WORKER_ENTRY, 1.0);
n_events += 1;
}
}
@@ -150,7 +152,7 @@ impl ConfinedSpaceMonitor {
if !self.worker_inside && was_inside {
self.state = WorkerState::Empty;
if n_events < 4 {
unsafe { EVENTS[n_events] = (EVENT_WORKER_EXIT, 1.0); }
self.events[n_events] = (EVENT_WORKER_EXIT, 1.0);
n_events += 1;
}
}
@@ -169,7 +171,7 @@ impl ConfinedSpaceMonitor {
// Periodic breathing confirmation.
if self.frame_count % BREATHING_REPORT_INTERVAL == 0 && n_events < 4 {
unsafe { EVENTS[n_events] = (EVENT_BREATHING_OK, breathing_bpm); }
self.events[n_events] = (EVENT_BREATHING_OK, breathing_bpm);
n_events += 1;
}
} else {
@@ -197,7 +199,7 @@ impl ConfinedSpaceMonitor {
self.state = WorkerState::BreathingCeased;
self.extraction_alerted = true;
let seconds = self.no_breathing_frames as f32 / 20.0;
unsafe { EVENTS[n_events] = (EVENT_EXTRACTION_ALERT, seconds); }
self.events[n_events] = (EVENT_EXTRACTION_ALERT, seconds);
n_events += 1;
}
@@ -209,12 +211,12 @@ impl ConfinedSpaceMonitor {
self.state = WorkerState::Immobile;
self.immobile_alerted = true;
let seconds = self.no_motion_frames as f32 / 20.0;
unsafe { EVENTS[n_events] = (EVENT_IMMOBILE_ALERT, seconds); }
self.events[n_events] = (EVENT_IMMOBILE_ALERT, seconds);
n_events += 1;
}
}
unsafe { &EVENTS[..n_events] }
&self.events[..n_events]
}
/// Current worker state.
@@ -59,6 +59,8 @@ pub const EVENT_HUMAN_NEAR_VEHICLE: i32 = 502;
/// Forklift proximity detector.
pub struct ForkliftProximityDetector {
/// Per-call event scratch buffer (owned; replaces former `static mut`).
events: [(i32, f32); 4],
/// Per-subcarrier baseline amplitude (calibrated).
baseline_amp: [f32; MAX_SC],
/// Phase history ring buffer for frequency analysis.
@@ -83,6 +85,7 @@ pub struct ForkliftProximityDetector {
impl ForkliftProximityDetector {
pub const fn new() -> Self {
Self {
events: [(0, 0.0); 4],
baseline_amp: [0.0; MAX_SC],
phase_history: [[0.0; MAX_SC]; PHASE_HISTORY],
phase_hist_idx: 0,
@@ -139,7 +142,6 @@ impl ForkliftProximityDetector {
self.phase_hist_len += 1;
}
static mut EVENTS: [(i32, f32); 4] = [(0, 0.0); 4];
let mut n_events = 0usize;
// Calibration phase: 100 frames (~5 seconds).
@@ -158,7 +160,7 @@ impl ForkliftProximityDetector {
}
self.calibrated = true;
}
return unsafe { &EVENTS[..0] };
return &self.events[..0];
}
// --- Step 1: Detect forklift/AGV signature ---
@@ -182,9 +184,7 @@ impl ForkliftProximityDetector {
// Emit vehicle detected on transition.
if self.vehicle_present && !was_vehicle && n_events < 4 {
unsafe {
EVENTS[n_events] = (EVENT_VEHICLE_DETECTED, amp_ratio);
}
self.events[n_events] = (EVENT_VEHICLE_DETECTED, amp_ratio);
n_events += 1;
}
@@ -197,9 +197,7 @@ impl ForkliftProximityDetector {
// Emit human-near-vehicle event on transition (debounce threshold reached).
if self.proximity_debounce == PROXIMITY_DEBOUNCE && n_events < 4 {
unsafe {
EVENTS[n_events] = (EVENT_HUMAN_NEAR_VEHICLE, motion_energy);
}
self.events[n_events] = (EVENT_HUMAN_NEAR_VEHICLE, motion_energy);
n_events += 1;
}
@@ -215,9 +213,7 @@ impl ForkliftProximityDetector {
} else {
2.0 // caution
};
unsafe {
EVENTS[n_events] = (EVENT_PROXIMITY_WARNING, dist_cat);
}
self.events[n_events] = (EVENT_PROXIMITY_WARNING, dist_cat);
n_events += 1;
self.cooldown = ALERT_COOLDOWN;
}
@@ -225,7 +221,7 @@ impl ForkliftProximityDetector {
self.proximity_debounce = 0;
}
unsafe { &EVENTS[..n_events] }
&self.events[..n_events]
}
/// Compute mean amplitude ratio vs baseline across subcarriers.
@@ -72,6 +72,8 @@ impl Species {
/// Livestock monitor.
pub struct LivestockMonitor {
/// Per-call event scratch buffer (owned; replaces former `static mut`).
events: [(i32, f32); 4],
/// Configured species.
species: Species,
/// Whether animal is currently detected (debounced).
@@ -97,6 +99,7 @@ pub struct LivestockMonitor {
impl LivestockMonitor {
pub const fn new() -> Self {
Self {
events: [(0, 0.0); 4],
species: Species::Cattle,
animal_present: false,
presence_frames: 0,
@@ -113,6 +116,7 @@ impl LivestockMonitor {
/// Create with a specific species.
pub const fn with_species(species: Species) -> Self {
Self {
events: [(0, 0.0); 4],
species,
animal_present: false,
presence_frames: 0,
@@ -148,7 +152,6 @@ impl LivestockMonitor {
self.escape_cooldown -= 1;
}
static mut EVENTS: [(i32, f32); 4] = [(0, 0.0); 4];
let mut n_events = 0usize;
let raw_present = presence > 0 || motion_energy > MIN_MOTION_ACTIVE;
@@ -177,7 +180,7 @@ impl LivestockMonitor {
{
self.escape_cooldown = ESCAPE_COOLDOWN;
let minutes_present = self.presence_frames as f32 / (20.0 * 60.0);
unsafe { EVENTS[n_events] = (EVENT_ESCAPE_ALERT, minutes_present); }
self.events[n_events] = (EVENT_ESCAPE_ALERT, minutes_present);
n_events += 1;
}
@@ -190,7 +193,7 @@ impl LivestockMonitor {
&& self.frame_count % PRESENCE_REPORT_INTERVAL == 0
&& n_events < 4
{
unsafe { EVENTS[n_events] = (EVENT_ANIMAL_PRESENT, breathing_bpm); }
self.events[n_events] = (EVENT_ANIMAL_PRESENT, breathing_bpm);
n_events += 1;
}
@@ -209,7 +212,7 @@ impl LivestockMonitor {
{
self.stillness_alerted = true;
let minutes_still = self.still_frames as f32 / (20.0 * 60.0);
unsafe { EVENTS[n_events] = (EVENT_ABNORMAL_STILLNESS, minutes_still); }
self.events[n_events] = (EVENT_ABNORMAL_STILLNESS, minutes_still);
n_events += 1;
}
}
@@ -226,7 +229,7 @@ impl LivestockMonitor {
if is_labored {
self.labored_debounce = self.labored_debounce.saturating_add(1);
if self.labored_debounce >= LABORED_DEBOUNCE && n_events < 4 {
unsafe { EVENTS[n_events] = (EVENT_LABORED_BREATHING, breathing_bpm); }
self.events[n_events] = (EVENT_LABORED_BREATHING, breathing_bpm);
n_events += 1;
self.labored_debounce = 0; // Reset to allow repeated alerts.
}
@@ -235,7 +238,7 @@ impl LivestockMonitor {
}
}
unsafe { &EVENTS[..n_events] }
&self.events[..n_events]
}
/// Whether an animal is currently detected.
@@ -72,6 +72,8 @@ pub const EVENT_VIBRATION_SPECTRUM: i32 = 543;
/// Structural vibration monitor.
pub struct StructuralVibrationMonitor {
/// Per-call event scratch buffer (owned; replaces former `static mut`).
events: [(i32, f32); 4],
/// Phase history ring buffer [time][subcarrier].
phase_history: [[f32; MAX_SC]; PHASE_HISTORY_LEN],
hist_idx: usize,
@@ -104,6 +106,7 @@ pub struct StructuralVibrationMonitor {
impl StructuralVibrationMonitor {
pub const fn new() -> Self {
Self {
events: [(0, 0.0); 4],
phase_history: [[0.0; MAX_SC]; PHASE_HISTORY_LEN],
hist_idx: 0,
hist_len: 0,
@@ -162,7 +165,6 @@ impl StructuralVibrationMonitor {
self.hist_len += 1;
}
static mut EVENTS: [(i32, f32); 4] = [(0, 0.0); 4];
let mut n_events = 0usize;
// --- Calibration: establish baseline when space is empty ---
@@ -180,7 +182,7 @@ impl StructuralVibrationMonitor {
self.baseline_set = true;
}
}
return unsafe { &EVENTS[..0] };
return &self.events[..0];
}
// Only analyze when unoccupied (human presence masks structural signals).
@@ -191,7 +193,7 @@ impl StructuralVibrationMonitor {
self.drift_direction[i] = 0;
self.drift_accumulator[i] = 0.0;
}
return unsafe { &EVENTS[..0] };
return &self.events[..0];
}
// --- Step 1: Compute phase deviation RMS ---
@@ -209,7 +211,7 @@ impl StructuralVibrationMonitor {
&& n_events < 4
{
self.seismic_cooldown = SEISMIC_COOLDOWN;
unsafe { EVENTS[n_events] = (EVENT_SEISMIC_DETECTED, rms); }
self.events[n_events] = (EVENT_SEISMIC_DETECTED, rms);
n_events += 1;
}
}
@@ -235,7 +237,7 @@ impl StructuralVibrationMonitor {
} else {
0.0
};
unsafe { EVENTS[n_events] = (EVENT_MECHANICAL_RESONANCE, freq); }
self.events[n_events] = (EVENT_MECHANICAL_RESONANCE, freq);
n_events += 1;
}
} else {
@@ -253,7 +255,7 @@ impl StructuralVibrationMonitor {
if fabsf(avg_drift) > DRIFT_RATE_THRESH {
self.drift_cooldown = DRIFT_COOLDOWN;
// Value is drift rate in rad/second.
unsafe { EVENTS[n_events] = (EVENT_STRUCTURAL_DRIFT, avg_drift * 20.0); }
self.events[n_events] = (EVENT_STRUCTURAL_DRIFT, avg_drift * 20.0);
n_events += 1;
}
}
@@ -263,11 +265,11 @@ impl StructuralVibrationMonitor {
&& self.hist_len >= MAX_LAGS + 1
&& n_events < 4
{
unsafe { EVENTS[n_events] = (EVENT_VIBRATION_SPECTRUM, rms); }
self.events[n_events] = (EVENT_VIBRATION_SPECTRUM, rms);
n_events += 1;
}
unsafe { &EVENTS[..n_events] }
&self.events[..n_events]
}
/// Compute RMS phase deviation from baseline.
@@ -57,6 +57,8 @@ pub enum DetectorState {
/// Intrusion detector.
pub struct IntrusionDetector {
/// Per-call event scratch buffer (owned; replaces former `static mut`).
events: [(i32, f32); 4],
/// Per-subcarrier baseline amplitude.
baseline_amp: [f32; MAX_SC],
/// Per-subcarrier baseline variance.
@@ -86,6 +88,7 @@ pub struct IntrusionDetector {
impl IntrusionDetector {
pub const fn new() -> Self {
Self {
events: [(0, 0.0); 4],
baseline_amp: [0.0; MAX_SC],
baseline_var: [0.0; MAX_SC],
prev_phases: [0.0; MAX_SC],
@@ -119,7 +122,6 @@ impl IntrusionDetector {
self.cooldown -= 1;
}
static mut EVENTS: [(i32, f32); 4] = [(0, 0.0); 4];
let mut n_events = 0usize;
match self.state {
@@ -165,9 +167,7 @@ impl IntrusionDetector {
if self.quiet_frames >= ARM_FRAMES {
self.state = DetectorState::Armed;
if n_events < 4 {
unsafe {
EVENTS[n_events] = (EVENT_INTRUSION_ARMED, 1.0);
}
self.events[n_events] = (EVENT_INTRUSION_ARMED, 1.0);
n_events += 1;
}
}
@@ -190,18 +190,14 @@ impl IntrusionDetector {
self.cooldown = ALERT_COOLDOWN;
if n_events < 4 {
unsafe {
EVENTS[n_events] = (EVENT_INTRUSION_ALERT, disturbance);
}
self.events[n_events] = (EVENT_INTRUSION_ALERT, disturbance);
n_events += 1;
}
// Find the most disturbed zone.
let zone = self.find_disturbed_zone(amplitudes, n_sc);
if n_events < 4 {
unsafe {
EVENTS[n_events] = (EVENT_INTRUSION_ZONE, zone as f32);
}
self.events[n_events] = (EVENT_INTRUSION_ZONE, zone as f32);
n_events += 1;
}
}
@@ -235,7 +231,7 @@ impl IntrusionDetector {
}
}
unsafe { &EVENTS[..n_events] }
&self.events[..n_events]
}
/// Compute overall disturbance score.
+14 -2
View File
@@ -46,10 +46,20 @@ pub mod vital_trend;
pub mod intrusion;
// ── Category 1: Medical & Health (ADR-041, event IDs 100-199) ───────────────
//
// ⚠️ EXPERIMENTAL — NOT clinically validated, NOT medical devices (ADR-160 §A1).
// Gated behind the non-default `medical-experimental` feature so they cannot be
// silently built into a shipping artifact. The DSP is real; the clinical claim
// surface is not. See each module's header disclaimer.
#[cfg(feature = "medical-experimental")]
pub mod med_sleep_apnea;
#[cfg(feature = "medical-experimental")]
pub mod med_cardiac_arrhythmia;
#[cfg(feature = "medical-experimental")]
pub mod med_respiratory_distress;
#[cfg(feature = "medical-experimental")]
pub mod med_gait_analysis;
#[cfg(feature = "medical-experimental")]
pub mod med_seizure_detect;
// ── Category 2: Security & Safety (ADR-041, event IDs 200-299) ──────────────
@@ -228,9 +238,11 @@ pub mod event_types {
pub const DEPARTURE_DETECTED: i32 = 212;
pub const SEC_ZONE_TRANSITION: i32 = 213;
// sec_weapon_detect (220-222)
// sec_weapon_detect (220-222) — ADR-160 §A3: honest physical-quantity names.
// `WEAPON_ALERT` was renamed to `HIGH_METAL_REFLECTIVITY`: a variance ratio
// measures RF reflectivity, not weapon-grade discrimination.
pub const METAL_ANOMALY: i32 = 220;
pub const WEAPON_ALERT: i32 = 221;
pub const HIGH_METAL_REFLECTIVITY: i32 = 221;
pub const CALIBRATION_NEEDED: i32 = 222;
// sec_tailgating (230-232)
@@ -71,6 +71,8 @@ type StateVec = [f32; STATE_DIM];
/// Attractor-based anomaly detector.
pub struct AttractorDetector {
/// Per-call event scratch buffer (owned; replaces former `static mut`).
events: [(i32, f32); 4],
/// Circular trajectory buffer.
trajectory: [StateVec; TRAJ_LEN],
/// Write index into trajectory buffer.
@@ -108,6 +110,7 @@ pub struct AttractorDetector {
impl AttractorDetector {
pub const fn new() -> Self {
Self {
events: [(0, 0.0); 4],
trajectory: [[0.0; STATE_DIM]; TRAJ_LEN],
traj_idx: 0,
traj_len: 0,
@@ -137,7 +140,6 @@ impl AttractorDetector {
amplitudes: &[f32],
motion_energy: f32,
) -> &[(i32, f32)] {
static mut EVENTS: [(i32, f32); 4] = [(0, 0.0); 4];
let mut n_ev = 0usize;
let n_sc = phases.len().min(amplitudes.len());
@@ -200,16 +202,14 @@ impl AttractorDetector {
self.radius = 0.01;
}
unsafe {
EVENTS[n_ev] = (EVENT_LEARNING_COMPLETE, 1.0);
n_ev += 1;
EVENTS[n_ev] = (EVENT_ATTRACTOR_TYPE, self.attractor_type as u8 as f32);
n_ev += 1;
EVENTS[n_ev] = (EVENT_LYAPUNOV_EXPONENT, lambda);
n_ev += 1;
}
self.events[n_ev] = (EVENT_LEARNING_COMPLETE, 1.0);
n_ev += 1;
self.events[n_ev] = (EVENT_ATTRACTOR_TYPE, self.attractor_type as u8 as f32);
n_ev += 1;
self.events[n_ev] = (EVENT_LYAPUNOV_EXPONENT, lambda);
n_ev += 1;
return unsafe { &EVENTS[..n_ev] };
return &self.events[..n_ev];
}
return &[];
@@ -221,10 +221,8 @@ impl AttractorDetector {
if dist > departure_threshold && self.cooldown == 0 {
self.cooldown = DEPARTURE_COOLDOWN;
unsafe {
EVENTS[n_ev] = (EVENT_BASIN_DEPARTURE, dist / self.radius);
n_ev += 1;
}
self.events[n_ev] = (EVENT_BASIN_DEPARTURE, dist / self.radius);
n_ev += 1;
}
// ── Periodic attractor update (every 200 frames) ────────────────
@@ -234,16 +232,14 @@ impl AttractorDetector {
if new_type != self.attractor_type && n_ev < 3 {
self.attractor_type = new_type;
unsafe {
EVENTS[n_ev] = (EVENT_ATTRACTOR_TYPE, new_type as u8 as f32);
n_ev += 1;
EVENTS[n_ev] = (EVENT_LYAPUNOV_EXPONENT, lambda);
n_ev += 1;
}
self.events[n_ev] = (EVENT_ATTRACTOR_TYPE, new_type as u8 as f32);
n_ev += 1;
self.events[n_ev] = (EVENT_LYAPUNOV_EXPONENT, lambda);
n_ev += 1;
}
}
unsafe { &EVENTS[..n_ev] }
&self.events[..n_ev]
}
/// Compute the current largest Lyapunov exponent estimate.
@@ -85,6 +85,8 @@ impl Template {
/// User-teachable gesture learner and recognizer.
pub struct GestureLearner {
/// Per-call event scratch buffer (owned; replaces former `static mut`).
events: [(i32, f32); 4],
// ── Stored templates ─────────────────────────────────────────────────
templates: [Template; MAX_TEMPLATES],
template_count: usize,
@@ -117,6 +119,7 @@ pub struct GestureLearner {
impl GestureLearner {
pub const fn new() -> Self {
Self {
events: [(0, 0.0); 4],
templates: [Template::empty(); MAX_TEMPLATES],
template_count: 0,
learn_phase: LearnPhase::Idle,
@@ -143,7 +146,6 @@ impl GestureLearner {
///
/// Returns events as `(event_id, value)` pairs in a static buffer.
pub fn process_frame(&mut self, phases: &[f32], motion_energy: f32) -> &[(i32, f32)] {
static mut EVENTS: [(i32, f32); 4] = [(0, 0.0); 4];
let mut n_ev = 0usize;
if phases.is_empty() {
@@ -228,12 +230,10 @@ impl GestureLearner {
// Check if all 3 rehearsals are mutually similar.
if self.rehearsals_are_similar() {
if let Some(id) = self.commit_template() {
unsafe {
EVENTS[n_ev] = (EVENT_GESTURE_LEARNED, id as f32);
n_ev += 1;
EVENTS[n_ev] = (EVENT_TEMPLATE_COUNT, self.template_count as f32);
n_ev += 1;
}
self.events[n_ev] = (EVENT_GESTURE_LEARNED, id as f32);
n_ev += 1;
self.events[n_ev] = (EVENT_TEMPLATE_COUNT, self.template_count as f32);
n_ev += 1;
}
}
// Reset learning state regardless.
@@ -284,18 +284,16 @@ impl GestureLearner {
if let Some(id) = best_id {
self.cooldown = MATCH_COOLDOWN;
unsafe {
EVENTS[n_ev] = (EVENT_GESTURE_MATCHED, id as f32);
self.events[n_ev] = (EVENT_GESTURE_MATCHED, id as f32);
n_ev += 1;
if n_ev < 4 {
self.events[n_ev] = (EVENT_MATCH_DISTANCE, best_dist);
n_ev += 1;
if n_ev < 4 {
EVENTS[n_ev] = (EVENT_MATCH_DISTANCE, best_dist);
n_ev += 1;
}
}
}
}
unsafe { &EVENTS[..n_ev] }
&self.events[..n_ev]
}
/// Check if all rehearsals are pairwise similar (DTW distance < threshold).
@@ -99,6 +99,8 @@ pub const EVENT_FORGETTING_RISK: i32 = 748;
/// Elastic Weight Consolidation lifelong on-device learner.
pub struct EwcLifelong {
/// Per-call event scratch buffer (owned; replaces former `static mut`).
events: [(i32, f32); 4],
/// Current learnable parameters [N_PARAMS] (flattened [N_OUTPUT][N_INPUT]).
params: [f32; N_PARAMS],
/// Fisher Information diagonal [N_PARAMS].
@@ -128,6 +130,7 @@ pub struct EwcLifelong {
impl EwcLifelong {
pub const fn new() -> Self {
Self {
events: [(0, 0.0); 4],
params: Self::default_params(),
fisher: [0.0; N_PARAMS],
theta_star: [0.0; N_PARAMS],
@@ -169,7 +172,6 @@ impl EwcLifelong {
///
/// Returns events as `(event_id, value)` pairs.
pub fn process_frame(&mut self, features: &[f32], target_zone: i32) -> &[(i32, f32)] {
static mut EVENTS: [(i32, f32); 4] = [(0, 0.0); 4];
let mut n_ev = 0usize;
if features.len() < N_INPUT {
@@ -217,17 +219,13 @@ impl EwcLifelong {
&& self.task_count < MAX_TASKS
{
self.commit_task();
unsafe {
EVENTS[n_ev] = (EVENT_NEW_TASK_LEARNED, self.task_count as f32);
}
self.events[n_ev] = (EVENT_NEW_TASK_LEARNED, self.task_count as f32);
n_ev += 1;
// Emit mean Fisher value.
let mean_fisher = self.mean_fisher();
if n_ev < 4 {
unsafe {
EVENTS[n_ev] = (EVENT_FISHER_UPDATE, mean_fisher);
}
self.events[n_ev] = (EVENT_FISHER_UPDATE, mean_fisher);
n_ev += 1;
}
}
@@ -235,9 +233,7 @@ impl EwcLifelong {
// Periodic reporting.
if self.frame_count % REPORT_INTERVAL == 0 {
if n_ev < 4 {
unsafe {
EVENTS[n_ev] = (EVENT_KNOWLEDGE_RETAINED, ewc_penalty);
}
self.events[n_ev] = (EVENT_KNOWLEDGE_RETAINED, ewc_penalty);
n_ev += 1;
}
@@ -248,15 +244,13 @@ impl EwcLifelong {
0.0
};
if n_ev < 4 {
unsafe {
EVENTS[n_ev] = (EVENT_FORGETTING_RISK, risk);
}
self.events[n_ev] = (EVENT_FORGETTING_RISK, risk);
n_ev += 1;
}
}
}
unsafe { &EVENTS[..n_ev] }
&self.events[..n_ev]
}
/// Forward pass: linear classifier `output = params * features`.
@@ -85,6 +85,8 @@ enum OptPhase {
/// Meta-learning parameter optimizer.
pub struct MetaAdapter {
/// Per-call event scratch buffer (owned; replaces former `static mut`).
events: [(i32, f32); 4],
/// Tunable parameters.
params: [TunableParam; NUM_PARAMS],
@@ -140,6 +142,7 @@ impl MetaAdapter {
/// 7: intrusion_sensitivity (0.30, range 0.05-0.9)
pub const fn new() -> Self {
Self {
events: [(0, 0.0); 4],
params: [
TunableParam::new(0.05, 0.01, 0.50, 0.01),
TunableParam::new(0.10, 0.02, 1.00, 0.02),
@@ -198,7 +201,6 @@ impl MetaAdapter {
///
/// Returns events as `(event_id, value)` pairs.
pub fn on_timer(&mut self) -> &[(i32, f32)] {
static mut EVENTS: [(i32, f32); 4] = [(0, 0.0); 4];
let mut n_ev = 0usize;
self.eval_ticks += 1;
@@ -228,16 +230,14 @@ impl MetaAdapter {
self.consecutive_failures = 0;
self.success_count += 1;
unsafe {
EVENTS[n_ev] = (
EVENT_PARAM_ADJUSTED,
self.current_param as f32
+ self.params[self.current_param].value / 1000.0,
);
n_ev += 1;
EVENTS[n_ev] = (EVENT_ADAPTATION_SCORE, score);
n_ev += 1;
}
self.events[n_ev] = (
EVENT_PARAM_ADJUSTED,
self.current_param as f32
+ self.params[self.current_param].value / 1000.0,
);
n_ev += 1;
self.events[n_ev] = (EVENT_ADAPTATION_SCORE, score);
n_ev += 1;
} else {
// Revert the perturbation.
self.params[self.current_param].value =
@@ -248,10 +248,8 @@ impl MetaAdapter {
// ── Safety rollback ──────────────────────────────────
if self.consecutive_failures >= MAX_CONSECUTIVE_FAILURES {
self.safety_rollback();
unsafe {
EVENTS[n_ev] = (EVENT_ROLLBACK_TRIGGERED, self.meta_level as f32);
n_ev += 1;
}
self.events[n_ev] = (EVENT_ROLLBACK_TRIGGERED, self.meta_level as f32);
n_ev += 1;
}
// ── Advance to next parameter ────────────────────────
@@ -261,16 +259,14 @@ impl MetaAdapter {
// ── Emit meta level periodically ─────────────────────
if self.sweep_idx == 0 && n_ev < 4 {
unsafe {
EVENTS[n_ev] = (EVENT_META_LEVEL, self.meta_level as f32);
n_ev += 1;
}
self.events[n_ev] = (EVENT_META_LEVEL, self.meta_level as f32);
n_ev += 1;
}
}
}
}
unsafe { &EVENTS[..n_ev] }
&self.events[..n_ev]
}
/// Compute the performance score from accumulated feedback.
@@ -1,10 +1,20 @@
//! Cardiac arrhythmia detection — ADR-041 Category 1 Medical module.
//! Cardiac-rhythm anomaly flagging — ADR-041 Category 1 Medical module.
//!
//! Monitors heart rate from host CSI pipeline and detects:
//! - Tachycardia: sustained HR > 100 BPM
//! - Bradycardia: sustained HR < 50 BPM
//! - Missed beats: sudden HR dips > 30% below running average
//! - HRV anomaly: RMSSD outside normal range over 30-second window
//! ⚠️ EXPERIMENTAL RESEARCH MODULE — NOT VALIDATED AGAINST CLINICAL DATA.
//! ⚠️ NOT A MEDICAL DEVICE. Do NOT use for diagnosis or patient monitoring.
//! ⚠️ This module flags *candidate* arrhythmia-like heart-rate signatures only
//! ⚠️ (sustained high/low rate estimates, abrupt drops, variability proxies);
//! ⚠️ it has never been compared against ECG or any reference standard, and its
//! ⚠️ accuracy is unproven (see ADR-160 §A1). Gated behind the non-default
//! ⚠️ `medical-experimental` cargo feature.
//!
//! Monitors a heart-rate estimate from the host CSI pipeline and flags:
//! - Tachycardia-like: sustained rate estimate > 100 BPM
//! - Bradycardia-like: sustained rate estimate < 50 BPM
//! - Missed-beat-like: sudden rate dips > 30% below running average
//! - HRV-like anomaly: RMSSD proxy outside a coarse band over 30 seconds
//!
//! These are experimental signal proxies, NOT clinical measurements.
//!
//! Events:
//! TACHYCARDIA (110) — sustained high heart rate
@@ -87,6 +97,8 @@ pub struct CardiacArrhythmiaDetector {
cd_hrv: u16,
/// Frame counter.
frame_count: u32,
/// Per-call event scratch buffer (owned; replaces former `static mut`).
events: [(i32, f32); 4],
}
impl CardiacArrhythmiaDetector {
@@ -106,6 +118,7 @@ impl CardiacArrhythmiaDetector {
cd_missed: 0,
cd_hrv: 0,
frame_count: 0,
events: [(0, 0.0); 4],
}
}
@@ -122,14 +135,13 @@ impl CardiacArrhythmiaDetector {
self.cd_missed = self.cd_missed.saturating_sub(1);
self.cd_hrv = self.cd_hrv.saturating_sub(1);
static mut EVENTS: [(i32, f32); 4] = [(0, 0.0); 4];
let mut n = 0usize;
// Ignore invalid / zero / NaN readings.
// NaN comparisons return false, so we must check explicitly to prevent
// NaN from contaminating the EMA and RMSSD calculations.
if !(hr_bpm >= 1.0) {
return unsafe { &EVENTS[..n] };
return &self.events[..n];
}
// ── EMA update ──────────────────────────────────────────────────
@@ -156,7 +168,7 @@ impl CardiacArrhythmiaDetector {
if hr_bpm > TACHY_THRESH {
self.tachy_count = self.tachy_count.saturating_add(1);
if self.tachy_count >= SUSTAINED_SECS && self.cd_tachy == 0 && n < 4 {
unsafe { EVENTS[n] = (EVENT_TACHYCARDIA, hr_bpm); }
self.events[n] = (EVENT_TACHYCARDIA, hr_bpm);
n += 1;
self.cd_tachy = COOLDOWN_SECS;
}
@@ -168,7 +180,7 @@ impl CardiacArrhythmiaDetector {
if hr_bpm < BRADY_THRESH {
self.brady_count = self.brady_count.saturating_add(1);
if self.brady_count >= SUSTAINED_SECS && self.cd_brady == 0 && n < 4 {
unsafe { EVENTS[n] = (EVENT_BRADYCARDIA, hr_bpm); }
self.events[n] = (EVENT_BRADYCARDIA, hr_bpm);
n += 1;
self.cd_brady = COOLDOWN_SECS;
}
@@ -180,7 +192,7 @@ impl CardiacArrhythmiaDetector {
if self.ema_init && self.hr_ema > 1.0 {
let drop_frac = (self.hr_ema - hr_bpm) / self.hr_ema;
if drop_frac > MISSED_BEAT_DROP && self.cd_missed == 0 && n < 4 {
unsafe { EVENTS[n] = (EVENT_MISSED_BEAT, hr_bpm); }
self.events[n] = (EVENT_MISSED_BEAT, hr_bpm);
n += 1;
self.cd_missed = COOLDOWN_SECS;
}
@@ -190,13 +202,13 @@ impl CardiacArrhythmiaDetector {
if self.rr_len >= HRV_WINDOW && n < 4 {
let rmssd = self.compute_rmssd();
if (rmssd < RMSSD_LOW || rmssd > RMSSD_HIGH) && self.cd_hrv == 0 {
unsafe { EVENTS[n] = (EVENT_HRV_ANOMALY, rmssd); }
self.events[n] = (EVENT_HRV_ANOMALY, rmssd);
n += 1;
self.cd_hrv = COOLDOWN_SECS;
}
}
unsafe { &EVENTS[..n] }
&self.events[..n]
}
/// Compute RMSSD from the RR-diff ring buffer.
@@ -1,7 +1,15 @@
//! Gait analysis — ADR-041 Category 1 Medical module.
//! Gait-parameter proxies & fall-risk-like scoring — ADR-041 Category 1 Medical module.
//!
//! Extracts gait parameters from CSI phase variance periodicity to assess
//! mobility and fall risk:
//! ⚠️ EXPERIMENTAL RESEARCH MODULE — NOT VALIDATED AGAINST CLINICAL DATA.
//! ⚠️ NOT A MEDICAL DEVICE. Do NOT use for diagnosis, fall-risk assessment, or
//! ⚠️ any clinical decision. This module computes *candidate* gait-parameter
//! ⚠️ proxies and a fall-risk-like score only; it has never been compared
//! ⚠️ against gait labs, clinical fall-risk instruments, or any reference
//! ⚠️ standard, and its accuracy is unproven (see ADR-160 §A1). Gated behind
//! ⚠️ the non-default `medical-experimental` cargo feature.
//!
//! Extracts candidate gait-parameter proxies from CSI phase-variance
//! periodicity (experimental, NOT clinical measurements):
//! - Step cadence (steps/min) from dominant phase variance frequency
//! - Gait asymmetry from left/right step interval ratio
//! - Stride variability (coefficient of variation)
@@ -109,6 +117,9 @@ pub struct GaitAnalyzer {
/// Frame counter.
frame_count: u32,
/// Per-call event scratch buffer (owned; replaces former `static mut`).
events: [(i32, f32); 5],
}
impl GaitAnalyzer {
@@ -132,6 +143,7 @@ impl GaitAnalyzer {
last_asymmetry: 0.0,
last_fall_risk: 0.0,
frame_count: 0,
events: [(0, 0.0); 5],
}
}
@@ -162,7 +174,6 @@ impl GaitAnalyzer {
self.var_idx = (self.var_idx + 1) % GAIT_WINDOW;
if self.var_len < GAIT_WINDOW { self.var_len += 1; }
static mut EVENTS: [(i32, f32); 5] = [(0, 0.0); 5];
let mut n = 0usize;
// ── Step detection (peak in variance) ───────────────────────────
@@ -201,13 +212,13 @@ impl GaitAnalyzer {
// Emit cadence.
if n < 5 {
unsafe { EVENTS[n] = (EVENT_STEP_CADENCE, cadence); }
self.events[n] = (EVENT_STEP_CADENCE, cadence);
n += 1;
}
// Emit asymmetry if above threshold.
if fabsf(asymmetry - 1.0) > ASYMMETRY_THRESH && n < 5 {
unsafe { EVENTS[n] = (EVENT_GAIT_ASYMMETRY, asymmetry); }
self.events[n] = (EVENT_GAIT_ASYMMETRY, asymmetry);
n += 1;
}
@@ -215,7 +226,7 @@ impl GaitAnalyzer {
if cadence > SHUFFLE_CADENCE_HIGH && avg_energy < SHUFFLE_ENERGY_LOW
&& self.cd_shuffle == 0 && n < 5
{
unsafe { EVENTS[n] = (EVENT_SHUFFLING_DETECTED, cadence); }
self.events[n] = (EVENT_SHUFFLING_DETECTED, cadence);
n += 1;
self.cd_shuffle = COOLDOWN_SECS;
}
@@ -223,7 +234,7 @@ impl GaitAnalyzer {
// Festination: accelerating cadence.
if self.cadence_len >= 3 && self.cd_festination == 0 && n < 5 {
if self.detect_festination() {
unsafe { EVENTS[n] = (EVENT_FESTINATION, cadence); }
self.events[n] = (EVENT_FESTINATION, cadence);
n += 1;
self.cd_festination = COOLDOWN_SECS;
}
@@ -233,7 +244,7 @@ impl GaitAnalyzer {
let risk = self.compute_fall_risk(cadence, asymmetry, variability, avg_energy);
self.last_fall_risk = risk;
if n < 5 {
unsafe { EVENTS[n] = (EVENT_FALL_RISK_SCORE, risk); }
self.events[n] = (EVENT_FALL_RISK_SCORE, risk);
n += 1;
}
@@ -241,7 +252,7 @@ impl GaitAnalyzer {
self.step_count = 0;
}
unsafe { &EVENTS[..n] }
&self.events[..n]
}
/// Compute cadence in steps/min from step intervals.
@@ -1,11 +1,20 @@
//! Respiratory distress detection — ADR-041 Category 1 Medical module.
//! Respiratory-distress-like pattern flagging — ADR-041 Category 1 Medical module.
//!
//! Detects pathological breathing patterns from host CSI pipeline:
//! - Tachypnea: sustained breathing rate > 25 BPM
//! - Labored breathing: high amplitude variance relative to baseline
//! - Cheyne-Stokes respiration: crescendo-decrescendo periodicity (30-90 s)
//! detected via autocorrelation of the breathing amplitude envelope
//! - Overall respiratory distress level: composite severity score 0-100
//! ⚠️ EXPERIMENTAL RESEARCH MODULE — NOT VALIDATED AGAINST CLINICAL DATA.
//! ⚠️ NOT A MEDICAL DEVICE. Do NOT use for diagnosis or patient monitoring.
//! ⚠️ This module flags *candidate* respiratory-distress-like breathing
//! ⚠️ signatures only; it has never been compared against capnography,
//! ⚠️ spirometry, or any reference standard, and its accuracy is unproven
//! ⚠️ (see ADR-160 §A1). Gated behind the non-default `medical-experimental`
//! ⚠️ cargo feature.
//!
//! Flags candidate pathological-breathing-like patterns from the host CSI
//! pipeline (experimental proxies, NOT clinical measurements):
//! - Tachypnea-like: sustained breathing-rate estimate > 25 BPM
//! - Labored-breathing-like: high amplitude variance relative to baseline
//! - Cheyne-Stokes-like: crescendo-decrescendo periodicity (30-90 s)
//! flagged via autocorrelation of the breathing-rate envelope
//! - Composite distress-level proxy: severity score 0-100
//!
//! Events:
//! TACHYPNEA (120) — sustained high respiratory rate
@@ -97,6 +106,9 @@ pub struct RespiratoryDistressDetector {
/// Frame counter.
frame_count: u32,
/// Per-call event scratch buffer (owned; replaces former `static mut`).
events: [(i32, f32); 4],
}
impl RespiratoryDistressDetector {
@@ -116,6 +128,7 @@ impl RespiratoryDistressDetector {
cd_cs: 0,
last_distress: 0.0,
frame_count: 0,
events: [(0, 0.0); 4],
}
}
@@ -163,14 +176,13 @@ impl RespiratoryDistressDetector {
self.var_mean += d / self.var_count as f32;
}
static mut EVENTS: [(i32, f32); 4] = [(0, 0.0); 4];
let mut n = 0usize;
// ── Tachypnea ───────────────────────────────────────────────────
if breathing_bpm > TACHYPNEA_THRESH {
self.tachy_count = self.tachy_count.saturating_add(1);
if self.tachy_count >= SUSTAINED_SECS && self.cd_tachy == 0 && n < 4 {
unsafe { EVENTS[n] = (EVENT_TACHYPNEA, breathing_bpm); }
self.events[n] = (EVENT_TACHYPNEA, breathing_bpm);
n += 1;
self.cd_tachy = COOLDOWN_SECS;
}
@@ -183,7 +195,7 @@ impl RespiratoryDistressDetector {
let current_var = self.recent_var_mean();
let ratio = current_var / self.var_mean;
if ratio > LABORED_VAR_RATIO && self.cd_labored == 0 && n < 4 {
unsafe { EVENTS[n] = (EVENT_LABORED_BREATHING, ratio); }
self.events[n] = (EVENT_LABORED_BREATHING, ratio);
n += 1;
self.cd_labored = COOLDOWN_SECS;
}
@@ -192,7 +204,7 @@ impl RespiratoryDistressDetector {
// ── Cheyne-Stokes (autocorrelation) ─────────────────────────────
if self.bpm_len >= AC_WINDOW && self.cd_cs == 0 && n < 4 {
if let Some(period) = self.detect_cheyne_stokes() {
unsafe { EVENTS[n] = (EVENT_CHEYNE_STOKES, period as f32); }
self.events[n] = (EVENT_CHEYNE_STOKES, period as f32);
n += 1;
self.cd_cs = COOLDOWN_SECS;
}
@@ -202,11 +214,11 @@ impl RespiratoryDistressDetector {
if self.frame_count % DISTRESS_REPORT_INTERVAL == 0 && n < 4 {
let score = self.compute_distress_score(breathing_bpm, variance);
self.last_distress = score;
unsafe { EVENTS[n] = (EVENT_RESP_DISTRESS_LEVEL, score); }
self.events[n] = (EVENT_RESP_DISTRESS_LEVEL, score);
n += 1;
}
unsafe { &EVENTS[..n] }
&self.events[..n]
}
/// Mean of recent variance samples.
@@ -1,7 +1,17 @@
//! Seizure detection — ADR-041 Category 1 Medical module.
//! Seizure-like motion-signature flagging — ADR-041 Category 1 Medical module.
//!
//! Detects tonic-clonic seizures via high-energy rhythmic motion in the
//! 3-8 Hz band, discriminating from:
//! ⚠️ EXPERIMENTAL RESEARCH MODULE — NOT VALIDATED AGAINST CLINICAL DATA.
//! ⚠️ NOT A MEDICAL DEVICE. Do NOT use for diagnosis, seizure monitoring, or any
//! ⚠️ clinical decision. This module flags *candidate* seizure-like motion
//! ⚠️ signatures (high-energy rhythmic 3-8 Hz motion) only; it has never been
//! ⚠️ validated against EEG/video-EEG or any reference standard, and its
//! ⚠️ accuracy is unproven (see ADR-160 §A1). Seizure detection cannot be
//! ⚠️ validated without clinical data — this module does not claim to do so.
//! ⚠️ Gated behind the non-default `medical-experimental` cargo feature.
//!
//! Flags candidate tonic-clonic-seizure-like motion signatures (experimental)
//! via high-energy rhythmic motion in the 3-8 Hz band, attempting to
//! discriminate from:
//! - Falls: single impulse followed by stillness
//! - Tremor: lower amplitude, higher regularity
//!
@@ -125,6 +135,9 @@ pub struct SeizureDetector {
/// Frame counter.
frame_count: u32,
/// Per-call event scratch buffer (owned; replaces former `static mut`).
events: [(i32, f32); 4],
}
impl SeizureDetector {
@@ -143,6 +156,7 @@ impl SeizureDetector {
cooldown: 0,
seizure_count: 0,
frame_count: 0,
events: [(0, 0.0); 4],
}
}
@@ -172,7 +186,6 @@ impl SeizureDetector {
self.amp_idx = (self.amp_idx + 1) % PHASE_WINDOW;
if self.amp_len < PHASE_WINDOW { self.amp_len += 1; }
static mut EVENTS: [(i32, f32); 4] = [(0, 0.0); 4];
let mut n = 0usize;
// No detection without presence.
@@ -182,7 +195,7 @@ impl SeizureDetector {
self.state_frames = 0;
self.high_energy_frames = 0;
}
return unsafe { &EVENTS[..n] };
return &self.events[..n];
}
// Tick cooldown.
@@ -192,7 +205,7 @@ impl SeizureDetector {
self.phase = SeizurePhase::Monitoring;
self.state_frames = 0;
}
return unsafe { &EVENTS[..n] };
return &self.events[..n];
}
// ── State machine ───────────────────────────────────────────────
@@ -222,7 +235,7 @@ impl SeizureDetector {
self.phase = SeizurePhase::Monitoring;
self.state_frames = 0;
self.high_energy_frames = 0;
return unsafe { &EVENTS[..n] };
return &self.events[..n];
}
}
@@ -232,7 +245,7 @@ impl SeizureDetector {
self.phase = SeizurePhase::Tonic;
self.state_frames = 0;
self.seizure_count += 1;
unsafe { EVENTS[n] = (EVENT_SEIZURE_ONSET, motion_energy); }
self.events[n] = (EVENT_SEIZURE_ONSET, motion_energy);
n += 1;
}
@@ -244,10 +257,10 @@ impl SeizureDetector {
self.phase = SeizurePhase::Clonic;
self.state_frames = 0;
self.seizure_count += 1;
unsafe { EVENTS[n] = (EVENT_SEIZURE_ONSET, motion_energy); }
self.events[n] = (EVENT_SEIZURE_ONSET, motion_energy);
n += 1;
if n < 4 {
unsafe { EVENTS[n] = (EVENT_SEIZURE_CLONIC, period as f32); }
self.events[n] = (EVENT_SEIZURE_CLONIC, period as f32);
n += 1;
}
}
@@ -271,13 +284,13 @@ impl SeizureDetector {
if energy_var > TONIC_VAR_CEIL {
if let Some(period) = self.detect_rhythm() {
if self.state_frames >= TONIC_MIN_FRAMES && n < 4 {
unsafe { EVENTS[n] = (EVENT_SEIZURE_TONIC, self.state_frames as f32); }
self.events[n] = (EVENT_SEIZURE_TONIC, self.state_frames as f32);
n += 1;
}
self.phase = SeizurePhase::Clonic;
self.state_frames = 0;
if n < 4 {
unsafe { EVENTS[n] = (EVENT_SEIZURE_CLONIC, period as f32); }
self.events[n] = (EVENT_SEIZURE_CLONIC, period as f32);
n += 1;
}
}
@@ -289,7 +302,7 @@ impl SeizureDetector {
self.low_energy_frames += 1;
if self.low_energy_frames >= POST_ICTAL_MIN_FRAMES {
if self.state_frames >= TONIC_MIN_FRAMES && n < 4 {
unsafe { EVENTS[n] = (EVENT_SEIZURE_TONIC, self.state_frames as f32); }
self.events[n] = (EVENT_SEIZURE_TONIC, self.state_frames as f32);
n += 1;
}
self.phase = SeizurePhase::PostIctal;
@@ -318,7 +331,7 @@ impl SeizureDetector {
SeizurePhase::PostIctal => {
self.state_frames += 1;
if self.state_frames == 1 && n < 4 {
unsafe { EVENTS[n] = (EVENT_POST_ICTAL, 1.0); }
self.events[n] = (EVENT_POST_ICTAL, 1.0);
n += 1;
}
@@ -337,7 +350,7 @@ impl SeizureDetector {
}
}
unsafe { &EVENTS[..n] }
&self.events[..n]
}
/// Compute variance of recent motion energy.
@@ -1,10 +1,19 @@
//! Sleep apnea detection — ADR-041 Category 1 Medical module.
//! Apnea-like breathing-pause flagging — ADR-041 Category 1 Medical module.
//!
//! Detects obstructive and central sleep apnea by monitoring breathing BPM
//! from the host CSI pipeline. When breathing drops below 4 BPM for more
//! than 10 seconds the detector flags an apnea event. It also tracks the
//! Apnea-Hypopnea Index (AHI) — the number of apnea events per hour of
//! monitored sleep time.
//! ⚠️ EXPERIMENTAL RESEARCH MODULE — NOT VALIDATED AGAINST CLINICAL DATA.
//! ⚠️ NOT A MEDICAL DEVICE. Do NOT use for diagnosis, monitoring of patients,
//! ⚠️ or any clinical decision. This module flags *candidate* apnea-like
//! ⚠️ breathing-pause signatures (sustained low breathing-rate estimates)
//! ⚠️ only; it has never been compared against polysomnography or any
//! ⚠️ reference standard, and its accuracy is unproven (see ADR-160 §A1).
//! ⚠️ Gated behind the non-default `medical-experimental` cargo feature so it
//! ⚠️ cannot be silently built into a shipping artifact.
//!
//! Monitors breathing-rate estimates from the host CSI pipeline. When the
//! estimate drops below 4 BPM for more than 10 seconds the detector flags a
//! candidate apnea-like event. It also tracks a candidate Apnea-Hypopnea
//! Index (AHI) proxy — the number of flagged events per hour of monitored
//! time. These are experimental proxies, NOT clinical measurements.
//!
//! Events:
//! APNEA_START (100) — breathing ceased or fell below threshold
@@ -77,6 +86,8 @@ pub struct SleepApneaDetector {
timer_count: u32,
/// Most recently computed AHI.
last_ahi: f32,
/// Per-call event scratch buffer (owned; replaces former `static mut`).
events: [(i32, f32); 4],
}
impl SleepApneaDetector {
@@ -90,6 +101,7 @@ impl SleepApneaDetector {
monitoring_secs: 0,
timer_count: 0,
last_ahi: 0.0,
events: [(0, 0.0); 4],
}
}
@@ -104,7 +116,6 @@ impl SleepApneaDetector {
) -> &[(i32, f32)] {
self.timer_count += 1;
static mut EVENTS: [(i32, f32); 4] = [(0, 0.0); 4];
let mut n = 0usize;
// Only monitor when subject is present.
@@ -115,11 +126,11 @@ impl SleepApneaDetector {
self.record_episode(self.current_start, dur);
self.in_apnea = false;
self.low_breath_secs = 0;
unsafe { EVENTS[n] = (EVENT_APNEA_END, dur as f32); }
self.events[n] = (EVENT_APNEA_END, dur as f32);
n += 1;
}
self.low_breath_secs = 0;
return unsafe { &EVENTS[..n] };
return &self.events[..n];
}
self.monitoring_secs += 1;
@@ -129,7 +140,7 @@ impl SleepApneaDetector {
// Treat NaN as invalid — skip detection for this frame.
if breathing_bpm != breathing_bpm {
// NaN: f32::NAN != f32::NAN is true.
return unsafe { &EVENTS[..n] };
return &self.events[..n];
}
// ── Apnea detection ─────────────────────────────────────────────
@@ -140,7 +151,7 @@ impl SleepApneaDetector {
// Apnea onset — backdate start to when breathing first dropped.
self.in_apnea = true;
self.current_start = self.timer_count.saturating_sub(self.low_breath_secs);
unsafe { EVENTS[n] = (EVENT_APNEA_START, breathing_bpm); }
self.events[n] = (EVENT_APNEA_START, breathing_bpm);
n += 1;
}
} else {
@@ -149,7 +160,7 @@ impl SleepApneaDetector {
let dur = self.timer_count.saturating_sub(self.current_start);
self.record_episode(self.current_start, dur);
self.in_apnea = false;
unsafe { EVENTS[n] = (EVENT_APNEA_END, dur as f32); }
self.events[n] = (EVENT_APNEA_END, dur as f32);
n += 1;
}
self.low_breath_secs = 0;
@@ -163,11 +174,11 @@ impl SleepApneaDetector {
} else {
0.0
};
unsafe { EVENTS[n] = (EVENT_AHI_UPDATE, self.last_ahi); }
self.events[n] = (EVENT_AHI_UPDATE, self.last_ahi);
n += 1;
}
unsafe { &EVENTS[..n] }
&self.events[..n]
}
fn record_episode(&mut self, start: u32, duration: u32) {
@@ -42,6 +42,8 @@ struct ZoneState {
/// Occupancy zone detector.
pub struct OccupancyDetector {
/// Per-call event scratch buffer (owned; replaces former `static mut`).
events: [(i32, f32); 12],
zones: [ZoneState; MAX_ZONES],
n_zones: usize,
/// Calibration accumulators.
@@ -61,6 +63,7 @@ impl OccupancyDetector {
prev_occupied: false,
};
Self {
events: [(0, 0.0); 12],
zones: [ZONE_INIT; MAX_ZONES],
n_zones: 0,
calib_sum: [0.0; MAX_ZONES],
@@ -163,7 +166,6 @@ impl OccupancyDetector {
// Build output events in a static buffer.
// We re-use a static to avoid allocation in no_std.
static mut EVENTS: [(i32, f32); 12] = [(0, 0.0); 12];
let mut n_events = 0usize;
// Emit per-zone occupancy (every 10 frames to limit bandwidth).
@@ -172,18 +174,14 @@ impl OccupancyDetector {
if self.zones[z].occupied && n_events < 10 {
// Encode zone_id in integer part, confidence in fractional.
let val = z as f32 + self.zones[z].score.min(0.99);
unsafe {
EVENTS[n_events] = (EVENT_ZONE_OCCUPIED, val);
}
self.events[n_events] = (EVENT_ZONE_OCCUPIED, val);
n_events += 1;
}
}
// Emit total occupied zone count.
if n_events < 11 {
unsafe {
EVENTS[n_events] = (EVENT_ZONE_COUNT, total_occupied as f32);
}
self.events[n_events] = (EVENT_ZONE_COUNT, total_occupied as f32);
n_events += 1;
}
}
@@ -192,14 +190,12 @@ impl OccupancyDetector {
for z in 0..zone_count {
if self.zones[z].occupied != self.zones[z].prev_occupied && n_events < 12 {
let val = z as f32 + if self.zones[z].occupied { 0.5 } else { 0.0 };
unsafe {
EVENTS[n_events] = (EVENT_ZONE_TRANSITION, val);
}
self.events[n_events] = (EVENT_ZONE_TRANSITION, val);
n_events += 1;
}
}
unsafe { &EVENTS[..n_events] }
&self.events[..n_events]
}
/// Get the number of currently occupied zones.
@@ -112,6 +112,8 @@ impl Hypothesis {
/// Grover-inspired room state search engine.
pub struct InterferenceSearch {
/// Per-call event scratch buffer (owned; replaces former `static mut`).
events: [(i32, f32); 3],
/// Amplitude for each of the 16 hypotheses.
amplitudes: [f32; N_HYPO],
/// Total Grover iterations applied.
@@ -130,6 +132,7 @@ impl InterferenceSearch {
pub const fn new() -> Self {
// 1/sqrt(16) = 0.25
Self {
events: [(0, 0.0); 3],
amplitudes: [0.25; N_HYPO],
iteration_count: 0,
converged: false,
@@ -178,37 +181,30 @@ impl InterferenceSearch {
self.converged = winner_prob > CONVERGENCE_PROB;
// ── Build output events ──
static mut EVENTS: [(i32, f32); 3] = [(0, 0.0); 3];
let mut n_events = 0usize;
// Emit winner periodically or on change.
let winner_changed = winner_idx as u8 != self.prev_winner;
if winner_changed || self.frame_count % WINNER_EMIT_INTERVAL == 0 {
unsafe {
EVENTS[n_events] = (EVENT_HYPOTHESIS_WINNER, winner_idx as f32);
}
self.events[n_events] = (EVENT_HYPOTHESIS_WINNER, winner_idx as f32);
n_events += 1;
}
// Emit amplitude periodically.
if self.frame_count % AMPLITUDE_EMIT_INTERVAL == 0 {
unsafe {
EVENTS[n_events] = (EVENT_HYPOTHESIS_AMPLITUDE, winner_prob);
}
self.events[n_events] = (EVENT_HYPOTHESIS_AMPLITUDE, winner_prob);
n_events += 1;
}
// Emit iteration count periodically.
if self.frame_count % ITERATION_EMIT_INTERVAL == 0 {
unsafe {
EVENTS[n_events] = (EVENT_SEARCH_ITERATIONS, self.iteration_count as f32);
}
self.events[n_events] = (EVENT_SEARCH_ITERATIONS, self.iteration_count as f32);
n_events += 1;
}
self.prev_winner = winner_idx as u8;
unsafe { &EVENTS[..n_events] }
&self.events[..n_events]
}
/// Apply the oracle: set boost/dampen factors based on CSI evidence.
@@ -58,6 +58,8 @@ pub const EVENT_BLOCH_DRIFT: i32 = 852;
/// Quantum-inspired coherence monitor using Bloch sphere representation.
pub struct QuantumCoherenceMonitor {
/// Per-call event scratch buffer (owned; replaces former `static mut`).
events: [(i32, f32); 3],
/// Previous aggregate Bloch vector [x, y, z].
prev_bloch: [f32; 3],
/// EMA-smoothed Von Neumann entropy.
@@ -74,6 +76,7 @@ impl QuantumCoherenceMonitor {
/// Create a new monitor. Const-evaluable for static initialization.
pub const fn new() -> Self {
Self {
events: [(0, 0.0); 3],
prev_bloch: [0.0, 0.0, 1.0],
smoothed_entropy: 0.0,
prev_entropy: 0.0,
@@ -129,34 +132,27 @@ impl QuantumCoherenceMonitor {
self.prev_bloch = bloch;
// ── Build output events ──
static mut EVENTS: [(i32, f32); 3] = [(0, 0.0); 3];
let mut n_events = 0usize;
// Entropy (periodic).
if self.frame_count % ENTROPY_EMIT_INTERVAL == 0 {
unsafe {
EVENTS[n_events] = (EVENT_ENTANGLEMENT_ENTROPY, self.smoothed_entropy);
}
self.events[n_events] = (EVENT_ENTANGLEMENT_ENTROPY, self.smoothed_entropy);
n_events += 1;
}
// Decoherence event (immediate).
if entropy_jump > DECOHERENCE_THRESHOLD {
unsafe {
EVENTS[n_events] = (EVENT_DECOHERENCE_EVENT, entropy_jump);
}
self.events[n_events] = (EVENT_DECOHERENCE_EVENT, entropy_jump);
n_events += 1;
}
// Bloch drift (periodic).
if self.frame_count % DRIFT_EMIT_INTERVAL == 0 {
unsafe {
EVENTS[n_events] = (EVENT_BLOCH_DRIFT, drift);
}
self.events[n_events] = (EVENT_BLOCH_DRIFT, drift);
n_events += 1;
}
unsafe { &EVENTS[..n_events] }
&self.events[..n_events]
}
/// Compute the mean Bloch vector from subcarrier phases.
@@ -72,6 +72,8 @@ const MAX_EVENTS: usize = 4;
/// Tracks directional foot traffic using phase gradient analysis.
pub struct CustomerFlowTracker {
/// Per-call event scratch buffer (owned; replaces former `static mut`).
events: [(i32, f32); MAX_EVENTS],
/// Previous phase values per subcarrier.
prev_phases: [f32; MAX_SC],
/// Previous amplitude values per subcarrier.
@@ -101,6 +103,7 @@ pub struct CustomerFlowTracker {
impl CustomerFlowTracker {
pub const fn new() -> Self {
Self {
events: [(0, 0.0); MAX_EVENTS],
prev_phases: [0.0; MAX_SC],
prev_amplitudes: [0.0; MAX_SC],
gradient_ema: Ema::new(GRADIENT_EMA_ALPHA),
@@ -200,7 +203,6 @@ impl CustomerFlowTracker {
}
// Build events.
static mut EVENTS: [(i32, f32); MAX_EVENTS] = [(0, 0.0); MAX_EVENTS];
let mut ne = 0usize;
// Crossing detection: look for gradient peak + motion + amplitude spike.
@@ -218,9 +220,7 @@ impl CustomerFlowTracker {
self.ingress_count += 1;
self.hourly_ingress += 1;
if ne < MAX_EVENTS {
unsafe {
EVENTS[ne] = (EVENT_INGRESS, self.ingress_count as f32);
}
self.events[ne] = (EVENT_INGRESS, self.ingress_count as f32);
ne += 1;
}
} else {
@@ -228,9 +228,7 @@ impl CustomerFlowTracker {
self.egress_count += 1;
self.hourly_egress += 1;
if ne < MAX_EVENTS {
unsafe {
EVENTS[ne] = (EVENT_EGRESS, self.egress_count as f32);
}
self.events[ne] = (EVENT_EGRESS, self.egress_count as f32);
ne += 1;
}
}
@@ -238,9 +236,7 @@ impl CustomerFlowTracker {
// Emit net occupancy on each crossing.
let net = self.net_occupancy();
if ne < MAX_EVENTS {
unsafe {
EVENTS[ne] = (EVENT_NET_OCCUPANCY, net as f32);
}
self.events[ne] = (EVENT_NET_OCCUPANCY, net as f32);
ne += 1;
}
}
@@ -248,9 +244,7 @@ impl CustomerFlowTracker {
// Periodic net occupancy report.
if self.frame_count % OCCUPANCY_REPORT_INTERVAL == 0 && ne < MAX_EVENTS {
let net = self.net_occupancy();
unsafe {
EVENTS[ne] = (EVENT_NET_OCCUPANCY, net as f32);
}
self.events[ne] = (EVENT_NET_OCCUPANCY, net as f32);
ne += 1;
}
@@ -259,16 +253,14 @@ impl CustomerFlowTracker {
// Encode: ingress * 1000 + egress.
let summary = self.hourly_ingress as f32 * 1000.0 + self.hourly_egress as f32;
if ne < MAX_EVENTS {
unsafe {
EVENTS[ne] = (EVENT_HOURLY_TRAFFIC, summary);
}
self.events[ne] = (EVENT_HOURLY_TRAFFIC, summary);
ne += 1;
}
self.hourly_ingress = 0;
self.hourly_egress = 0;
}
unsafe { &EVENTS[..ne] }
&self.events[..ne]
}
/// Get net occupancy (ingress - egress), clamped to 0.
@@ -80,6 +80,8 @@ const ZONE_INIT: ZoneState = ZoneState {
/// Tracks dwell time across a 3x3 spatial zone grid.
pub struct DwellHeatmapTracker {
/// Per-call event scratch buffer (owned; replaces former `static mut`).
events: [(i32, f32); MAX_EVENTS],
zones: [ZoneState; NUM_ZONES],
/// Frame counter.
frame_count: u32,
@@ -96,6 +98,7 @@ pub struct DwellHeatmapTracker {
impl DwellHeatmapTracker {
pub const fn new() -> Self {
Self {
events: [(0, 0.0); MAX_EVENTS],
zones: [ZONE_INIT; NUM_ZONES],
frame_count: 0,
any_present: false,
@@ -176,7 +179,6 @@ impl DwellHeatmapTracker {
self.any_present = is_present || any_zone_occupied;
// Build events.
static mut EVENTS: [(i32, f32); MAX_EVENTS] = [(0, 0.0); MAX_EVENTS];
let mut ne = 0usize;
// Periodic zone updates.
@@ -186,9 +188,7 @@ impl DwellHeatmapTracker {
if self.zones[z].dwell_seconds > 0.0 && ne < MAX_EVENTS - 3 {
// Encode zone_id in integer part, dwell seconds in value.
let val = z as f32 * 1000.0 + self.zones[z].dwell_seconds;
unsafe {
EVENTS[ne] = (EVENT_DWELL_ZONE_UPDATE, val);
}
self.events[ne] = (EVENT_DWELL_ZONE_UPDATE, val);
ne += 1;
}
}
@@ -211,16 +211,12 @@ impl DwellHeatmapTracker {
}
if hot_dwell > 0.0 && ne < MAX_EVENTS {
unsafe {
EVENTS[ne] = (EVENT_HOT_ZONE, hot_zone as f32 + hot_dwell / 1000.0);
}
self.events[ne] = (EVENT_HOT_ZONE, hot_zone as f32 + hot_dwell / 1000.0);
ne += 1;
}
if cold_dwell < f32::MAX && ne < MAX_EVENTS {
unsafe {
EVENTS[ne] = (EVENT_COLD_ZONE, cold_zone as f32 + cold_dwell / 1000.0);
}
self.events[ne] = (EVENT_COLD_ZONE, cold_zone as f32 + cold_dwell / 1000.0);
ne += 1;
}
}
@@ -230,14 +226,12 @@ impl DwellHeatmapTracker {
self.session_active = false;
let session_duration = (self.frame_count - self.session_start_frame) as f32 / FRAME_RATE;
if ne < MAX_EVENTS {
unsafe {
EVENTS[ne] = (EVENT_SESSION_SUMMARY, session_duration);
}
self.events[ne] = (EVENT_SESSION_SUMMARY, session_duration);
ne += 1;
}
}
unsafe { &EVENTS[..ne] }
&self.events[..ne]
}
/// Get dwell time (seconds) for a specific zone in the current session.
@@ -62,6 +62,8 @@ const RATE_HISTORY: usize = 1200;
/// Estimates queue length from CSI presence and person-count data.
pub struct QueueLengthEstimator {
/// Per-call event scratch buffer (owned; replaces former `static mut`).
events: [(i32, f32); 4],
/// Smoothed queue length estimate.
queue_ema: Ema,
/// Smoothed arrival rate (persons/minute).
@@ -91,6 +93,7 @@ pub struct QueueLengthEstimator {
impl QueueLengthEstimator {
pub const fn new() -> Self {
Self {
events: [(0, 0.0); 4],
queue_ema: Ema::new(QUEUE_EMA_ALPHA),
arrival_rate_ema: Ema::new(RATE_EMA_ALPHA),
service_rate_ema: Ema::new(RATE_EMA_ALPHA),
@@ -161,14 +164,11 @@ impl QueueLengthEstimator {
}
// Build events.
static mut EVENTS: [(i32, f32); 4] = [(0, 0.0); 4];
let mut ne = 0usize;
// Periodic queue length report.
if self.frame_count % REPORT_INTERVAL == 0 {
unsafe {
EVENTS[ne] = (EVENT_QUEUE_LENGTH, self.current_queue as f32);
}
self.events[ne] = (EVENT_QUEUE_LENGTH, self.current_queue as f32);
ne += 1;
}
@@ -184,9 +184,7 @@ impl QueueLengthEstimator {
// Service rate event.
if ne < 4 {
unsafe {
EVENTS[ne] = (EVENT_SERVICE_RATE, self.service_rate_ema.value);
}
self.events[ne] = (EVENT_SERVICE_RATE, self.service_rate_ema.value);
ne += 1;
}
@@ -199,9 +197,7 @@ impl QueueLengthEstimator {
};
if ne < 4 {
unsafe {
EVENTS[ne] = (EVENT_WAIT_TIME_ESTIMATE, wait_time);
}
self.events[ne] = (EVENT_WAIT_TIME_ESTIMATE, wait_time);
ne += 1;
}
}
@@ -216,16 +212,14 @@ impl QueueLengthEstimator {
if self.current_queue as f32 >= QUEUE_ALERT_THRESH && !self.alert_active {
self.alert_active = true;
if ne < 4 {
unsafe {
EVENTS[ne] = (EVENT_QUEUE_ALERT, self.current_queue as f32);
}
self.events[ne] = (EVENT_QUEUE_ALERT, self.current_queue as f32);
ne += 1;
}
} else if (self.current_queue as f32) < QUEUE_ALERT_THRESH - 1.0 {
self.alert_active = false;
}
unsafe { &EVENTS[..ne] }
&self.events[..ne]
}
/// Get the current smoothed queue length.
@@ -96,6 +96,8 @@ pub enum EngagementLevel {
/// Detects and classifies customer shelf engagement from CSI data.
pub struct ShelfEngagementDetector {
/// Per-call event scratch buffer (owned; replaces former `static mut`).
events: [(i32, f32); MAX_EVENTS],
/// Previous phase values for perturbation calculation.
prev_phases: [f32; MAX_SC],
/// Phase perturbation EMA (high-frequency component).
@@ -133,6 +135,7 @@ pub struct ShelfEngagementDetector {
impl ShelfEngagementDetector {
pub const fn new() -> Self {
Self {
events: [(0, 0.0); MAX_EVENTS],
prev_phases: [0.0; MAX_SC],
perturbation_ema: Ema::new(PERTURBATION_EMA_ALPHA),
motion_ema: Ema::new(MOTION_EMA_ALPHA),
@@ -221,7 +224,6 @@ impl ShelfEngagementDetector {
self.phase_diff_history.push(perturbation);
// Build events.
static mut EVENTS: [(i32, f32); MAX_EVENTS] = [(0, 0.0); MAX_EVENTS];
let mut ne = 0usize;
if !is_present {
@@ -234,7 +236,7 @@ impl ShelfEngagementDetector {
self.still_frames = 0;
self.level = EngagementLevel::None;
self.prev_emitted_level = EngagementLevel::None;
unsafe { return &EVENTS[..ne]; }
return &self.events[..ne];
}
// Detect stillness (low translational motion).
@@ -249,7 +251,7 @@ impl ShelfEngagementDetector {
self.engagement_frames = 0;
self.level = EngagementLevel::None;
self.prev_emitted_level = EngagementLevel::None;
unsafe { return &EVENTS[..ne]; }
return &self.events[..ne];
}
// Only start engagement counting after debounce.
@@ -284,9 +286,7 @@ impl ShelfEngagementDetector {
};
if event_id != 0 && ne < MAX_EVENTS {
unsafe {
EVENTS[ne] = (event_id, duration);
}
self.events[ne] = (event_id, duration);
ne += 1;
self.prev_emitted_level = self.level;
self.cooldown = ENGAGEMENT_COOLDOWN;
@@ -297,13 +297,11 @@ impl ShelfEngagementDetector {
// Reach detection: sudden high-frequency phase burst while still.
if self.still_frames > STILL_DEBOUNCE && perturbation > REACH_BURST_THRESH && ne < MAX_EVENTS {
self.total_reaches += 1;
unsafe {
EVENTS[ne] = (EVENT_REACH_DETECTED, perturbation);
}
self.events[ne] = (EVENT_REACH_DETECTED, perturbation);
ne += 1;
}
unsafe { &EVENTS[..ne] }
&self.events[..ne]
}
/// Emit engagement end event based on current level.
@@ -80,6 +80,8 @@ pub enum TableState {
/// Tracks table occupancy state transitions and turnover metrics.
pub struct TableTurnoverTracker {
/// Per-call event scratch buffer (owned; replaces former `static mut`).
events: [(i32, f32); MAX_EVENTS],
/// Current table state.
state: TableState,
/// Smoothed motion energy.
@@ -109,6 +111,7 @@ pub struct TableTurnoverTracker {
impl TableTurnoverTracker {
pub const fn new() -> Self {
Self {
events: [(0, 0.0); MAX_EVENTS],
state: TableState::Empty,
motion_ema: Ema::new(MOTION_EMA_ALPHA),
presence_frames: 0,
@@ -143,7 +146,6 @@ impl TableTurnoverTracker {
let smoothed_motion = self.motion_ema.update(motion_energy);
let n = if n_persons < 0 { 0 } else { n_persons };
static mut EVENTS: [(i32, f32); MAX_EVENTS] = [(0, 0.0); MAX_EVENTS];
let mut ne = 0usize;
match self.state {
@@ -158,9 +160,7 @@ impl TableTurnoverTracker {
self.absence_frames = 0;
if ne < MAX_EVENTS {
unsafe {
EVENTS[ne] = (EVENT_TABLE_SEATED, n as f32);
}
self.events[ne] = (EVENT_TABLE_SEATED, n as f32);
ne += 1;
}
}
@@ -202,9 +202,7 @@ impl TableTurnoverTracker {
let duration_s = self.session_frames as f32 / FRAME_RATE;
if ne < MAX_EVENTS {
unsafe {
EVENTS[ne] = (EVENT_TABLE_VACATED, duration_s);
}
self.events[ne] = (EVENT_TABLE_VACATED, duration_s);
ne += 1;
}
@@ -241,9 +239,7 @@ impl TableTurnoverTracker {
let duration_s = self.session_frames as f32 / FRAME_RATE;
if ne < MAX_EVENTS {
unsafe {
EVENTS[ne] = (EVENT_TABLE_VACATED, duration_s);
}
self.events[ne] = (EVENT_TABLE_VACATED, duration_s);
ne += 1;
}
@@ -270,9 +266,7 @@ impl TableTurnoverTracker {
self.peak_persons = 0;
if ne < MAX_EVENTS {
unsafe {
EVENTS[ne] = (EVENT_TABLE_AVAILABLE, 1.0);
}
self.events[ne] = (EVENT_TABLE_AVAILABLE, 1.0);
ne += 1;
}
} else if is_present {
@@ -285,9 +279,7 @@ impl TableTurnoverTracker {
self.presence_frames = 0;
if ne < MAX_EVENTS {
unsafe {
EVENTS[ne] = (EVENT_TABLE_SEATED, n as f32);
}
self.events[ne] = (EVENT_TABLE_SEATED, n as f32);
ne += 1;
}
}
@@ -301,14 +293,12 @@ impl TableTurnoverTracker {
if self.frame_count % TURNOVER_REPORT_INTERVAL == 0 && self.frame_count > 0 {
let rate = self.turnover_rate();
if ne < MAX_EVENTS {
unsafe {
EVENTS[ne] = (EVENT_TURNOVER_RATE, rate);
}
self.events[ne] = (EVENT_TURNOVER_RATE, rate);
ne += 1;
}
}
unsafe { &EVENTS[..ne] }
&self.events[..ne]
}
/// Compute turnovers per hour (rolling window).
@@ -46,6 +46,8 @@ pub enum LoiterState {
/// Loitering detector.
pub struct LoiteringDetector {
/// Per-call event scratch buffer (owned; replaces former `static mut`).
events: [(i32, f32); 2],
state: LoiterState,
/// Consecutive frames with presence detected.
presence_frames: u32,
@@ -65,6 +67,7 @@ pub struct LoiteringDetector {
impl LoiteringDetector {
pub const fn new() -> Self {
Self {
events: [(0, 0.0); 2],
state: LoiterState::Absent,
presence_frames: 0,
dwell_frames: 0,
@@ -88,7 +91,6 @@ impl LoiteringDetector {
self.frame_count += 1;
self.post_end_cd = self.post_end_cd.saturating_sub(1);
static mut EVENTS: [(i32, f32); 2] = [(0, 0.0); 2];
let mut ne = 0usize;
// Determine if someone is present and roughly stationary.
@@ -133,9 +135,7 @@ impl LoiteringDetector {
if ne < 2 {
let dwell_seconds = self.dwell_frames as f32 / 20.0;
unsafe {
EVENTS[ne] = (EVENT_LOITERING_START, dwell_seconds);
}
self.events[ne] = (EVENT_LOITERING_START, dwell_seconds);
ne += 1;
}
}
@@ -161,9 +161,7 @@ impl LoiteringDetector {
self.ongoing_timer = 0;
if ne < 2 {
let total_seconds = self.dwell_frames as f32 / 20.0;
unsafe {
EVENTS[ne] = (EVENT_LOITERING_ONGOING, total_seconds);
}
self.events[ne] = (EVENT_LOITERING_ONGOING, total_seconds);
ne += 1;
}
}
@@ -177,9 +175,7 @@ impl LoiteringDetector {
if ne < 2 {
let total_seconds = self.dwell_frames as f32 / 20.0;
unsafe {
EVENTS[ne] = (EVENT_LOITERING_END, total_seconds);
}
self.events[ne] = (EVENT_LOITERING_END, total_seconds);
ne += 1;
}
@@ -191,7 +187,7 @@ impl LoiteringDetector {
}
}
unsafe { &EVENTS[..ne] }
&self.events[..ne]
}
pub fn state(&self) -> LoiterState { self.state }
@@ -54,6 +54,8 @@ pub const EVENT_FLEEING_DETECTED: i32 = 252;
/// Panic/erratic motion detector.
pub struct PanicMotionDetector {
/// Per-call event scratch buffer (owned; replaces former `static mut`).
events: [(i32, f32); 3],
/// Circular buffer of motion energy values.
energy_buf: [f32; WINDOW],
/// Circular buffer of phase variance values (for direction estimation).
@@ -75,6 +77,7 @@ pub struct PanicMotionDetector {
impl PanicMotionDetector {
pub const fn new() -> Self {
Self {
events: [(0, 0.0); 3],
energy_buf: [0.0; WINDOW],
variance_buf: [0.0; WINDOW],
buf_idx: 0,
@@ -102,7 +105,6 @@ impl PanicMotionDetector {
self.cd_struggle = self.cd_struggle.saturating_sub(1);
self.cd_fleeing = self.cd_fleeing.saturating_sub(1);
static mut EVENTS: [(i32, f32); 3] = [(0, 0.0); 3];
let mut ne = 0usize;
// Store in circular buffer.
@@ -117,13 +119,13 @@ impl PanicMotionDetector {
if !self.buf_filled {
self.prev_energy = motion_energy;
self.prev_energy_init = true;
return unsafe { &EVENTS[..0] };
return &self.events[..0];
}
// Require presence.
if presence < MIN_PRESENCE {
self.prev_energy = motion_energy;
return unsafe { &EVENTS[..0] };
return &self.events[..0];
}
// Compute jerk (absolute rate of change of motion energy).
@@ -142,7 +144,7 @@ impl PanicMotionDetector {
// Skip if not enough motion.
if mean_energy < MIN_MOTION {
return unsafe { &EVENTS[..0] };
return &self.events[..0];
}
// Panic detection: high jerk AND high entropy over threshold fraction of window.
@@ -152,7 +154,7 @@ impl PanicMotionDetector {
if is_panic && self.cd_panic == 0 && ne < 3 {
let severity = (mean_jerk / JERK_THRESH) * (entropy / ENTROPY_THRESH);
unsafe { EVENTS[ne] = (EVENT_PANIC_DETECTED, severity.min(10.0)); }
self.events[ne] = (EVENT_PANIC_DETECTED, severity.min(10.0));
ne += 1;
self.cd_panic = COOLDOWN;
self.panic_count += 1;
@@ -167,7 +169,7 @@ impl PanicMotionDetector {
&& entropy > ENTROPY_THRESH * 0.5;
if is_struggle && !is_panic && self.cd_struggle == 0 && ne < 3 {
unsafe { EVENTS[ne] = (EVENT_STRUGGLE_PATTERN, mean_jerk); }
self.events[ne] = (EVENT_STRUGGLE_PATTERN, mean_jerk);
ne += 1;
self.cd_struggle = COOLDOWN;
}
@@ -179,12 +181,12 @@ impl PanicMotionDetector {
&& entropy < FLEE_MAX_ENTROPY;
if is_fleeing && !is_panic && self.cd_fleeing == 0 && ne < 3 {
unsafe { EVENTS[ne] = (EVENT_FLEEING_DETECTED, mean_energy); }
self.events[ne] = (EVENT_FLEEING_DETECTED, mean_energy);
ne += 1;
self.cd_fleeing = COOLDOWN;
}
unsafe { &EVENTS[..ne] }
&self.events[..ne]
}
/// Compute window-level statistics.

Some files were not shown because too many files have changed in this diff Show More