Compare commits

...

18 Commits

Author SHA1 Message Date
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
108 changed files with 3391 additions and 908 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,260 @@
# 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).**
## 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. **ACCEPTED-FUTURE.**
- **Plugin signature/hash verification (P4)** — implement the
`wasm_module_hash`/`wasm_module_sig`/`publisher_key` gate that B5 now honestly
says is absent. **ACCEPTED-FUTURE.**
- **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). **ACCEPTED-FUTURE** — the
`engine.rs` doc states exactly this, no over-claim.
- **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`).
+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
+11 -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",
@@ -10933,7 +10935,7 @@ dependencies = [
[[package]]
name = "wifi-densepose-hardware"
version = "0.3.0"
version = "0.3.1"
dependencies = [
"approx",
"byteorder",
@@ -10953,7 +10955,7 @@ dependencies = [
[[package]]
name = "wifi-densepose-mat"
version = "0.3.0"
version = "0.3.1"
dependencies = [
"anyhow",
"approx",
@@ -10985,7 +10987,7 @@ dependencies = [
[[package]]
name = "wifi-densepose-nn"
version = "0.3.0"
version = "0.3.1"
dependencies = [
"anyhow",
"candle-core 0.4.1",
@@ -11039,7 +11041,7 @@ dependencies = [
[[package]]
name = "wifi-densepose-ruvector"
version = "0.3.1"
version = "0.3.2"
dependencies = [
"approx",
"criterion",
@@ -11059,7 +11061,7 @@ dependencies = [
[[package]]
name = "wifi-densepose-sensing-server"
version = "0.3.1"
version = "0.3.2"
dependencies = [
"axum",
"chrono",
@@ -11093,7 +11095,7 @@ dependencies = [
[[package]]
name = "wifi-densepose-signal"
version = "0.3.2"
version = "0.3.3"
dependencies = [
"chrono",
"criterion",
@@ -11120,7 +11122,7 @@ dependencies = [
[[package]]
name = "wifi-densepose-train"
version = "0.3.1"
version = "0.3.2"
dependencies = [
"anyhow",
"approx",
@@ -11158,7 +11160,7 @@ dependencies = [
[[package]]
name = "wifi-densepose-vitals"
version = "0.3.0"
version = "0.3.1"
dependencies = [
"criterion",
"serde",
@@ -11190,7 +11192,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");
}
}
+250 -40
View File
@@ -2,56 +2,129 @@
//! 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, HC-WS-05)
//!
//! `RunMode::Single` is enforced via a per-automation `AtomicBool`
//! guard: while an instance is executing, a second trigger is skipped.
//! `Parallel` (and the as-yet-unbounded `Restart`/`Queued`) spawn a
//! fresh instance on every trigger. (Before this fix the doc claimed
//! AtomicBool enforcement but every trigger spawned unbounded parallel
//! tasks regardless of `mode`.)
//!
//! ## 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::atomic::{AtomicBool, Ordering};
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::automation::{Automation, RunMode};
use crate::condition::EvalContext;
use crate::trigger::TriggerContext;
use crate::template::TemplateEnvironment;
use crate::trigger::{Trigger, TriggerContext};
/// An automation registered with the engine, plus its runtime run-state.
struct Registered {
auto: Arc<Automation>,
/// `true` while a `Single`-mode instance is executing. Used to
/// skip re-entrant triggers (HC-WS-05).
running: Arc<AtomicBool>,
}
/// 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));
self.automations.lock().unwrap().push(Registered {
auto: Arc::new(automation),
running: Arc::new(AtomicBool::new(false)),
});
}
/// 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>, Arc<AtomicBool>)> = automations
.lock()
.unwrap()
.iter()
.map(|r| (Arc::clone(&r.auto), Arc::clone(&r.running)))
.collect();
for (automation, running) in snapshot {
if !automation.enabled {
continue;
}
@@ -60,7 +133,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 +140,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;
}
}
});
spawn_run(&hc, automation, running);
}
}
Err(broadcast::error::RecvError::Closed) => break,
@@ -108,6 +159,156 @@ 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>, Arc<AtomicBool>)> = automations
.lock()
.unwrap()
.iter()
.map(|r| (Arc::clone(&r.auto), Arc::clone(&r.running)))
.collect();
let mut fired_any = false;
for (automation, running) 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;
}
spawn_run(&hc, automation, running);
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>, Arc<AtomicBool>)> = self
.automations
.lock()
.unwrap()
.iter()
.map(|r| (Arc::clone(&r.auto), Arc::clone(&r.running)))
.collect();
let mut fired = 0usize;
for (automation, running) 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;
}
spawn_run(&self.hc, automation, running);
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
}
/// Spawn an automation run, honoring `RunMode::Single` re-entrancy
/// guard (HC-WS-05). For `Single`/`IgnoreFirst` modes a run already in
/// flight causes the new trigger to be skipped; the `running` flag is
/// cleared when the run finishes.
fn spawn_run(hc: &HomeCore, automation: Arc<Automation>, running: Arc<AtomicBool>) {
let single = matches!(automation.mode, RunMode::Single | RunMode::IgnoreFirst);
if single {
// Try to claim the running slot; if already running, skip.
if running
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
.is_err()
{
return;
}
}
let hc_clone = hc.clone();
tokio::spawn(async move {
let mut exec_ctx = ExecutionContext::new(hc_clone, automation.id.clone());
for action in &automation.action {
if let Err(e) = action.execute(&mut exec_ctx).await {
eprintln!("[homecore-automation] action error in {}: {e}", automation.id);
break;
}
}
if single {
running.store(false, Ordering::SeqCst);
}
});
}
#[cfg(test)]
@@ -166,7 +367,6 @@ mod tests {
let _handle = engine.start();
// Fire a matching state change
hc.states().set(
EntityId::parse("switch.living").unwrap(),
"on",
@@ -174,7 +374,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 +402,6 @@ mod tests {
let _handle = engine.start();
// Fire on a DIFFERENT entity
hc.states().set(
EntityId::parse("switch.bedroom").unwrap(),
"on",
@@ -249,4 +447,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"));
}
}
+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,259 @@
//! 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");
}
+12 -1
View File
@@ -83,15 +83,26 @@ 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 — not yet enforced, ADR-161/B5):** this field is parsed and
/// round-tripped but is NOT verified before execution. The hash/sig
/// gate lands in P4; until then the presence of this field implies no
/// integrity guarantee.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub wasm_module_hash: Option<String>,
/// [HOMECORE] Ed25519 signature of the wasm binary hash (`ed25519:<base64>`).
///
/// **(P4 — not yet enforced, ADR-161/B5):** parsed but never checked.
/// No signature verification happens before a plugin runs.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub wasm_module_sig: Option<String>,
/// [HOMECORE] Ed25519 public key of the plugin publisher.
///
/// **(P4 — not yet enforced, ADR-161/B5):** parsed but never used to
/// verify `wasm_module_sig`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub publisher_key: Option<String>,
+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.
@@ -92,6 +92,8 @@ impl ZoneState {
/// Multi-zone perimeter breach detector.
pub struct PerimeterBreachDetector {
/// Per-call event scratch buffer (owned; replaces former `static mut`).
events: [(i32, f32); 4],
zones: [ZoneState; MAX_ZONES],
/// Calibration accumulators per zone: sum of gradient magnitudes.
cal_grad_sum: [f32; MAX_ZONES],
@@ -118,6 +120,7 @@ pub struct PerimeterBreachDetector {
impl PerimeterBreachDetector {
pub const fn new() -> Self {
Self {
events: [(0, 0.0); 4],
zones: [ZoneState::new(); MAX_ZONES],
cal_grad_sum: [0.0; MAX_ZONES],
cal_var_sum: [0.0; MAX_ZONES],
@@ -155,7 +158,6 @@ impl PerimeterBreachDetector {
self.cd_departure = self.cd_departure.saturating_sub(1);
self.cd_transition = self.cd_transition.saturating_sub(1);
static mut EVENTS: [(i32, f32); 4] = [(0, 0.0); 4];
let mut ne = 0usize;
let subs_per_zone = n_sc / MAX_ZONES;
@@ -196,7 +198,7 @@ impl PerimeterBreachDetector {
}
if !self.phase_init {
self.phase_init = true;
return unsafe { &EVENTS[..0] };
return &self.events[..0];
}
// Calibration phase.
@@ -214,7 +216,7 @@ impl PerimeterBreachDetector {
}
self.calibrated = true;
}
return unsafe { &EVENTS[..0] };
return &self.events[..0];
}
// Detect breaches and direction per zone.
@@ -262,7 +264,7 @@ impl PerimeterBreachDetector {
if self.approach_run[z] >= DIRECTION_DEBOUNCE && is_breach
&& self.cd_approach == 0 && ne < 4
{
unsafe { EVENTS[ne] = (EVENT_APPROACH_DETECTED, z as f32); }
self.events[ne] = (EVENT_APPROACH_DETECTED, z as f32);
ne += 1;
self.cd_approach = COOLDOWN;
self.approach_run[z] = 0;
@@ -272,7 +274,7 @@ impl PerimeterBreachDetector {
if self.departure_run[z] >= DIRECTION_DEBOUNCE
&& self.cd_departure == 0 && ne < 4
{
unsafe { EVENTS[ne] = (EVENT_DEPARTURE_DETECTED, z as f32); }
self.events[ne] = (EVENT_DEPARTURE_DETECTED, z as f32);
ne += 1;
self.cd_departure = COOLDOWN;
self.departure_run[z] = 0;
@@ -281,7 +283,7 @@ impl PerimeterBreachDetector {
// Perimeter breach event.
if most_disturbed_zone >= 0 && self.cd_breach == 0 && ne < 4 {
unsafe { EVENTS[ne] = (EVENT_PERIMETER_BREACH, max_energy); }
self.events[ne] = (EVENT_PERIMETER_BREACH, max_energy);
ne += 1;
self.cd_breach = COOLDOWN;
}
@@ -296,7 +298,7 @@ impl PerimeterBreachDetector {
// Encode as from*10 + to.
let transition_code = self.last_active_zone as f32 * 10.0
+ most_disturbed_zone as f32;
unsafe { EVENTS[ne] = (EVENT_ZONE_TRANSITION, transition_code); }
self.events[ne] = (EVENT_ZONE_TRANSITION, transition_code);
ne += 1;
self.cd_transition = COOLDOWN;
}
@@ -305,7 +307,7 @@ impl PerimeterBreachDetector {
self.last_active_zone = most_disturbed_zone;
}
unsafe { &EVENTS[..ne] }
&self.events[..ne]
}
pub fn is_calibrated(&self) -> bool { self.calibrated }
@@ -50,6 +50,8 @@ enum PeakState {
/// Tailgating detector.
pub struct TailgateDetector {
/// Per-call event scratch buffer (owned; replaces former `static mut`).
events: [(i32, f32); 3],
state: PeakState,
/// Current peak's maximum energy.
peak_max: f32,
@@ -80,6 +82,7 @@ pub struct TailgateDetector {
impl TailgateDetector {
pub const fn new() -> Self {
Self {
events: [(0, 0.0); 3],
state: PeakState::Idle,
peak_max: 0.0,
peak_frames: 0,
@@ -110,7 +113,6 @@ impl TailgateDetector {
self.cd_tailgate = self.cd_tailgate.saturating_sub(1);
self.cd_passage = self.cd_passage.saturating_sub(1);
static mut EVENTS: [(i32, f32); 3] = [(0, 0.0); 3];
let mut ne = 0usize;
// Update noise floor estimate (exponential moving average of variance).
@@ -168,7 +170,7 @@ impl TailgateDetector {
self.state = PeakState::InPeak;
self.peak_max = motion_energy;
self.peak_frames = 1;
return unsafe { &EVENTS[..0] };
return &self.events[..0];
}
// Window expired — evaluate passage.
@@ -176,9 +178,7 @@ impl TailgateDetector {
if self.peaks_in_window >= 2 {
// Multiple peaks detected = tailgating.
if self.cd_tailgate == 0 && ne < 3 {
unsafe {
EVENTS[ne] = (EVENT_TAILGATE_DETECTED, self.peaks_in_window as f32);
}
self.events[ne] = (EVENT_TAILGATE_DETECTED, self.peaks_in_window as f32);
ne += 1;
self.cd_tailgate = COOLDOWN;
self.tailgate_count += 1;
@@ -186,18 +186,14 @@ impl TailgateDetector {
// Also emit multi-passage.
if self.cd_passage == 0 && ne < 3 {
unsafe {
EVENTS[ne] = (EVENT_MULTI_PASSAGE, self.peaks_in_window as f32);
}
self.events[ne] = (EVENT_MULTI_PASSAGE, self.peaks_in_window as f32);
ne += 1;
self.cd_passage = COOLDOWN;
}
} else if self.peaks_in_window == 1 {
// Single passage.
if self.cd_passage == 0 && ne < 3 {
unsafe {
EVENTS[ne] = (EVENT_SINGLE_PASSAGE, self.peak_energies[0]);
}
self.events[ne] = (EVENT_SINGLE_PASSAGE, self.peak_energies[0]);
ne += 1;
self.cd_passage = COOLDOWN;
self.single_passages += 1;
@@ -212,7 +208,7 @@ impl TailgateDetector {
}
self.prev_energy = motion_energy;
unsafe { &EVENTS[..ne] }
&self.events[..ne]
}
pub fn frame_count(&self) -> u32 { self.frame_count }
@@ -11,7 +11,14 @@
//! variance ratio compared to a person without metal, because metal strongly
//! reflects RF energy while producing less phase dispersion than diffuse tissue.
//!
//! Events: METAL_ANOMALY(220), WEAPON_ALERT(221), CALIBRATION_NEEDED(222).
//! ⚠️ HONEST-NAMING NOTE (ADR-160 §A3): this module measures RF **reflectivity**
//! ⚠️ (an amplitude-variance / phase-variance ratio), not weapons. A variance
//! ⚠️ ratio cannot discriminate a weapon from any other highly-reflective metal
//! ⚠️ object (keys, laptop, belt buckle). The high-ratio event is therefore named
//! ⚠️ `HIGH_METAL_REFLECTIVITY`, NOT a weapon alert — the physical quantity the
//! ⚠️ code can actually back.
//!
//! Events: METAL_ANOMALY(220), HIGH_METAL_REFLECTIVITY(221), CALIBRATION_NEEDED(222).
//! Budget: S (<5 ms).
#[cfg(not(feature = "std"))]
@@ -26,16 +33,17 @@ const MAX_SC: usize = 32;
const BASELINE_FRAMES: u32 = 100;
/// Amplitude variance / phase variance ratio threshold for metal detection.
const METAL_RATIO_THRESH: f32 = 4.0;
/// Elevated ratio for weapon-grade alert (very high reflectivity).
const WEAPON_RATIO_THRESH: f32 = 8.0;
/// Elevated reflectivity-ratio threshold (very high RF reflectivity).
/// NOTE (ADR-160 §A3): a variance ratio measures reflectivity, not weapons.
const HIGH_REFLECTIVITY_THRESH: f32 = 8.0;
/// Minimum motion energy to consider detection valid (ignore static scenes).
const MIN_MOTION_ENERGY: f32 = 0.5;
/// Minimum presence required (person must be present).
const MIN_PRESENCE: i32 = 1;
/// Consecutive frames for metal anomaly debounce.
const METAL_DEBOUNCE: u8 = 4;
/// Consecutive frames for weapon alert debounce.
const WEAPON_DEBOUNCE: u8 = 6;
/// Consecutive frames for high-reflectivity debounce.
const HIGH_REFLECTIVITY_DEBOUNCE: u8 = 6;
/// Cooldown frames after event emission.
const COOLDOWN: u16 = 60;
/// Re-calibration trigger: if baseline drift exceeds this ratio.
@@ -44,7 +52,9 @@ const RECALIB_DRIFT_THRESH: f32 = 3.0;
const VAR_WINDOW: usize = 16;
pub const EVENT_METAL_ANOMALY: i32 = 220;
pub const EVENT_WEAPON_ALERT: i32 = 221;
/// High RF reflectivity (formerly mislabelled `EVENT_WEAPON_ALERT`, ADR-160 §A3).
/// A variance ratio measures reflectivity, not weapon-grade discrimination.
pub const EVENT_HIGH_METAL_REFLECTIVITY: i32 = 221;
pub const EVENT_CALIBRATION_NEEDED: i32 = 222;
/// Concealed metallic object detector.
@@ -74,12 +84,14 @@ pub struct WeaponDetector {
run_count: u32,
/// Debounce counters.
metal_run: u8,
weapon_run: u8,
high_refl_run: u8,
/// Cooldowns.
cd_metal: u16,
cd_weapon: u16,
cd_high_refl: u16,
cd_recalib: u16,
frame_count: u32,
/// Per-call event scratch buffer (owned; replaces former `static mut`).
events: [(i32, f32); 3],
}
impl WeaponDetector {
@@ -101,11 +113,12 @@ impl WeaponDetector {
run_phase_m2: [0.0; MAX_SC],
run_count: 0,
metal_run: 0,
weapon_run: 0,
high_refl_run: 0,
cd_metal: 0,
cd_weapon: 0,
cd_high_refl: 0,
cd_recalib: 0,
frame_count: 0,
events: [(0, 0.0); 3],
}
}
@@ -125,10 +138,9 @@ impl WeaponDetector {
self.frame_count += 1;
self.cd_metal = self.cd_metal.saturating_sub(1);
self.cd_weapon = self.cd_weapon.saturating_sub(1);
self.cd_high_refl = self.cd_high_refl.saturating_sub(1);
self.cd_recalib = self.cd_recalib.saturating_sub(1);
static mut EVENTS: [(i32, f32); 3] = [(0, 0.0); 3];
let mut ne = 0usize;
// Calibration phase: collect baseline statistics in empty room.
@@ -153,7 +165,7 @@ impl WeaponDetector {
}
self.calibrated = true;
}
return unsafe { &EVENTS[..0] };
return &self.events[..0];
}
// Update running Welford statistics.
@@ -176,7 +188,7 @@ impl WeaponDetector {
// Only detect when someone is present and moving.
if presence < MIN_PRESENCE || motion_energy < MIN_MOTION_ENERGY {
self.metal_run = 0;
self.weapon_run = 0;
self.high_refl_run = 0;
// Reset running stats periodically when no one is present.
if self.run_count > 200 {
self.run_count = 0;
@@ -187,12 +199,12 @@ impl WeaponDetector {
self.run_phase_m2[i] = 0.0;
}
}
return unsafe { &EVENTS[..0] };
return &self.events[..0];
}
// Compute current amplitude variance / phase variance ratio.
if self.run_count < 4 {
return unsafe { &EVENTS[..0] };
return &self.events[..0];
}
let mut ratio_sum = 0.0f32;
@@ -221,14 +233,14 @@ impl WeaponDetector {
}
if valid_sc < 2 {
return unsafe { &EVENTS[..0] };
return &self.events[..0];
}
let mean_ratio = ratio_sum / valid_sc as f32;
// Check for re-calibration need.
if max_drift > RECALIB_DRIFT_THRESH && self.cd_recalib == 0 && ne < 3 {
unsafe { EVENTS[ne] = (EVENT_CALIBRATION_NEEDED, max_drift); }
self.events[ne] = (EVENT_CALIBRATION_NEEDED, max_drift);
ne += 1;
self.cd_recalib = COOLDOWN * 5; // Less frequent recalibration alerts.
}
@@ -240,28 +252,28 @@ impl WeaponDetector {
self.metal_run = self.metal_run.saturating_sub(1);
}
// Weapon-grade detection (higher threshold).
if mean_ratio > WEAPON_RATIO_THRESH {
self.weapon_run = self.weapon_run.saturating_add(1);
// High-reflectivity detection (higher threshold). NOT weapon discrimination.
if mean_ratio > HIGH_REFLECTIVITY_THRESH {
self.high_refl_run = self.high_refl_run.saturating_add(1);
} else {
self.weapon_run = self.weapon_run.saturating_sub(1);
self.high_refl_run = self.high_refl_run.saturating_sub(1);
}
// Emit metal anomaly.
if self.metal_run >= METAL_DEBOUNCE && self.cd_metal == 0 && ne < 3 {
unsafe { EVENTS[ne] = (EVENT_METAL_ANOMALY, mean_ratio); }
self.events[ne] = (EVENT_METAL_ANOMALY, mean_ratio);
ne += 1;
self.cd_metal = COOLDOWN;
}
// Emit weapon alert (supersedes metal anomaly in severity).
if self.weapon_run >= WEAPON_DEBOUNCE && self.cd_weapon == 0 && ne < 3 {
unsafe { EVENTS[ne] = (EVENT_WEAPON_ALERT, mean_ratio); }
// Emit high-reflectivity event (supersedes metal anomaly in severity).
if self.high_refl_run >= HIGH_REFLECTIVITY_DEBOUNCE && self.cd_high_refl == 0 && ne < 3 {
self.events[ne] = (EVENT_HIGH_METAL_REFLECTIVITY, mean_ratio);
ne += 1;
self.cd_weapon = COOLDOWN;
self.cd_high_refl = COOLDOWN;
}
unsafe { &EVENTS[..ne] }
&self.events[..ne]
}
pub fn is_calibrated(&self) -> bool { self.calibrated }
@@ -311,7 +323,7 @@ mod tests {
let ev = det.process_frame(&p, &[20.0; 16], &[0.01; 16], 0.0, 0);
for &(et, _) in ev {
assert_ne!(et, EVENT_METAL_ANOMALY);
assert_ne!(et, EVENT_WEAPON_ALERT);
assert_ne!(et, EVENT_HIGH_METAL_REFLECTIVITY);
}
}
}
@@ -369,7 +381,7 @@ mod tests {
}
let ev = det.process_frame(&p, &a, &[0.01; 16], 1.0, 1);
for &(et, _) in ev {
assert_ne!(et, EVENT_WEAPON_ALERT, "normal person should not trigger weapon alert");
assert_ne!(et, EVENT_HIGH_METAL_REFLECTIVITY, "normal person should not trigger weapon alert");
}
}
}
@@ -73,6 +73,8 @@ impl WelfordStats {
/// Coherence-gated frame filter.
pub struct CoherenceGate {
/// Per-call event scratch buffer (owned; replaces former `static mut`).
events: [(i32, f32); 3],
prev_phases: [f32; MAX_SC],
stats: WelfordStats,
initial_variance: f32,
@@ -89,6 +91,7 @@ pub struct CoherenceGate {
impl CoherenceGate {
pub const fn new() -> Self {
Self {
events: [(0, 0.0); 3],
prev_phases: [0.0; MAX_SC],
stats: WelfordStats::new(),
initial_variance: 0.0,
@@ -105,7 +108,6 @@ impl CoherenceGate {
let n_sc = if phases.len() > MAX_SC { MAX_SC } else { phases.len() };
if n_sc < 2 { return &[]; }
static mut EVENTS: [(i32, f32); 3] = [(0, 0.0); 3];
let mut n_ev = 0usize;
if !self.initialized {
@@ -146,7 +148,7 @@ impl CoherenceGate {
self.gate = GateDecision::Recalibrate;
self.low_count = 0;
self.high_count = 0;
unsafe { EVENTS[n_ev] = (EVENT_RECALIBRATE_NEEDED, variance); }
self.events[n_ev] = (EVENT_RECALIBRATE_NEEDED, variance);
n_ev += 1;
} else {
let below = coherence < LOW_THRESHOLD;
@@ -178,11 +180,11 @@ impl CoherenceGate {
};
}
unsafe { EVENTS[n_ev] = (EVENT_GATE_DECISION, self.gate.as_f32()); }
self.events[n_ev] = (EVENT_GATE_DECISION, self.gate.as_f32());
n_ev += 1;
unsafe { EVENTS[n_ev] = (EVENT_COHERENCE_SCORE, coherence); }
self.events[n_ev] = (EVENT_COHERENCE_SCORE, coherence);
n_ev += 1;
unsafe { &EVENTS[..n_ev] }
&self.events[..n_ev]
}
pub fn gate(&self) -> GateDecision { self.gate }
@@ -25,6 +25,8 @@ pub const EVENT_SPATIAL_FOCUS_ZONE: i32 = 702;
/// Flash Attention spatial focus estimator.
pub struct FlashAttention {
/// Per-call event scratch buffer (owned; replaces former `static mut`).
events: [(i32, f32); 3],
prev_group_phases: [f32; N_GROUPS],
attention_weights: [f32; N_GROUPS],
smoothed_entropy: f32,
@@ -37,6 +39,7 @@ pub struct FlashAttention {
impl FlashAttention {
pub const fn new() -> Self {
Self {
events: [(0, 0.0); 3],
prev_group_phases: [0.0; N_GROUPS],
attention_weights: [0.0; N_GROUPS],
smoothed_entropy: MAX_ENTROPY,
@@ -50,7 +53,6 @@ impl FlashAttention {
let n_sc = phases.len().min(amplitudes.len()).min(MAX_SC);
if n_sc < N_GROUPS { return &[]; }
static mut EVENTS: [(i32, f32); 3] = [(0, 0.0); 3];
// Per-group means for Q and V.
let subs_per = n_sc / N_GROUPS;
@@ -117,12 +119,10 @@ impl FlashAttention {
for g in 0..N_GROUPS { self.prev_group_phases[g] = q[g]; }
// Emit events.
unsafe {
EVENTS[0] = (EVENT_ATTENTION_PEAK_SC, peak_idx as f32);
EVENTS[1] = (EVENT_ATTENTION_SPREAD, self.smoothed_entropy);
EVENTS[2] = (EVENT_SPATIAL_FOCUS_ZONE, centroid);
&EVENTS[..3]
}
self.events[0] = (EVENT_ATTENTION_PEAK_SC, peak_idx as f32);
self.events[1] = (EVENT_ATTENTION_SPREAD, self.smoothed_entropy);
self.events[2] = (EVENT_SPATIAL_FOCUS_ZONE, centroid);
&self.events[..3]
}
pub fn weights(&self) -> &[f32; N_GROUPS] { &self.attention_weights }
@@ -59,6 +59,8 @@ impl PersonSlot {
/// Min-cut person identity matcher.
pub struct PersonMatcher {
/// Per-call event scratch buffer (owned; replaces former `static mut`).
events: [(i32, f32); 8],
slots: [PersonSlot; MAX_PERSONS],
active_count: u8,
prev_assignment: [u8; MAX_PERSONS],
@@ -69,6 +71,7 @@ pub struct PersonMatcher {
impl PersonMatcher {
pub const fn new() -> Self {
Self {
events: [(0, 0.0); 8],
slots: [
PersonSlot::new(0),
PersonSlot::new(1),
@@ -98,7 +101,6 @@ impl PersonMatcher {
self.frame_count += 1;
let n_det = n_persons.min(MAX_PERSONS);
static mut EVENTS: [(i32, f32); 8] = [(0, 0.0); 8];
let mut n_events = 0usize;
// Extract per-person feature vectors (spatial region -> top-8 variances).
@@ -134,9 +136,7 @@ impl PersonMatcher {
self.swap_count += 1;
if n_events < 7 {
let swap_val = (prev as f32) * 16.0 + (curr as f32);
unsafe {
EVENTS[n_events] = (EVENT_PERSON_ID_SWAP, swap_val);
}
self.events[n_events] = (EVENT_PERSON_ID_SWAP, swap_val);
n_events += 1;
}
}
@@ -177,9 +177,7 @@ impl PersonMatcher {
0.0
};
let val = slot.person_id as f32 + confidence.min(0.99) * 0.01;
unsafe {
EVENTS[n_events] = (EVENT_PERSON_ID_ASSIGNED, val);
}
self.events[n_events] = (EVENT_PERSON_ID_ASSIGNED, val);
n_events += 1;
}
}
@@ -213,9 +211,7 @@ impl PersonMatcher {
avg_conf /= n_det as f32;
if n_events < 8 {
unsafe {
EVENTS[n_events] = (EVENT_MATCH_CONFIDENCE, avg_conf);
}
self.events[n_events] = (EVENT_MATCH_CONFIDENCE, avg_conf);
n_events += 1;
}
}
@@ -223,7 +219,7 @@ impl PersonMatcher {
// Save current assignment for next-frame swap detection.
self.prev_assignment = assignment;
unsafe { &EVENTS[..n_events] }
&self.events[..n_events]
}
/// Extract top-FEAT_DIM variance values (descending) from a subcarrier range.
@@ -84,12 +84,15 @@ pub struct OptimalTransportDetector {
frame_count: u32,
shift_streak: u8,
subtle_streak: u8,
/// Per-call event scratch buffer (owned; replaces former `static mut`).
events: [(i32, f32); 4],
}
impl OptimalTransportDetector {
pub const fn new() -> Self {
Self { prev_amps: [0.0; MAX_SC], smoothed_dist: 0.0, smoothed_var: 0.0, prev_var: 0.0,
initialized: false, frame_count: 0, shift_streak: 0, subtle_streak: 0 }
initialized: false, frame_count: 0, shift_streak: 0, subtle_streak: 0,
events: [(0, 0.0); 4] }
}
fn w1_sorted(a: &[f32], b: &[f32], n: usize) -> f32 {
@@ -150,16 +153,15 @@ impl OptimalTransportDetector {
i = 0; while i < n { self.prev_amps[i] = cur[i]; i += 1; }
static mut EV: [(i32, f32); 4] = [(0, 0.0); 4];
let mut ne = 0usize;
if self.frame_count % 5 == 0 && ne < 4 {
unsafe { EV[ne] = (EVENT_WASSERSTEIN_DISTANCE, self.smoothed_dist); } ne += 1;
self.events[ne] = (EVENT_WASSERSTEIN_DISTANCE, self.smoothed_dist); ne += 1;
}
if self.smoothed_dist > WASS_SHIFT {
self.shift_streak = self.shift_streak.saturating_add(1);
if self.shift_streak >= SHIFT_DEB && ne < 4 {
unsafe { EV[ne] = (EVENT_DISTRIBUTION_SHIFT, self.smoothed_dist); } ne += 1;
self.events[ne] = (EVENT_DISTRIBUTION_SHIFT, self.smoothed_dist); ne += 1;
self.shift_streak = 0;
}
} else { self.shift_streak = 0; }
@@ -167,12 +169,12 @@ impl OptimalTransportDetector {
if self.smoothed_dist > WASS_SUBTLE && vc < VAR_STABLE {
self.subtle_streak = self.subtle_streak.saturating_add(1);
if self.subtle_streak >= SUBTLE_DEB && ne < 4 {
unsafe { EV[ne] = (EVENT_SUBTLE_MOTION, self.smoothed_dist); } ne += 1;
self.events[ne] = (EVENT_SUBTLE_MOTION, self.smoothed_dist); ne += 1;
self.subtle_streak = 0;
}
} else { self.subtle_streak = 0; }
unsafe { &EV[..ne] }
&self.events[..ne]
}
pub fn distance(&self) -> f32 { self.smoothed_dist }
@@ -64,6 +64,8 @@ fn soft_threshold(x: f32, t: f32) -> f32 {
/// Sparse subcarrier recovery engine.
pub struct SparseRecovery {
/// Per-call event scratch buffer (owned; replaces former `static mut`).
events: [(i32, f32); 3],
/// Compact correlation estimate: [MAX_SC][NEIGHBORS].
/// For subcarrier i: [corr(i,i-1), corr(i,i), corr(i,i+1)].
/// Edge entries (i=0 left neighbor, i=31 right neighbor) are zero.
@@ -87,6 +89,7 @@ pub struct SparseRecovery {
impl SparseRecovery {
pub const fn new() -> Self {
Self {
events: [(0, 0.0); 3],
correlation: [[0.0; NEIGHBORS]; MAX_SC],
recent_valid: [0.0; MAX_SC],
initialized: false,
@@ -135,20 +138,17 @@ impl SparseRecovery {
}
// -- Build event output -----------------------------------------------
static mut EVENTS: [(i32, f32); 3] = [(0, 0.0); 3];
let mut n_events = 0usize;
// Always emit dropout rate periodically (every 20 frames).
if self.frame_count % 20 == 0 {
unsafe {
EVENTS[n_events] = (EVENT_DROPOUT_RATE, dropout_rate);
}
self.events[n_events] = (EVENT_DROPOUT_RATE, dropout_rate);
n_events += 1;
}
// -- Skip recovery if dropout too low or model not ready ---------------
if dropout_rate < MIN_DROPOUT_RATE || !self.initialized {
unsafe { return &EVENTS[..n_events]; }
return &self.events[..n_events];
}
// -- ISTA recovery ----------------------------------------------------
@@ -158,19 +158,15 @@ impl SparseRecovery {
// Emit recovery results.
if n_events < 3 {
unsafe {
EVENTS[n_events] = (EVENT_RECOVERY_COMPLETE, recovered as f32);
}
self.events[n_events] = (EVENT_RECOVERY_COMPLETE, recovered as f32);
n_events += 1;
}
if n_events < 3 {
unsafe {
EVENTS[n_events] = (EVENT_RECOVERY_ERROR, residual);
}
self.events[n_events] = (EVENT_RECOVERY_ERROR, residual);
n_events += 1;
}
unsafe { &EVENTS[..n_events] }
&self.events[..n_events]
}
/// Update the compact correlation model from a fully valid frame.
@@ -54,12 +54,16 @@ pub struct TemporalCompressor {
prev_ts: u32,
has_ts: bool,
ratio: f32,
/// Per-call event scratch buffers (owned; replace former `static mut`).
events: [(i32, f32); 4],
timer_events: [(i32, f32); 2],
}
impl TemporalCompressor {
pub const fn new() -> Self {
const E: Snap = Snap::empty();
Self { buf: [E; CAP], w_idx: 0, total: 0, frame_rate: 20.0, prev_ts: 0, has_ts: false, ratio: 1.0 }
Self { buf: [E; CAP], w_idx: 0, total: 0, frame_rate: 20.0, prev_ts: 0, has_ts: false, ratio: 1.0,
events: [(0, 0.0); 4], timer_events: [(0, 0.0); 2] }
}
fn occ(&self) -> usize { if (self.total as usize) < CAP { self.total as usize } else { CAP } }
@@ -97,7 +101,6 @@ impl TemporalCompressor {
}
self.prev_ts = ts_ms; self.has_ts = true;
static mut EV: [(i32, f32); 4] = [(0, 0.0); 4];
let mut ne = 0usize;
let occ = self.occ();
@@ -113,23 +116,22 @@ impl TemporalCompressor {
let mut j = 0;
while j < VALS { let d = dequantize(self.buf[slot].data[j], s, old_l); self.buf[slot].data[j] = quantize(d, s, new_l); j += 1; }
self.buf[slot].tier = new_t;
if ne < 4 { unsafe { EV[ne] = (EVENT_TIER_TRANSITION, new_t as i32 as f32); } ne += 1; }
if ne < 4 { self.events[ne] = (EVENT_TIER_TRANSITION, new_t as i32 as f32); ne += 1; }
}
}
}
self.ratio = self.calc_ratio(occ);
if self.total % 64 == 0 && ne < 4 { unsafe { EV[ne] = (EVENT_COMPRESSION_RATIO, self.ratio); } ne += 1; }
unsafe { &EV[..ne] }
if self.total % 64 == 0 && ne < 4 { self.events[ne] = (EVENT_COMPRESSION_RATIO, self.ratio); ne += 1; }
&self.events[..ne]
}
/// Periodic timer events.
pub fn on_timer(&self) -> &[(i32, f32)] {
static mut TE: [(i32, f32); 2] = [(0, 0.0); 2];
pub fn on_timer(&mut self) -> &[(i32, f32)] {
let mut n = 0;
let h = self.history_hours();
if h > 0.0 { unsafe { TE[n] = (EVENT_HISTORY_DEPTH_HOURS, h); } n += 1; }
unsafe { TE[n] = (EVENT_COMPRESSION_RATIO, self.ratio); } n += 1;
unsafe { &TE[..n] }
if h > 0.0 { self.timer_events[n] = (EVENT_HISTORY_DEPTH_HOURS, h); n += 1; }
self.timer_events[n] = (EVENT_COMPRESSION_RATIO, self.ratio); n += 1;
&self.timer_events[..n]
}
fn calc_ratio(&self, occ: usize) -> f32 {
@@ -56,6 +56,8 @@ fn l2_query(stored: &[f32; DIM], query: &[f32]) -> f32 {
/// Micro-HNSW on-device vector index.
pub struct MicroHnsw {
/// Per-call event scratch buffer (owned; replaces former `static mut`).
events: [(i32, f32); 4],
nodes: [HnswNode; MAX_VECTORS],
n_vectors: usize,
entry_point: usize,
@@ -68,6 +70,7 @@ impl MicroHnsw {
pub const fn new() -> Self {
const EMPTY: HnswNode = HnswNode::empty();
Self {
events: [(0, 0.0); 4],
nodes: [EMPTY; MAX_VECTORS], n_vectors: 0, entry_point: usize::MAX,
frame_count: 0, last_nearest: 0, last_distance: f32::MAX,
}
@@ -194,9 +197,8 @@ impl MicroHnsw {
pub fn process_frame(&mut self, features: &[f32]) -> &[(i32, f32)] {
self.frame_count += 1;
if self.n_vectors == 0 {
static mut EMPTY: [(i32, f32); 1] = [(0, 0.0); 1];
unsafe { EMPTY[0] = (EVENT_LIBRARY_SIZE, 0.0); }
return unsafe { &EMPTY[..1] };
self.events[0] = (EVENT_LIBRARY_SIZE, 0.0);
return &self.events[..1];
}
let (nearest_id, distance) = self.search(features);
self.last_nearest = nearest_id;
@@ -205,14 +207,11 @@ impl MicroHnsw {
self.nodes[nearest_id].label
} else { CLASS_UNKNOWN };
static mut EVENTS: [(i32, f32); 4] = [(0, 0.0); 4];
unsafe {
EVENTS[0] = (EVENT_NEAREST_MATCH_ID, nearest_id as f32);
EVENTS[1] = (EVENT_MATCH_DISTANCE, distance);
EVENTS[2] = (EVENT_CLASSIFICATION, label as f32);
EVENTS[3] = (EVENT_LIBRARY_SIZE, self.n_vectors as f32);
}
unsafe { &EVENTS[..4] }
self.events[0] = (EVENT_NEAREST_MATCH_ID, nearest_id as f32);
self.events[1] = (EVENT_MATCH_DISTANCE, distance);
self.events[2] = (EVENT_CLASSIFICATION, label as f32);
self.events[3] = (EVENT_LIBRARY_SIZE, self.n_vectors as f32);
&self.events[..4]
}
pub fn size(&self) -> usize { self.n_vectors }

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