mirror of
https://github.com/ruvnet/RuView
synced 2026-07-28 18:21:42 +00:00
Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c3b16b70d0 | |||
| 235d3b4115 | |||
| 192b4c6b4a | |||
| 246b5ba4af | |||
| 3044108295 | |||
| b809ca8abd | |||
| 41d52311bd | |||
| b273fac719 | |||
| 22dfd0a407 | |||
| 6953944255 | |||
| d925538773 | |||
| a8291d2535 | |||
| d0cf36a0ba | |||
| 9744d367a2 | |||
| 46b4d63dad | |||
| d55e3659be | |||
| 2aac160067 | |||
| d3c1082c03 | |||
| 3413a74228 | |||
| 3408883f79 | |||
| 9161fa7156 | |||
| 09960d405f | |||
| 205b12032b | |||
| 662820e2d1 | |||
| 7bf8680da2 | |||
| 1deb9df2d4 |
@@ -7,7 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
- **Mesh partition risk now demotes the privacy class and is witnessed (ADR-032).** The dynamic min-cut guard's `at_risk` signal was advisory-only (it fed the recalibration advisor). It now also contributes to the ADR-141 privacy demotion alongside fusion- and array-level contradictions: a mesh close to partitioning makes the fused belief less trustworthy, so the cycle emits at a more restricted class (monotonic — information only removed). Because `effective_class` feeds the BLAKE3 witness, a fragmenting array now shifts the witness — partition risk is auditable, not just logged. The mesh computation moved ahead of the demotion step in `process_cycle`; new `mesh_guard_mut()` exposes risk-threshold tuning. Test proves a forced-risk 3-node cycle demotes PrivateHome Anonymous→Restricted and shifts the witness vs a clean *same-topology* baseline (the only delta between the two cycles is the forced risk).
|
||||
|
||||
### Added
|
||||
- **Dynamic min-cut mesh partition guard in the streaming engine (`mesh_guard`).** Maintains a `ruvector-mincut` exact min-cut over the live mesh coupling graph (nodes = sensing nodes, coupling = product of fusion attention weights), surfacing per cycle: the global **cut value** (how close the array is to splitting — a structural measure per-node heuristics miss), the **weak side** (which specific nodes would partition: failure/jamming triage feeding ADR-032 posture), and an **at-risk flag** that counts as a structural event for the drift→recalibration advisor. Surfaced as `TrustedOutput::mesh`. **Measured cost policy** (criterion, 12-node mesh): weights are quantized (1/64; a *nonzero* coupling below one quantum saturates to quantum 1 so quantization never erases a live coupling — without the floor, balanced meshes of ≥ 65 nodes had every ~1/n coupling erased and sat permanently "at risk") and updates change-gated, so the steady-state cycle does zero graph work (~7.3 µs, ~23× cheaper than building); on any real change a full exact rebuild (~171 µs) is used because one `DynamicMinCut` delete+insert measured ~240 µs — the incremental machinery's overhead targets much larger graphs, so rebuild-on-change is the measured optimum at mesh scale (one-edge case −28% after the policy switch). Degenerate cases fail toward risk: a node with zero coupling is reported as already partitioned (cut 0). 9 mesh-guard tests + an engine-level wiring test; full `process_cycle` with the guard: ~33 µs for 4 nodes (50 ms budget).
|
||||
- **`ruview-gamma-clinic` crate (ADR-251) — clinical dashboard + persistent hash-chained RuVector store.** Read-only research/clinical instrumentation over the ADR-250 platform, closing two operational gaps (no durable cohort memory; no clinician surface). **Store** (`store.rs`): append-only JSONL holding profiles, witnessed session summaries, and acceptance verdicts, each line hash-chained `entry_hash = SHA-256(prev ‖ raw record bytes)` so any retroactive edit/deletion/reorder breaks the chain — the store *fails closed* (refuses to open tampered data) and rebuilds the RuVector kNN/clustering layer on open so warm-start survives restarts. (Hashes the exact on-disk bytes via `RawValue`, since serde_json's default float parse is lossy and re-serialization isn't byte-stable.) **Dashboard** (`server.rs` + embedded dependency-free `dashboard.html`): Axum surface with `GET` routes for participants, per-participant frequency-response map + session trend (safety-stop markers), cohort clusters, per-program acceptance verdicts, and a live chain-integrity badge — **strictly read-only** (a test asserts no route accepts POST). Claim discipline inherited: acceptance payloads carry `AcceptanceReport::released_claim` (the gate's output, `NO_CLAIM` on failure), never a raw program claim. `gamma-clinic` binary; `ingest_governor` bridges the live ADR-250 loop into the store (pseudonymous, dedup by witness hash). 20 tests (13 store/lib + 7 server) + 1 doctest; pseudonymity asserted (the `person_id` never reaches disk).
|
||||
- **Opt-in FFT operator for the CIR ISTA solver (8–14× measured).** Φ is a sub-DFT, so each ISTA mat-vec can run as one length-G FFT (O(G log G)) instead of a dense O(K·G) product. New `CirConfig::fft_operator` (default **false** — the dense path stays the bit-exact witness default; the FFT evaluates the same sums in a different order, so enabling it shifts float results and requires regenerating any pinned witness). `FftOperator` (rustfft, planned once at construction, scratch reused across the ISTA loop) dispatches inside `ista_solve`; warm-start/Lipschitz stay dense at construction. Measured (criterion, same run): ht20 2.22 ms → 265 µs (**8.4×**), ht40 10.26 ms → 717 µs (**14.3×**); the real HE40 grid (K=484, G=1452) scales further. 3 new tests: FFT↔dense matvec equivalence to float tolerance (ht20 + he40 grids), end-to-end dominant-tap agreement on a single-path frame, and all default configs keep FFT off. New `cir_estimate_fft` bench group.
|
||||
- **Per-room adapter provenance + drift→recalibration advisor in the streaming engine.** Closes the trust-chain gap where an ~11 KB per-room LoRA adapter (ADR-150 §3.4) could silently change inference without the witness noticing. `StreamingEngine::set_room_adapter(AdapterInfo)` pins the adapter's content-derived id into provenance `model_version` (`rfenc-v1+adapter:<id>`) — and therefore into the BLAKE3 witness — so swapping or clearing adapter weights always shifts the witness (engine test proves base → adapter → other-adapter → cleared all witness differently, and cleared == base). New `RecalibrationAdvisor` recommends re-running the ADR-135 baseline / refitting the adapter on sustained low fusion coherence (streak threshold, default 60 cycles ≈ 3 s at 20 Hz) or an ADR-142 change-point; surfaced as `TrustedOutput::recalibration_recommended` and recorded on the sensing-server's `EngineBridge` alongside the witness. Bridge plumbing: `EngineBridge::{set_room_adapter, clear_room_adapter}` + live-path test that the adapter id flows into the live witness. *Scope note: this is the deployable provenance/trigger half of the "retrained model" roadmap item — fitting the adapter itself runs in the existing external calibration service (`aether-arena/calibration/`), and a trained RF-encoder checkpoint still does not exist in-tree.*
|
||||
- **`esp32-gamma-stim` firmware — ESP32 gamma stimulation actuator (ADR-250 §21 M2 device harness).** The hardware side of `ruview-gamma`: an ESP32 driving an LED + audio flicker at a commanded 36–44 Hz envelope with a hardware emergency stop. Split into a **pure, host-tested safety core** (`main/stim_core.{h,c}` — envelope validation mirroring `SafetyEnvelope::conservative()`, a latched START/STOP/e-stop state machine, exact integer timing math in millihertz so the ±0.1 Hz HIL target is exact, and a line-protocol parser; **15 host tests pass under gcc, no ESP-IDF needed**) and a thin **ESP-IDF binding** (`main/main.c` — GPTimer ISR, LEDC PWM for LED+audio, sync-out GPIO for logic-analyzer capture, e-stop GPIO ISR that kills outputs in microseconds, USB-CDC console). Defense in depth: the device re-enforces the safety envelope independently of the Rust host, so a buggy/compromised host still cannot command an out-of-envelope output. Emits a canonical integer `SESSION {...}` record per run for witness-hash reproduction. Maps 1:1 to the five `hil::verify_hil` targets. Kconfig pin config, 4 MB single-app, radio-off deterministic actuator profile.
|
||||
- **`ruview-gamma` claim-gate invariant + hardware-in-the-loop contract.** Centralized the claim release rule into a single `acceptance::claim_allowed(entrainment, safety, adherence, repeatability)` (strict AND of all four) used by every path, with a test proving every 3-of-4 subset is denied — no path can weaken the gate. New `hil` module: `verify_hil` grades a captured actuator bench measurement against fixed targets (LED frequency ±0.1 Hz, audio-visual sync < 5 ms, stop-signal→actuator-off < 100 ms, session-hash reproducibility 100%, EEG entrainment lift ≥ 20% over fixed 40 Hz) — the next acceptance milestone for a real LED+speaker (e.g. ESP32) actuator; all failure modes fail closed (missing stop measurement, no replay, any hash mismatch). README gains the benchmark table and the "governed personalization engine that refuses to overpromise" positioning. 9 new tests; crate now 97 + 1 doctest; pinned witness preserved.
|
||||
- **`ruview-gamma` generalized to an adaptive sensory neuromodulation platform (ADR-250 §23).** 40 Hz is now one prior in one program, not the product. New `program` module: `NeuroProgram` catalog of 7 use cases (Alzheimer's research, post-stroke cognition, sleep optimization, attention/working-memory, mood/arousal, home wellness, drug+device trial infrastructure), each with its own `SafetyEnvelope`, starting prior, `ObjectiveWeights`, physiological-state gating (sleep permits `Asleep` + near-dark brightness cap; attention requires wakefulness), `EvidenceLevel`, and a single non-disease claim. New `acceptance` module makes the acceptance sentence executable: `AcceptanceHarness` grades a program over ≥3 repeats on entrainment gain, safety-stop rate, adherence, and optimal-frequency repeatability, exposing a `ClaimGate` that returns the program's claim **only if all four criteria pass** — the marketing claim is otherwise unreadable (`NO_CLAIM`). Governor wiring: `enroll_program` (per-program envelope/objective; `enroll` stays the bare Alzheimer's-defaults path so the pinned witness `13cb164c…` is preserved), `program()`, `prior()`, `state_eligible()`. 13 new module tests + 2 platform integration tests (per-program envelope enforced end-to-end — a stimulus valid for Alzheimer's is refused by the sleep program; acceptance gates every catalog program's claim); crate now 88 tests + 1 doctest. Bench: full 3-repeat program grading ~425 µs.
|
||||
- **`ruview-gamma` RuVector self-learning layer (ADR-250 §10 items 3–6).** New `ruvector` module: anonymized `ProfileStore` (one-way SHA-256 hashed tags, never `person_id`; safe-session scores only), deterministic exact kNN (fixed-range normalization, index tie-break), **cohort warm-start** — a new person's optimizer is seeded from the k nearest responders as down-weighted GP pseudo-observations (`BayesianOptimizer::observe_prior`, ≥25× real-observation noise, excluded from the EI incumbent / audit / clinician report), **physiological drift detection** (Welford centroid with stimulus-input fields masked out of the distance; `Drifted` recommends re-calibration), and deterministic k-means response clustering (farthest-point seeding, no RNG). Wired into `RufloGovernor` (`seed_from_cohort`, `export_anonymized_profile`, per-session `drift_status`). The GP gains per-observation noise (real path unchanged — pinned witness `13cb164c…` preserved). 11 new module tests + 2 integration tests (cohort warm-start beats the cold 40 Hz prior for a detuned subject; collapsed physiology flags drift); crate now 75 tests + 1 doctest. Benches: kNN over 500 profiles ~15 µs, full warm-start ~16 µs; no regression on existing paths.
|
||||
- **`ruview-gamma` crate (ADR-250) — Adaptive Gamma Entrainment.** Governed, deterministic, safety-constrained personalization of 40 Hz-prior light+sound stimulation, treating 40 Hz as the evidence-based *starting prior* and learning each person's safe entrainment response curve. Eleven modules: `stimulus` (params + `SafetyEnvelope` validate/clamp), `safety` (exclusion screen + latched `SafetyMonitor` with hard-stop reasons), `response` (`RuViewState`, optional `EegMeasurement`, 20-field `PersonResponseVector` with sticky adverse flag), `objective` (safe-entrainment score; safety is a hard gate, not a weight), `simulator` (deterministic ChaCha20 `frequency_response_curve`), `optimizer` (Phase-1 calibration sweep + Phase-2 GP/Expected-Improvement + Phase-4 closed-loop control), `bandit` (Phase-3 LinUCB over envelope-safe arms), `session` (reproducible SHA-256 `session_hash`), `ruflo` (consent→exclusion→envelope→run→monitor→score→update→witnessed audit, trial/sham mode, clinician export, claim discipline), `proof` (deterministic bundle witness), `math` (dependency-light numerics). **Safety invariant** (asserted in tests): no recommendation, calibration step, bandit arm, or closed-loop nudge can ever emit a stimulus outside the `SafetyEnvelope`; non-finite inputs clamp to the conservative floor. **Claim discipline**: the only product claim is `PRODUCT_CLAIM` = "personalized entrainment optimization" — never Alzheimer's treatment (ADR-250 §19). Standalone leaf crate (no internal RuView deps), `publish = false` pending safety sign-off. 64 unit/integration tests + 1 doctest pass; deterministic witness pinned (`13cb164c…`); criterion benches (safety-stop tick ~9.3 ns vs the ADR §17 500 ms bound, Bayesian recommend ~105 µs, full 9-session governed sweep ~486 µs). See [ADR-250](docs/adr/ADR-250-adaptive-gamma-entrainment.md).
|
||||
- **RuView beyond-SOTA research series** (`docs/research/ruview-beyond-sota/`, 6 docs) — research-swarm output defining the beyond-SOTA bar and the path to it: system capability audit (role→crate maturity matrix, gap analysis, risk register), web-verified 2026 SOTA landscape per capability axis (incl. ratified IEEE 802.11bf-2025), 8-pillar target architecture on the ADR-136 contract spine (no rewrite), 6-layer benchmark/validation methodology (all 15 criterion bench targets inventoried; ADR-149 statistical protocol), and a determinism-safe optimization roadmap. Includes session validation evidence: 2,797 workspace tests / 0 failed, Python proof PASS (bit-exact), paired pre/post criterion runs.
|
||||
|
||||
### Performance
|
||||
- **`ruview-gamma` Bayesian optimizer factorized once per recommendation.** `BayesianOptimizer::recommend` evaluated Expected Improvement at every 0.1 Hz grid candidate, and each `predict` rebuilt the full kernel matrix and re-ran Cholesky — ~82 factorizations per recommendation, though `K` and `alpha = K⁻¹y` depend only on the observations, not the query point. Now the GP is fit once (`GpFit`: cached `L` + `alpha`, lower-triangle-only `K` build) and reused across the grid. Bit-identical arithmetic (the pinned deterministic witness `13cb164c…` and all 64 tests are unchanged), pure work elimination. Measured (criterion, paired): `gamma_bayesian_recommend` 105 µs → 19 µs (**−81%**), `gamma_calibration_sweep` (9 recommends) 466 µs → 135 µs (**−71%**).
|
||||
- **CIR estimator warm-start precompute** — the diagonal Tikhonov preconditioner `diag(Φ^H Φ)+λI` and its CSR matrix were rebuilt every frame although they depend only on Φ and λ (fixed at `CirEstimator::new`); now precomputed at construction (`ruvsense/cir.rs`). Bit-identical floats (summation order unchanged, witness chain unaffected). Measured: `cir_estimate/he40` −3.9% (p<0.01), multiband groups −1.2/−1.4%; smaller configs within container noise.
|
||||
- **RF tomography solver hoisting** — ISTA gradient buffer no longer allocated inside the 100-iteration loop, and the Frobenius Lipschitz bound moved from per-`reconstruct` to construction (`ruvsense/tomography.rs`). Bit-identical results.
|
||||
|
||||
### Added
|
||||
- **Falsifiable occupancy benchmark (`wifi-densepose-train::occupancy_bench`).** Makes the presence/person-count "beyond SOTA" claim falsifiable in code instead of aspirational (the unfalsifiability gap from the beyond-SOTA system review). Grades predictions vs ground truth and gates a SOTA claim behind one `claim_allowed` invariant requiring all of: `DataProvenance::Measured` (synthetic/mock is scorable but **never claimable** — anti-mock-contamination per the CLAUDE.md Kconfig-bug lesson), a leak-free `EvalSplit` (refuses any split where a subject *or* environment id appears in both train and test — subject leakage / per-environment overfitting), `n_test ≥ min`, a **non-degenerate test set** (both truth classes represented: present-rate ≥ `min_positive_rate` and ≥ 1 absent sample — an all-absent set plus an always-absent predictor cannot release a claim; vacuous F1 scores 0.0, never 1.0), presence-F1 **bootstrap-CI lower bound** (deterministic seeded splitmix64) clearing the threshold, and count MAE within threshold. The claim string is unreadable except through the gate (`NO_CLAIM` otherwise) — same discipline as the `ruview-gamma` acceptance gate. What remains is data, not method: a frozen, SHA-pinned, subject/environment-disjoint measured replay set turns the claim into a passing/failing test. 12 tests cover each refusal path, including the point-above/CI-below case (claim withheld on the CI lower bound even when the point estimate clears the threshold).
|
||||
- **Live trust path: sensing-server routes real frames through the governed `StreamingEngine` (parallel governed path with partial output gating).** Previously the live server ran only the *bare* `MultistaticFuser` (fused amplitudes, no trust control plane), while the privacy/provenance/witness engine (ADR-135..146) ran only on synthetic in-test frames — the gap called out in ADR-136 §8 and the beyond-SOTA system review. New `engine_bridge` module drives `StreamingEngine::process_cycle` from the server's live `NodeState` map (reusing the existing `NodeState → MultiBandCsiFrame` conversion), lazily wiring each node as a WorldGraph sensor and bounding belief growth via the retention cap; every *governed belief* carries evidence + model + calibration + privacy decision and a deterministic witness. **Honest scope:** the engine runs alongside (not instead of) the bare fusion path that feeds the live `SensingUpdate`. What its decision gates on the wire today: a cycle emitted at class `Restricted` (base mode or contradiction/mesh-risk demotion) suppresses the per-node raw amplitude vectors from the live publish — the same field mapping `wifi-densepose-bfld`'s privacy gate applies at `Restricted`; gating the remaining derived outputs (person count, classification, signal field) is tracked as a follow-up. Trust state is no longer write-only: the latest witness, effective privacy class, demotion flag, recalibration recommendation, and an engine-error counter are readable on `GET /api/v1/status`, and engine errors are counted + rate-limit logged instead of silently swallowed (`EngineBridge::observe_cycle`). Adds `wifi-densepose-engine/-worldgraph/-bfld/-geo` deps. Bridge tests cover witnessed belief with provenance, determinism, idempotent node registration, retention bound, privacy-mode propagation, trust-state recording, the error-counter path, and Restricted-class raw-output suppression.
|
||||
|
||||
### Fixed
|
||||
- **`wifi-densepose-mat` standalone `--no-default-features` build (101 errors → 0).** `pub mod api` was unconditional while its only dependency, serde, is optional behind the `api` feature — so any build without default features failed with unresolved serde imports (masked in `--workspace` runs by feature unification). The `api` module and its `create_router`/`AppState` re-export are now `#[cfg(feature = "api")]`-gated (with docsrs annotations). All feature combos compile: bare `--no-default-features`, `--no-default-features --features api`, and full default (177 tests pass). `StreamingEngine::process_cycle` appended one `SemanticState` belief per cycle with no eviction — ~1.7M nodes/day at 20 Hz (identified in `docs/research/ruview-beyond-sota/04-optimization-roadmap.md`). Added `WorldGraph::prune_semantic_states(max)` — deterministic eviction of the oldest beliefs by `(valid_from_unix_ms, id)`, structural nodes (rooms/zones/sensors/anchors/tracks/events) never eligible — and wired it into the engine after each belief append (`StreamingEngine::DEFAULT_SEMANTIC_RETENTION` = 7,200 ≈ 6 min at 20 Hz; tunable via `set_semantic_retention`). The WorldGraph holds *current* beliefs; durable history is the recorder's job, so no audit data is lost. 3 new tests (bounded growth end-to-end, oldest-only eviction, deterministic tie-break).
|
||||
- **ESP32 edge heart rate no longer stuck at ~45 BPM / dropping wildly — #987.** The on-device HR estimator (`edge_processing.c`, `0xC5110002`) reported ~45 BPM regardless of true heart rate (Apple-Watch ground truth 87 BPM read as ~45) and swung frame-to-frame. Two root causes: (1) a hardcoded `sample_rate = 10.0f` that became wrong after #985's self-ping raised the CSI callback rate to a variable ~13–19 Hz — BPM scales as `assumed/actual × true`, so 87 read ~45 and the reading swung as CSI yield fluctuated; (2) the zero-crossing estimator locked onto a breathing harmonic (a 0.25 Hz breathing fundamental puts its 3rd harmonic at ~0.74 Hz ≈ 44 BPM inside the HR band). Fix: measure the real sample rate from inter-frame timestamps (used for BPM conversion + biquad re-tuning on >15% drift); replace the HR zero-crossing with an autocorrelation estimator that rejects breathing harmonics (driven by a robust autocorr breathing period); median-13 smooth the output. Hardware A/B (fixed vs unmodified control board, both `edge_tier=2`): control pegged 40–49 BPM; fixed reaches the true 88–91 BPM (vs 87 GT) and holds a stable physiological value (spread 59→0 for a steady subject). Known limitation: heavy subject motion still degrades the estimate (motion gating is a follow-up).
|
||||
- **Person count no longer leaks up to 10 in heuristic mode — addresses #894.** `field_bridge::occupancy_or_fallback` returned the eigenvalue-based `FieldModel::estimate_occupancy` count **unbounded** (its internal ceiling is 10), while the sibling estimators on the same single-link data — the perturbation-energy fallback right below it and `score_to_person_count` — both cap at 3 ("1-3 for single ESP32"). On noisy / under-calibrated CSI the eigenvalue count inflated, producing the "10 persons reported when 1 present" symptom (seen when `--model` fails to load and the server runs on heuristics). Bounded the eigenvalue path to the shared `MAX_SINGLE_LINK_OCCUPANCY` (3) so every estimator on one link agrees; genuine higher counts come from the multistatic fusion path, not a single-link covariance estimate.
|
||||
- **MQTT multi-node deployments now create one Home-Assistant device per node — closes #898.** After the #872 MQTT wiring landed, the JSON→`VitalsSnapshot` bridge hard-coded a single `node_id` (the MQTT client id) and the publisher used a single `OwnedDiscoveryBuilder`, so every physical node collapsed into one device (`identifiers:["wifi_densepose_wifi-densepose-1"]`), contradicting the "one device per node" docs. The bridge now emits one snapshot per node in the sensing update's `nodes[]` (each with its own `node_id` + RSSI, falling back to a single aggregate snapshot for wifi/simulate sources), and the publisher derives a per-node builder (`OwnedDiscoveryBuilder::for_node`) that publishes discovery + availability lazily on first sight of each `node_id` and routes state to per-node topics — yielding N distinct HA devices with per-node availability/LWT. Unit-tested (distinct nodes → distinct `wifi_densepose_<node>` identifiers); 71 MQTT tests pass.
|
||||
|
||||
@@ -22,6 +22,8 @@ Dual codebase: Python v1 (`v1/`) and Rust port (`v2/`).
|
||||
| `nvsim` | Deterministic NV-diamond magnetometer pipeline simulator (ADR-089) — standalone leaf, WASM-ready |
|
||||
| `vendor/rvcsi` (submodule) | **rvCSI** — edge RF sensing runtime (ADR-095/096): 9 crates (`rvcsi-core`/`-dsp`/`-events`/`-adapter-file`/`-adapter-nexmon`/`-ruvector`/`-runtime`/`-node`/`-cli`). Lives in its own repo ([github.com/ruvnet/rvcsi](https://github.com/ruvnet/rvcsi)), vendored here under `vendor/rvcsi`, published to crates.io as `rvcsi-* 0.3.x` and to npm as `@ruv/rvcsi`. Not a `v2/` workspace member — depend on the published crates (or the submodule's `crates/rvcsi-*` paths). Normalized `CsiFrame`/`CsiWindow`/`CsiEvent` schema, validate-before-FFI, reusable DSP, typed confidence-scored events, the napi-c Nexmon shim (real nexmon_csi `.pcap` from a Raspberry Pi 5 / 4 / 3B+ — BCM43455c0), the napi-rs SDK, the `rvcsi` CLI, a Claude Code plugin. |
|
||||
| `ruview-swarm` | Drone swarm control system (ADR-148) — hierarchical-mesh topology, Raft consensus, MARL, CSI sensing payload, MAVLink/PX4 compat, Ruflo AI-agent integration |
|
||||
| `ruview-gamma` | Adaptive Sensory Neuromodulation platform (ADR-250) — governed, deterministic, safety-constrained personal neural-rhythm optimization. 7-program `NeuroProgram` catalog (Alzheimer's research, post-stroke cognition, sleep, attention/WM, mood/arousal, home wellness, trial infrastructure), each with its own safety envelope/prior/objective/state-gating/evidence-level/gated-claim. RuView sensing + RuVector cross-person self-learning (cohort warm-start, drift, clustering) + RuFlo audit. Executable acceptance gate (entrainment/safety/adherence/repeatability) releases a claim only
|
||||
| `ruview-gamma-clinic` | Clinical dashboard + persistent hash-chained RuVector store (ADR-251) — read-only Axum instrumentation over `ruview-gamma`: participant frequency-response maps, session trends with safety-stop markers, cohort clusters, per-program acceptance verdicts carrying only gate-released claims, live chain-integrity badge. Append-only JSONL, tamper-evident `entry_hash = SHA-256(prev ‖ raw record bytes)` (fails closed). `gamma-clinic` binary. Research tooling, not a medical device. |
|
||||
|
||||
### RuvSense Modules (`signal/src/ruvsense/`)
|
||||
| Module | Purpose |
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
# ADR-250: Adaptive Gamma Entrainment Using RuVector and RuView
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Proposed |
|
||||
| **Date** | 2026-06-09 |
|
||||
| **Owner** | RuView, RuVector, RuFlo clinical systems |
|
||||
| **Decision type** | Architecture, safety, research platform |
|
||||
| **Scope** | Personalized noninvasive sensory stimulation and passive state sensing |
|
||||
| **Codebase target** | `v2/crates/ruview-gamma` (this ADR's reference implementation) |
|
||||
|
||||
> **Not medical advice:** This ADR defines a research and engineering
|
||||
> architecture. It does **not** define an approved Alzheimer's treatment. The
|
||||
> immediate product claim is **personalized entrainment optimization**, never
|
||||
> Alzheimer's treatment.
|
||||
|
||||
---
|
||||
|
||||
## 1. Context
|
||||
|
||||
Recent research suggests that noninvasive 40 Hz multisensory stimulation using
|
||||
light and sound can influence gamma neural activity and may activate glymphatic
|
||||
clearance pathways associated with amyloid removal in Alzheimer's mouse models.
|
||||
The strongest mechanistic support comes from a 2024 Nature paper showing that
|
||||
multisensory gamma stimulation promoted cerebrospinal fluid influx, interstitial
|
||||
fluid efflux, aquaporin-4 polarization, meningeal lymphatic dilation, VIP
|
||||
interneuron signaling, and amyloid clearance in 5XFAD mice. Blocking glymphatic
|
||||
clearance abolished the amyloid-clearing effect.
|
||||
|
||||
Human evidence is promising but still early. A 2022 study in mild probable
|
||||
Alzheimer's disease reported that 40 Hz sensory stimulation was feasible and
|
||||
well tolerated, with exploratory signals around brain structure, connectivity,
|
||||
sleep, and memory. A small 2025 long-term pilot reported daily 40 Hz
|
||||
audiovisual stimulation over two years was safe and feasible and may slow
|
||||
cognitive and biomarker progression, but the sample size was very small and not
|
||||
definitive.
|
||||
|
||||
The field mostly treats 40 Hz as a fixed protocol. That is a useful population
|
||||
prior, but individual brains may differ by baseline gamma state, arousal, sleep
|
||||
quality, sensory acuity, medication, age, disease stage, and comfort tolerance.
|
||||
A 2025 PLOS One study re-evaluated gamma stimulation frequency across 36–44 Hz,
|
||||
supporting the idea that frequency choice should be empirically measured rather
|
||||
than assumed.
|
||||
|
||||
## 2. Problem
|
||||
|
||||
Fixed 40 Hz stimulation creates four engineering limits:
|
||||
|
||||
1. It assumes the same frequency works for everyone.
|
||||
2. It does not continuously verify entrainment.
|
||||
3. It ignores physiological state (sleep, motion, breathing, restlessness, comfort).
|
||||
4. It cannot safely optimize stimulation parameters over time.
|
||||
|
||||
For clinical or wellness-grade deployment, the system must answer: *which
|
||||
frequency, modality, intensity, timing, and session structure produces the
|
||||
strongest safe entrainment for this person in this state?*
|
||||
|
||||
## 3. Decision
|
||||
|
||||
We build an **Adaptive Gamma Entrainment Architecture** where 40 Hz is the
|
||||
initial prior, not the hard-coded answer. The system uses:
|
||||
|
||||
1. **RuView** as the passive state-sensing layer.
|
||||
2. **RuVector** as the personal response-modeling layer.
|
||||
3. A **constrained optimizer** to select stimulation parameters.
|
||||
4. **RuFlo** as the governed workflow, audit, safety, and protocol-execution layer.
|
||||
5. **Clinical-mode separation** to prevent unsupported therapeutic claims.
|
||||
|
||||
The system optimizes stimulation **only within a predefined safety envelope**
|
||||
and separates entrainment optimization from disease-outcome claims.
|
||||
|
||||
## 4. Architecture Overview
|
||||
|
||||
```
|
||||
Person baseline → RuView passive sensing → optional EEG → stimulus session
|
||||
→ response extraction → RuVector personal response vector
|
||||
→ constrained optimizer → next best protocol → RuFlo audit + governance
|
||||
```
|
||||
|
||||
| Component | Role | Output |
|
||||
|-----------|------|--------|
|
||||
| RuView | Passive sensing of body and environment | breathing, motion, posture, stillness, sleep state, adherence |
|
||||
| EEG (optional) | Direct entrainment measurement | gamma power, phase locking, artifact score |
|
||||
| Stimulus controller | Light + sound actuator | frequency, intensity, phase, duty cycle, duration |
|
||||
| RuVector | Learns personal response surface | individual entrainment vector |
|
||||
| Optimizer | Selects next safe stimulation setting | recommended protocol |
|
||||
| RuFlo | Governance and audit | protocol record, safety log, reproducibility trail |
|
||||
|
||||
## 5. Stimulus Search Space
|
||||
|
||||
| Parameter | Default range | Notes |
|
||||
|-----------|---------------|-------|
|
||||
| Frequency | 36–44 Hz | published exploratory range |
|
||||
| Starting prior | 40 Hz | strongest preclinical literature |
|
||||
| Modality | audio, visual, combined | combined preferred (GENUS-style) |
|
||||
| Brightness | bounded low–moderate | avoid unsafe flicker intensity |
|
||||
| Volume | bounded low–moderate | comfort-constrained |
|
||||
| Duty cycle | continuous, ramped, pulsed | start conservative |
|
||||
| Phase | synchronized, offset | explore only after baseline |
|
||||
| Duration | short calibration first | longer only after tolerance |
|
||||
| Time of day | morning, evening, quiet wake | state-dependent |
|
||||
|
||||
## 6. Personal Response Vector
|
||||
|
||||
RuVector represents each person with a compact 20-field adaptive vector
|
||||
(`baseline_gamma, baseline_alpha, alpha_gamma_ratio, gamma_power_gain,
|
||||
phase_locking_value, breathing_rate, breathing_stability, motion_artifact,
|
||||
posture_state, sleep_state, restlessness_score, stimulus_frequency,
|
||||
brightness_level, sound_level, duty_cycle, phase_offset, session_duration,
|
||||
comfort_score, adherence_score, adverse_event_flag`), updated after each
|
||||
session: `R_{t+1} = update(R_t, stimulus_t, response_t, safety_t)`.
|
||||
|
||||
## 7. Optimization Objective
|
||||
|
||||
The optimizer maximizes **safe, stable entrainment**, not raw gamma power:
|
||||
|
||||
```
|
||||
score = w1·gamma_power_gain + w2·phase_locking_gain + w3·breathing_stability
|
||||
+ w4·adherence + w5·comfort
|
||||
− w6·motion_artifact − w7·adverse_event_risk − w8·overstimulation_penalty
|
||||
```
|
||||
|
||||
Default weights: gamma 0.30, phase-locking 0.25, comfort 0.15, breathing 0.10,
|
||||
adherence 0.10, motion penalty 0.05; **safety penalty is a hard constraint, not
|
||||
negotiable**.
|
||||
|
||||
## 8. Learning Method (staged loop)
|
||||
|
||||
- **Phase 1 — Conservative calibration:** short sessions at 36–44 Hz (1 Hz steps).
|
||||
- **Phase 2 — Bayesian optimization:** GP surrogate + Expected Improvement,
|
||||
subject to `safety==true ∧ comfort≥threshold ∧ adverse_event_risk≤threshold`.
|
||||
- **Phase 3 — Contextual bandit:** once enough sessions exist, LinUCB over state
|
||||
context → stimulus action → safe-entrainment reward.
|
||||
- **Phase 4 — Closed-loop control:** mid-session, bounded frequency nudges when
|
||||
entrainment drops, intensity reduction on discomfort, scoring pause on motion
|
||||
spikes, and hard terminate-and-lock on adverse events.
|
||||
|
||||
## 9–12. RuView / RuVector / RuFlo roles & Safety
|
||||
|
||||
RuView supplies non-camera passive context (breathing, motion, posture,
|
||||
stillness, restlessness, sleep proxy, interference, adherence). RuVector
|
||||
supplies adaptive memory (personal vector, session-to-session learning,
|
||||
anonymized nearest-neighbor, drift detection, clustering, recommendation, edge
|
||||
inference) and predicts **safe-entrainment / comfort / artifact / adherence
|
||||
likelihood** — not Alzheimer's improvement. RuFlo governs (consent,
|
||||
inclusion/exclusion, scheduling, safety-stop rules, parameter audit trail, ADR
|
||||
linkage, model-version tracking, clinician export, trial-mode separation).
|
||||
Every session is reproducible via
|
||||
`session_hash = hash(protocol_version, model_version, device_version,
|
||||
stimulus_parameters, sensor_summary, response_summary, safety_events)`.
|
||||
|
||||
**Hard-stop conditions:** headache, dizziness, nausea, agitation, visual
|
||||
discomfort, abnormal distress, seizure-like symptoms, user-stop request, sensor
|
||||
confidence below threshold, protocol outside approved envelope.
|
||||
|
||||
**Exclusion / clinical supervision:** epilepsy or seizure history,
|
||||
photosensitivity, severe migraine sensitivity, severe psychiatric instability,
|
||||
implanted neurological devices, significant sensory impairment affecting
|
||||
protocol validity, medication changes affecting neural response. **The system
|
||||
must never autonomously expand beyond the allowed safety envelope.**
|
||||
|
||||
## 18. Acceptance Criteria
|
||||
|
||||
| Criterion | Target |
|
||||
|-----------|--------|
|
||||
| Frequency control precision | ±0.1 Hz |
|
||||
| Session audit completeness | 100% |
|
||||
| Motion artifact detection | ≥90% valid/invalid classification |
|
||||
| Adaptive protocol improvement | ≥20% entrainment gain vs fixed 40 Hz |
|
||||
| Comfort | no worse than fixed 40 Hz |
|
||||
| Safety stops | 100% logged |
|
||||
| Repeatability | same optimal band within ±1 Hz across 3 sessions |
|
||||
| Claim discipline | no disease-treatment claim in product UI |
|
||||
|
||||
## 19. Non-Goals
|
||||
|
||||
This ADR does **not** claim: RuView treats Alzheimer's; RuVector clears amyloid;
|
||||
RF sensing measures amyloid directly; personalized frequency improves clinical
|
||||
outcomes; consumer deployment is safe without screening; 40 Hz is always optimal.
|
||||
|
||||
## 21. Implementation Roadmap → reference crate `ruview-gamma`
|
||||
|
||||
| Milestone | Module(s) in `ruview-gamma` | Status in this ADR's impl |
|
||||
|-----------|------------------------------|---------------------------|
|
||||
| M1 Simulator | `simulator.rs` (deterministic ChaCha20 response surface) | **Implemented** |
|
||||
| M2 Device harness (contract) | `stimulus.rs`, `safety.rs` (envelope + emergency stop) | **Interfaces + safety implemented** |
|
||||
| M3 RuView integration (contract) | `response.rs` (`RuViewState`) | **State contract implemented** |
|
||||
| M4 EEG validation (contract) | `response.rs` (`EegMeasurement`), `objective.rs` | **Optional input implemented** |
|
||||
| M5 Adaptive optimizer | `optimizer.rs` (Phase 1+2), `bandit.rs` (Phase 3), closed-loop | **Implemented** |
|
||||
| M6 Trial mode | `ruflo.rs` (consent, inclusion/exclusion, sham, audit, session hash) | **Implemented** |
|
||||
| §10 RuVector self-learning | `ruvector.rs` (anonymized `ProfileStore`, deterministic kNN, cohort warm-start priors via down-weighted GP pseudo-observations, physiological drift detection, deterministic clustering) | **Implemented** |
|
||||
|
||||
The crate is a **deterministic, dependency-light leaf** (no internal RuView
|
||||
deps, ChaCha20 PRNG, SHA-256 witness — same discipline as `nvsim`), so the
|
||||
optimizer, safety envelope, and RuVector update logic can be tested and replayed
|
||||
bit-exactly before any hardware or human exposure. Hardware actuation, real RF
|
||||
sensing, and real EEG land behind feature flags / external adapters; this crate
|
||||
implements the governed software core and its proofs.
|
||||
|
||||
## 23. Platform Generalization — Adaptive Sensory Neuromodulation
|
||||
|
||||
The broader opportunity is **adaptive sensory neuromodulation**, not just
|
||||
Alzheimer's. 40 Hz is one prior in one program; the engine is a personal
|
||||
neural-rhythm optimization platform. RuView turns the body into the feedback
|
||||
signal, RuVector turns repeated sessions into a personal response map, the
|
||||
device is the actuator, and RuFlo makes the loop governed and auditable.
|
||||
|
||||
Each use case is a `NeuroProgram` (`program.rs`) bundling its own safety
|
||||
envelope, starting prior, objective weighting, physiological-state gating,
|
||||
evidence level, and the single non-disease claim it may surface:
|
||||
|
||||
| Program | Evidence level | RuView / RuVector role | Released claim |
|
||||
|---------|----------------|------------------------|----------------|
|
||||
| `alzheimers-research` | Medium preclinical, early human | Adaptive entrainment + trial monitoring | personalized entrainment optimization |
|
||||
| `post-stroke-cognition` | Early human | Recovery-state tracking (ramped, comfort-weighted) | …with recovery-state monitoring |
|
||||
| `sleep-optimization` | Early but plausible | Time stimulation to sleep state (audio, near-dark cap) | sleep-state-timed entrainment optimization |
|
||||
| `attention-working-memory` | Mixed / protocol-dependent | Personal frequency discovery (entrainment-weighted) | personalized frequency-response discovery |
|
||||
| `mood-arousal` | Early human | Avoid overstimulation, tune calming response | personalized calming-response optimization |
|
||||
| `home-wellness` | Speculative | Safe personalization without treatment claims | personal neural-rhythm wellness optimization |
|
||||
| `trial-infrastructure` | Strong infrastructure | Governed protocol/safety/consent/sham log | governed, reproducible protocol measurement |
|
||||
|
||||
**Claim discipline is structural.** A program's claim is always an
|
||||
optimization/monitoring statement, never a disease-treatment claim; the disease
|
||||
*context* lives only in `EvidenceLevel`. A claim is releasable **only** through
|
||||
the acceptance gate.
|
||||
|
||||
### 23.1 Generalized acceptance gate (`acceptance.rs`)
|
||||
|
||||
> Every use case must show measurable **entrainment**, **safety**,
|
||||
> **adherence**, and **repeatability** before making any disease claim.
|
||||
|
||||
`AcceptanceHarness::evaluate(program, person, state)` runs the program over ≥3
|
||||
independent repeats and measures: adaptive-vs-fixed-prior entrainment gain,
|
||||
safety-stop rate, mean adherence, and the spread of the discovered optimal
|
||||
frequency. The resulting `AcceptanceReport` exposes a `ClaimGate` that returns
|
||||
the program's claim **iff all four criteria pass**, and the research-only
|
||||
`NO_CLAIM` string otherwise — the program's marketing claim cannot be read
|
||||
except through this gate. This makes the acceptance sentence executable, not
|
||||
aspirational, and applies uniformly to all seven programs.
|
||||
|
||||
## 22. Final Decision Statement
|
||||
|
||||
We build Adaptive Gamma Entrainment as a governed RuView + RuVector
|
||||
architecture. The system treats 40 Hz as the evidence-based starting prior, then
|
||||
learns each person's safe entrainment response curve using passive sensing,
|
||||
optional EEG, constrained optimization, and auditable RuFlo workflows. The
|
||||
immediate product claim is **personalized entrainment optimization** — not
|
||||
Alzheimer's treatment. That distinction keeps the system scientifically
|
||||
credible, clinically safer, and commercially defensible.
|
||||
|
||||
## Safety hardening (2026-06-11 review)
|
||||
|
||||
A safety review of the `ruview-gamma` reference crate found five places where
|
||||
safety depended on *default values* rather than *construction*. The following
|
||||
invariants now hold by construction; the reference crate enforces and tests each.
|
||||
|
||||
1. **Safety stops are control-flow events (Finding 1).** `run_session` still
|
||||
returns the witnessed record on a stop, but `run_calibration` (and any
|
||||
multi-session loop) inspects the record's safety outcome and **terminates the
|
||||
sweep** on any stop — a stop in step *N* can no longer let steps *N+1…*
|
||||
proceed. The partial calibration is preserved in the audit log.
|
||||
|
||||
2. **Latched, persisted governor lock (Finding 2, ADR-250 §8 terminate-and-lock).**
|
||||
An adverse-event / seizure-like / distress stop engages a **latched lock** on
|
||||
the participant's `ParticipantSafetyState`, which is serialized into the
|
||||
session store. A *new* governor instance for the same participant also
|
||||
refuses (`Err(ParticipantLocked)`) until `unlock_with_acknowledgment(operator_note)`
|
||||
is called, which itself writes an audit record. Lock-class mapping: seizure-like
|
||||
→ `SeizureLike`, abnormal distress → `Distress`, other medical adverse events
|
||||
→ `AdverseEvent`; user-stop / low-sensor-confidence / out-of-envelope stops do
|
||||
not latch a cross-session lock (retryable / pre-empted, not adverse).
|
||||
|
||||
3. **Compiled-in absolute envelope bounds (Finding 3).** `SafetyEnvelope` fields
|
||||
are now private, reachable only through a validated `try_new` / `with_*`
|
||||
constructor and `#[serde(try_from)]`, so no config — however hostile — can be
|
||||
built outside the absolute bounds: frequency floor **≥ 30 Hz** (keeps the whole
|
||||
envelope above the 15–25 Hz photosensitive provocative band with margin),
|
||||
ceiling **≤ 60 Hz**, brightness/volume cap **≤ 0.6**, max session duration
|
||||
**≤ 30 min**. This mirrors the firmware's compiled-in stance: deserialization
|
||||
of an 18–22 Hz / brightness-1.0 / 10⁶-minute envelope fails closed.
|
||||
|
||||
4. **Daily-dose cap + inter-session cooldown (Finding 4).** The governor enforces
|
||||
**≤ 4 sittings per rolling 24 h** and a **≥ 60 min** inter-sitting cooldown from
|
||||
the persisted ledger; violations return typed errors and are enforced across
|
||||
governor instances. A calibration sweep delivered at one timestamp is **one
|
||||
sitting / one dose unit** (sub-sessions do not trip the inter-step cooldown),
|
||||
and calibration sittings are counted toward the cap — not a backdoor around it.
|
||||
|
||||
5. **Per-tick monitor wired into the session loop (Finding 5).** `run_session`
|
||||
now evaluates the latched `SafetyMonitor` over **every tick** of the simulated
|
||||
session; a mid-session latch truncates the session at that tick (the recorded
|
||||
delivered stimulus duration is reduced to the completed fraction). A clean
|
||||
session is byte-identical to the prior single-summary path, so the pinned
|
||||
deterministic proof witness is unchanged.
|
||||
|
||||
*Minor:* `seed_from_cohort` enforces a privacy k-floor (**≥ 3 distinct cohort
|
||||
profiles**) before consuming cohort priors.
|
||||
@@ -0,0 +1,91 @@
|
||||
# ADR-251: Clinical Dashboard + Persistent RuVector Store for Adaptive Gamma
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Proposed |
|
||||
| **Date** | 2026-06-11 |
|
||||
| **Owner** | RuView, RuVector, RuFlo clinical systems |
|
||||
| **Decision type** | Architecture, clinical tooling |
|
||||
| **Relates to** | ADR-250 (Adaptive Gamma Entrainment — platform, programs, acceptance gate) |
|
||||
| **Codebase target** | `v2/crates/ruview-gamma-clinic` (this ADR's implementation) |
|
||||
|
||||
> **Not a medical device.** Read-only research/clinical *instrumentation* over
|
||||
> the ADR-250 platform. It surfaces only gated claims and witnessed records; it
|
||||
> can neither start stimulation nor widen any safety envelope.
|
||||
|
||||
## 1. Context
|
||||
|
||||
ADR-250 shipped the governed adaptive-neuromodulation engine: per-program
|
||||
safety envelopes, the RuVector self-learning layer (anonymized profiles, kNN
|
||||
warm-start, drift detection, clustering), witnessed session records, and the
|
||||
executable acceptance gate. Two operational gaps remain for clinical use:
|
||||
|
||||
1. **No durable store.** `ProfileStore` and the governor's audit log are
|
||||
in-memory; a clinic restart loses cohort memory, and there is no
|
||||
tamper-evident persistence matching the platform's proof discipline.
|
||||
2. **No clinician surface.** RuFlo's clinician export exists as a struct
|
||||
(`ClinicianReport`), but there is no way for a clinician/trial monitor to
|
||||
*see* a participant's frequency-response curve, session trend, safety
|
||||
events, drift status, or a program's acceptance verdict.
|
||||
|
||||
## 2. Decision
|
||||
|
||||
Build `ruview-gamma-clinic`, a separate crate (keeping `ruview-gamma` a
|
||||
dependency-light deterministic leaf) with two components:
|
||||
|
||||
### 2.1 Persistent RuVector store (`store.rs`)
|
||||
|
||||
Append-only JSON-lines file per clinic, holding three record kinds —
|
||||
anonymized profiles, witnessed session summaries, and acceptance reports —
|
||||
each line **hash-chained** (`entry_hash = SHA-256(prev_hash ‖ canonical_json)`)
|
||||
so any retroactive edit breaks the chain (`verify_chain()`); the RuVector
|
||||
in-memory layer (`ProfileStore` kNN, clustering) is rebuilt from the file at
|
||||
open. Pseudonymity is preserved: records carry only the one-way profile tags
|
||||
from ADR-250 §10.
|
||||
|
||||
### 2.2 Read-only clinical dashboard (`server.rs` + embedded `dashboard.html`)
|
||||
|
||||
Axum surface, **strictly read-only** (no POST mutates stimulation state):
|
||||
|
||||
| Route | Payload |
|
||||
|-------|---------|
|
||||
| `GET /` | embedded single-file HTML dashboard (no build step; SVG charts) |
|
||||
| `GET /api/clinic/participants` | tag, session count, mean entrainment, safety stops, adverse flag, drift status |
|
||||
| `GET /api/clinic/participants/{tag}` | response vector, frequency→score curve, session trend |
|
||||
| `GET /api/clinic/cohort` | deterministic k-means clusters over the stored profiles |
|
||||
| `GET /api/clinic/acceptance` | per-program acceptance reports with the **gated** claim |
|
||||
| `GET /api/clinic/integrity` | hash-chain verification result + record count |
|
||||
|
||||
**Claim discipline is inherited, not re-implemented:** acceptance payloads
|
||||
embed `AcceptanceReport::released_claim` (the gate's output), never the
|
||||
program's raw claim. The dashboard renders what the gate released — nothing
|
||||
stronger.
|
||||
|
||||
### 2.3 Visualization (embedded, dependency-free)
|
||||
|
||||
One static HTML file (`include_str!`) with vanilla JS + inline SVG:
|
||||
participant list → per-participant frequency-response curve (the personal
|
||||
response map), entrainment/comfort session trend, safety-event markers, cohort
|
||||
cluster table, and an integrity badge (green only when `verify_chain` passes).
|
||||
No JS framework, no CDN, no build step — auditable by reading one file.
|
||||
|
||||
## 3. Consequences
|
||||
|
||||
- Clinic restarts no longer lose cohort memory; warm-start works across runs.
|
||||
- Tampering with stored records is detectable by one endpoint call.
|
||||
- A clinician can inspect a trial without shell access; the surface cannot
|
||||
actuate anything.
|
||||
- The store is JSONL, not a database server: at research-cohort scale this is
|
||||
deliberate (greppable, diffable, witness-friendly). An HNSW/ruvector-crate
|
||||
backend remains the drop-in path past ~10⁵ profiles (ADR-250 §10).
|
||||
|
||||
## 4. Acceptance criteria (tested)
|
||||
|
||||
| Criterion | Test |
|
||||
|-----------|------|
|
||||
| Store round-trips all three record kinds across reopen | `store::tests` |
|
||||
| Hash chain detects any line edit/deletion/reorder | `tampered_chain_is_detected` |
|
||||
| kNN warm-start works from a reloaded store | `knn_survives_reload` |
|
||||
| Every API route serves and is read-only | `server::tests` (oneshot) |
|
||||
| Acceptance payload carries only the gated claim | `acceptance_payload_uses_gated_claim` |
|
||||
| Dashboard HTML embeds and serves | `dashboard_html_served` |
|
||||
@@ -0,0 +1,165 @@
|
||||
# RuView System Review — Capability Audit (Beyond-SOTA Series, Doc 00)
|
||||
|
||||
**Date:** 2026-06-09
|
||||
**Scope:** The RuView product surface (ADR-031) and the 38-crate Rust workspace under `v2/crates/` that implements it, plus the ADR corpus (`docs/adr/`, 150 numbered ADRs) and the prior research corpus (`docs/research/sota-2026-05-22/`).
|
||||
**Method:** Direct reads of `lib.rs`/`mod.rs` and key ADRs; static test counts via `grep -r '#[test]'` / `#[tokio::test]` per crate (counts are *static occurrences in source*, not CI pass counts). No metrics in this document are estimated — everything cited was read or measured in the working tree.
|
||||
|
||||
---
|
||||
|
||||
## 1. Executive Summary — What RuView IS Today
|
||||
|
||||
RuView is **not a crate**. Per ADR-136 §2.1 (`docs/adr/ADR-136-ruview-streaming-engine-frame-contracts.md`), RuView is the sensing-first *product surface and brand* (ADR-031, status: Proposed) layered on the existing `wifi-densepose-*` / `homecore*` / `cog-*` workspace. ADR-136 explicitly **rejects** a `ruview_*` crate rename and pins a normative ten-role mapping (ingest / signal / fusion / world / models / privacy / store / api / eval / observe) onto the existing crates.
|
||||
|
||||
What concretely exists:
|
||||
|
||||
1. **A deep, heavily-tested signal-processing layer.** `wifi-densepose-signal` contains 473 static `#[test]` occurrences, including a 22-file `ruvsense/` bounded context (`v2/crates/wifi-densepose-signal/src/ruvsense/`) implementing the ADR-029 six-stage multistatic pipeline plus ADR-030/032a/134/135/137/138/142/143 extensions (~14,000 lines, 330 in-module tests measured by per-file grep).
|
||||
2. **A trust-traceable composition root.** `wifi-densepose-engine` (`src/lib.rs`, 752 lines, 11 tests) wires fusion quality (ADR-137), array coordination (ADR-138), evolution change-points (ADR-142), RF-SLAM anchors (ADR-143), the WorldGraph (ADR-139), and the BFLD privacy control plane (ADR-141) into one `StreamingEngine::process_cycle` (`lib.rs:285`) that emits a `TrustedOutput` (`lib.rs:80`) carrying evidence + model version + calibration version + privacy decision + a BLAKE3 witness (`lib.rs:437`).
|
||||
3. **A privacy layer with structural invariants.** `wifi-densepose-bfld` (20 modules, 369 tests) implements ADR-118–123/141: raw BFI never exits the node (I1), identity embeddings are RAM-only (I2), cross-site identity correlation is cryptographically impossible (I3) — stated at `wifi-densepose-bfld/src/lib.rs:7-11`.
|
||||
4. **A Home-Assistant-class world/state layer.** `homecore` + 9 sibling crates (state machine, event bus, plugins, automation, REST/WS API, recorder, HAP bridge, assist) — explicitly a "P1 scaffold" per `homecore/src/lib.rs:7` with deferred items listed at `lib.rs:24-31`.
|
||||
5. **A drone-swarm extension.** `ruview-swarm` (17 modules, ~9,000 lines in subdirectories, 115 + 19 async tests), ADR-148 self-reports ~98% complete with the remaining 15% of M3 gated on real ESP32-S3 hardware (`ADR-148:940-953`).
|
||||
6. **A large prior research corpus.** The 2026-05-22 autonomous SOTA loop: 41 ticks, 19 research threads (R1–R20), 22 numpy reference implementations, 7 ADRs, and a 6-tier production roadmap (`docs/research/sota-2026-05-22/00-summary.md`, `PRODUCTION-ROADMAP.md`).
|
||||
|
||||
The critical caveat, stated by the project itself: the ADR-136–146 series is *"a skeleton and nervous system, not a shipping product… Most of the series is not yet wired into the live 20 Hz pipeline"* (ADR-136 §8). The engine crate's own docs confirm what is absent: *"the live 20 Hz I/O loop (sensing-server), UWB hardware (ADR-144), and model training (ADR-146)"* (`wifi-densepose-engine/src/lib.rs:27-29`).
|
||||
|
||||
---
|
||||
|
||||
## 2. Capability Matrix — Pipeline Role → Crates → Maturity
|
||||
|
||||
Role mapping is normative per ADR-136 §2.1; maturity is this review's judgment from code + ADR status. Test counts: static `#[test]` + `#[tokio::test]` greps (2026-06-09).
|
||||
|
||||
| Role | Crate(s) | Key modules | Tests (sync+async) | Maturity | Evidence |
|
||||
|---|---|---|---|---|---|
|
||||
| **ingest** | `wifi-densepose-sensing-server`, `wifi-densepose-hardware`, `wifi-densepose-wifiscan` | `csi.rs`, `multistatic_bridge.rs`, `tracker_bridge.rs`, ESP32 TDM | 557+14, 137, 150 | **Production** (hardware-validated per ADR-028/039) | `sensing-server/src/` has 30+ modules incl. MQTT, Matter, RVF pipeline |
|
||||
| **signal** | `wifi-densepose-signal` (incl. `ruvsense/`) | 6-stage pipeline (`ruvsense/mod.rs:9-23`), `cir.rs`, `calibration.rs`, `hampel.rs`, `fresnel.rs`, `phase_sanitizer.rs` | 473 | **Production** (unit level); live multistatic wiring **beta** | §3 below; ADR-014 Accepted, ADR-029 Proposed |
|
||||
| **fusion** | `ruvsense/multistatic.rs`, `ruvsense/fusion_quality.rs`, `wifi-densepose-ruvector/src/viewpoint/` | `MultistaticFuser`, `QualityScore`, `CrossViewpointAttention`, GDI/Cramér-Rao (`viewpoint/geometry.rs`) | 20 (multistatic.rs), 3 (fusion_quality.rs), 136 (ruvector crate) | **Beta** — tested building blocks, composed only in `wifi-densepose-engine` tests | `viewpoint/mod.rs:1-30`; engine `lib.rs:317-319` |
|
||||
| **world** | `homecore`, `wifi-densepose-worldgraph`, `wifi-densepose-geo`, `wifi-densepose-worldmodel` | `StateMachine`, `EventBus`, `WorldGraph` (rooms/sensors/person-tracks/semantic states), ENU geo registration | 9+11, 7, 16+1, 12+1 | **Beta** — homecore is explicit "P1 scaffold"; persistence/service dispatch deferred to P2 | `homecore/src/lib.rs:7, 24-31`; ADR-127 Proposed |
|
||||
| **models** | `cog-pose-estimation`, `cog-person-count`, `wifi-densepose-nn`, `wifi-densepose-train`, `wifi-densepose-occworld-candle` | ONNX/Candle inference, training pipeline, OccWorld bridge | 7, 15, 30+1, 312, 12 | **Experimental** — no trained RF foundation encoder exists; ADR-147 benchmarked OccWorld with **random weights** | `ADR-147-benchmark-proof.md` ("random weights — pre-domain-fine-tuning baseline"); ADR-146/150 Proposed |
|
||||
| **privacy** | `wifi-densepose-bfld` | `privacy_gate.rs`, `privacy_mode.rs` (mode registry + hash-chained attestation), `identity_risk.rs`, `signature_hasher.rs`, `embedding_ring.rs` | 369 | **Beta** — strongest-tested layer, but lib header still says "Status: P1 in progress" (`lib.rs:12`, stale vs 20 implemented modules) | ADR-118–123, 141 all Proposed |
|
||||
| **store** | `homecore-recorder` | trajectory/event recording | 8+12 | **Experimental** | ADR-136 §2.1 |
|
||||
| **api** | `homecore-api`, `homecore-server`, `cog-ha-matter`, `homecore-hap` | REST/WS, HA discovery, Matter, HomeKit | 7+11, 0, 63+1, 15+2 | **Experimental→Beta** (`homecore-server` has zero tests) | ADR-130/125/115 Proposed |
|
||||
| **eval** | `wifi-densepose-train/src/ablation.rs`, `ruview-swarm/src/evals/` | ablation harness (ADR-145), swarm eval suite (ADR-149) | included in 312 / 115 | **Experimental** — ADR-145 self-labels "skeleton/scaffolding, mostly not yet on the live 20 Hz path" | `ablation.rs` exists; ADR-149 (swarm benchmarking) Accepted |
|
||||
| **observe** | `homecore-automation`, `homecore-assist` | automation engine, assistant/Ruflo bridge | 20+14, 3+20 | **Experimental** | ADR-129/133 Proposed |
|
||||
| **(integration root)** | `wifi-densepose-engine` | `StreamingEngine`, `TrustedOutput`, privacy demotion, witness | 11 | **Beta** — the only crate that proves cross-role composition; not on a live I/O path | `engine/src/lib.rs:1-29, 457-751` |
|
||||
| **(swarm)** | `ruview-swarm` | Raft/gossip topology, RRT-APF planning, Candle PPO MARL, CSI sensing payload, failsafe, Ruflo | 115+19 | **Experimental/simulation** — M3 needs real ESP32-S3 hardware | ADR-148:940-953 ("Overall ~98%", M3 85%) |
|
||||
| **(adjacent)** | `nvsim`, `nvsim-server`, `ruv-neural`, `wifi-densepose-wasm-edge`, `wifi-densepose-mat`, `wifi-densepose-vitals` | NV-diamond sim, neural lib, WASM edge, MAT disaster tool, vitals | 50, 0, 364, 643, 165+9, 52 | Mixed — `mat`/`vitals`/`wasm-edge` mature unit-wise | crate listing |
|
||||
|
||||
**Workspace totals (measured):** 3,890 `#[test]` + 121 `#[tokio::test]` static occurrences across `v2/crates/`. (CLAUDE.md's "1,031+ tests" figure refers to an earlier `cargo test --workspace` run count; this review did not execute the suite.)
|
||||
|
||||
External vendored runtimes also present: `vendor/rvcsi` (ADR-095/096 edge RF runtime, own repo), `vendor/ruvector`, `vendor/midstream`, `vendor/sublinear-time-solver`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Signal-Processing Capability Inventory — `ruvsense/`
|
||||
|
||||
Location: `v2/crates/wifi-densepose-signal/src/ruvsense/`. CLAUDE.md says "16 modules"; the directory now contains **22 `.rs` files** (21 modules + `mod.rs`) — the table below is the ground truth. Lines/tests measured per file (2026-06-09).
|
||||
|
||||
| Module | Lines | Tests | ADR | What it does |
|
||||
|---|---:|---:|---|---|
|
||||
| `mod.rs` | 510 | 14 | 029 | Pipeline shell, COCO-17 keypoint constants, `RuvSensePipeline` (concrete fields + `tick()`), re-exports |
|
||||
| `multiband.rs` | 442 | 14 | 029 | Channel-hopping CSI → wideband virtual snapshot per node (`MultiBandCsiFrame`) |
|
||||
| `phase_align.rs` | 460 | 13 | 029 | LO phase-offset estimation via circular mean + `ruvector-solver::NeumannSolver` |
|
||||
| `multistatic.rs` | 957 | 20 | 029 | Attention-weighted N-node fusion → `FusedSensingFrame`; timestamp-spread guards |
|
||||
| `coherence.rs` | 474 | 19 | 029 | Per-subcarrier z-score coherence vs rolling template; `DriftProfile` |
|
||||
| `coherence_gate.rs` | 380 | 17 | 029 | Accept / PredictOnly / Reject / Recalibrate gate decisions |
|
||||
| `pose_tracker.rs` | 1,577 | 38 | 029/026/082 | 17-keypoint Kalman tracker, lifecycle state machine, AETHER re-ID embeddings, skeleton constraints, temporal keypoint attention |
|
||||
| `field_model.rs` | 1,417 | 22 | 030 | SVD room eigenstructure (persistent field model), perturbation extraction |
|
||||
| `tomography.rs` | 751 | 12 | 030 | RF tomography, ISTA L1 voxel solver |
|
||||
| `longitudinal.rs` | 1,020 | 20 | 030 | Welford long-horizon stats, biomechanics drift detection |
|
||||
| `intention.rs` | 511 | 12 | 030 | Pre-movement lead signals (200–500 ms) |
|
||||
| `cross_room.rs` | 626 | 13 | 030 | Environment fingerprinting + room-transition graph |
|
||||
| `gesture.rs` | 579 | 14 | 030 | DTW template-matching gesture classifier |
|
||||
| `adversarial.rs` | 586 | 13 | 030/032 | Physically-impossible-signal detection, multi-link consistency |
|
||||
| `attractor_drift.rs` | 566 | 15 | 032a | Midstream-enhanced attractor drift detection |
|
||||
| `temporal_gesture.rs` | 540 | 15 | 032a | Midstream temporal gesture stream |
|
||||
| `cir.rs` | 1,025 | 10 | 134 | CSI→CIR via ISTA L1 sparse recovery, NeumannSolver warm-start, `Complex32` sub-DFT Φ |
|
||||
| `calibration.rs` | 717 | 8 | 135 | Empty-room baseline (Welford amplitude + von Mises phase), drift-triggered recalibration |
|
||||
| `fusion_quality.rs` | 188 | 3 | 137 | `QualityScore` with `EvidenceRef`s, `ContradictionFlag`s, `CalibrationId`, privacy-demotion predicate |
|
||||
| `array_coordinator.rs` | 343 | 5 | 138 | Clock-quality gating + `DirectionalEvidence` (geometric admission) |
|
||||
| `evolution.rs` | 406 | 7 | 142 | Cross-link change-point detection, Bayesian `TemporalVoxelMap` (privacy-gated) |
|
||||
| `rf_slam.rs` | 301 | 6 | 143 | Persistent reflector discovery → static anchor learning (Wall/Furniture/Mobile classes) |
|
||||
|
||||
Subtotal: ~14,400 lines, 310 tests inside `ruvsense/` alone. The non-ruvsense signal layer adds Hampel filtering, CSI-ratio, phase sanitisation, Fresnel modeling, BVP, spectrograms, subcarrier selection, and hardware normalisation (`signal/src/*.rs`).
|
||||
|
||||
**Cross-viewpoint fusion** (`wifi-densepose-ruvector/src/viewpoint/`, 5 files): scaled dot-product attention with geometric bias (`attention.rs`), Geometric Diversity Index + Cramér-Rao bounds (`geometry.rs`), phase-phasor coherence with hysteresis + clock-quality gate (`coherence.rs`), and the `MultistaticArray` aggregate root (`fusion.rs`). 136 tests crate-wide.
|
||||
|
||||
---
|
||||
|
||||
## 4. The Trust Chain — What Actually Composes Today
|
||||
|
||||
`wifi-densepose-engine/src/lib.rs` is the proof-of-composition. One `process_cycle` (`lib.rs:285-368`):
|
||||
|
||||
1. ADR-138 array coordination (only if every node's geometry is registered, `lib.rs:372-389`)
|
||||
2. ADR-137 `fuse_scored_calibrated` with **per-node calibration epochs** — mismatching `CalibrationId`s raise a contradiction (`lib.rs:304-319`)
|
||||
3. ADR-142 change-point → WorldGraph `Event` node (`lib.rs:393-430`)
|
||||
4. ADR-141 monotonic privacy demotion on any contradiction (`demote_one`, `lib.rs:452-455`)
|
||||
5. ADR-139/140 `SemanticState` with mandatory provenance (evidence ‖ model ‖ calibration ‖ privacy decision) (`lib.rs:336-352`)
|
||||
6. BLAKE3 witness over the trust decision (`witness_of`, `lib.rs:437-448`)
|
||||
|
||||
The 11 engine tests verify exactly the right invariants: full provenance flow (`cycle_carries_full_provenance`, `lib.rs:487`), contradiction→demotion (`lib.rs:517`), determinism (`lib.rs:535`), calibration-mismatch→Restricted+stable-witness (`lib.rs:648`), privacy-mode attestation chain (`lib.rs:741`), and persist→reload round-trip with **no raw RF in the snapshot** (`live_frame_to_reload_same_contents`, `lib.rs:696-736`).
|
||||
|
||||
This is genuinely strong design. But all inputs are synthetic `MultiBandCsiFrame`s constructed in the test module; no ingest crate calls `StreamingEngine` yet.
|
||||
|
||||
---
|
||||
|
||||
## 5. Strengths
|
||||
|
||||
1. **Deterministic witness chain, end to end in design.** ADR-028 proof (`archive/v1/data/proof/verify.py` + SHA-256), ADR-119 BLAKE3 frame witnesses (`bfld/src/signature_hasher.rs`), ADR-136 `CanonicalFrame`/`ComplexSample` LE contracts, and the engine's per-cycle trust witness form a coherent auditability story few sensing systems attempt.
|
||||
2. **Privacy as a control plane, not a feature.** BFLD's three structural invariants (`bfld/src/lib.rs:7-11`), hash-rotation (ADR-120), identity-risk scoring (ADR-121), mode registry with hash-chained attestations, and *monotonic* demotion wired to fusion contradictions (engine `lib.rs:327-328`) — uncertainty automatically reduces information release.
|
||||
3. **Multistatic fusion with physics-grounded quality.** Attention fusion + GDI + Cramér-Rao bounds + clock-quality gating means geometry and synchronisation deficits are first-class, measurable contradiction sources rather than silent failure modes.
|
||||
4. **Test density at the unit level.** 3,890 static test functions; the signal core (473), BFLD (369), and sensing-server (571) are the deepest. ruvsense files average ~14 tests/module.
|
||||
5. **Honest self-assessment culture.** ADR-136 §8's "skeleton, not a shipping product" framing, ADR-147's explicit "random weights" disclosure, and homecore's in-source TODO-P2 ledger (`homecore/src/lib.rs:24-31`) make the gap analysis below mostly a matter of reading what the project already admits.
|
||||
6. **A real prior research base with negative results.** The sota-2026-05-22 loop catalogued negatives by resolution path (missing-tool / architecture-error / physics-floor) and produced a ship-recipe (N=5 chest-centric placement, 100% coverage for 1–4 occupants) consolidated into ADR-113.
|
||||
7. **Hardware path exists and was audited.** ADR-028 (Accepted) and ADR-039 (Accepted, hardware-validated) anchor the ESP32-S3/C6 ingest tier; firmware release process includes real-CSI verification on COM ports.
|
||||
|
||||
---
|
||||
|
||||
## 6. Honest Gap Analysis — ADR vs Implemented vs Integrated
|
||||
|
||||
| Capability | ADR status | Code status | Integrated on live path? |
|
||||
|---|---|---|---|
|
||||
| Six-stage ruvsense pipeline | ADR-029 **Proposed** | Implemented + tested (310 tests) | Partially — sensing-server has `multistatic_bridge.rs`/`tracker_bridge.rs`, but `RuvSensePipeline` still holds concrete fields with `tick()` only (`mod.rs`); no uniform `Stage<I,O>` chain runs live |
|
||||
| Frame contracts (`ComplexSample`, provenance fields, `Stage` traits) | ADR-136 Proposed | Built + 9 acceptance tests (per ADR-136 §8, commit `11f89727f`) | **No** — AC6 600-frame replay witness key and AC7 cross-arch CI matrix not done; provenance fields not populated by live calibration/model stages |
|
||||
| Fusion quality / contradictions | ADR-137 Proposed | `fusion_quality.rs` (188 lines, 3 tests) + engine wiring | Engine-tests only |
|
||||
| WorldGraph digital twin | ADR-139 Proposed | `wifi-densepose-worldgraph` (4 files, 7 tests) | Engine-tests only; no recorder-backed persistence loop |
|
||||
| Privacy control plane | ADR-141 Proposed | `privacy_mode.rs` registry + attestation chain, tested | Engine-tests only; MQTT/HA exposure exists in BFLD but the *engine→BFLD sink* live path is unwired |
|
||||
| UWB range fusion | ADR-144 Proposed | **No hardware, no crate** — acknowledged absent (`engine/src/lib.rs:28`) | No |
|
||||
| Ablation/leakage eval harness | ADR-145 Proposed | `wifi-densepose-train/src/ablation.rs` exists | Self-labelled "skeleton/scaffolding" (ADR-145 §status) |
|
||||
| RF encoder multi-task heads | ADR-146 Proposed | Not trained; `model_id`/`model_version` registry unowned | No — engine stamps `rfenc-v1` as a placeholder string (`lib.rs:338`) |
|
||||
| RF foundation encoder | ADR-150 **Proposed** | ADR only | No |
|
||||
| World-model forecasting (OccWorld) | ADR-147 (benchmark doc) | Runs on RTX 5080, 72.39M params — **random weights**, no domain checkpoint | No |
|
||||
| HomeCore HA port | ADR-125–133 all Proposed | P1 scaffold + siblings; `homecore-server` has **0 tests**; persistence, service mpsc dispatch, device registry, witness integration all deferred (`homecore/src/lib.rs:24-31`) | Partially (API surfaces exist) |
|
||||
| BFLD capture path (Nexmon/ESP32 BFI) | ADR-123 Proposed | rvCSI vendored runtime exists for nexmon `.pcap`; BFI-specific capture unverified in this review | Unclear |
|
||||
| Drone swarm | ADR-148 In Progress | 17 modules, sim + Candle PPO complete per milestones | **Simulation only** — M3's 15% requires physical ESP32-S3 CSI capture (ADR-148:946) |
|
||||
| Federation / DP-SGD / PQC | ADR-105–109 Proposed | `ruview-fed` crate **does not exist** (roadmap Tier 2 item) | No |
|
||||
| Antenna-placement CLI (`plan-antennas`) | ADR-113 Proposed; Roadmap Tier 1.1 HIGH | numpy references only; not found as a Rust CLI subcommand | No |
|
||||
|
||||
**Pattern:** the unit layer is real and deep; the *integration* layer is one crate (`wifi-densepose-engine`) exercised solely by its own synthetic tests; the *model* layer (anything learned: RF encoder, pose model fine-tuned on CSI, OccWorld domain weights) is the emptiest tier. Nearly every ADR ≥118 carries status **Proposed** even where substantial tested code exists — ADR status hygiene lags implementation in both directions (BFLD code outruns its "P1 in progress" header; ADR-148's "98%" outruns its hardware evidence).
|
||||
|
||||
---
|
||||
|
||||
## 7. Risk Register
|
||||
|
||||
| # | Risk | Likelihood | Impact | Evidence / Notes |
|
||||
|---|---|---|---|---|
|
||||
| R1 | **Integration gap**: trust chain proven only against synthetic in-test frames; live 20 Hz ingest→engine→BFLD-sink path unwired, so the headline guarantee (auditable provenance on every emission) is unverified in production conditions | High | Critical | `engine/src/lib.rs:27-29`; ADR-136 §8 |
|
||||
| R2 | **No trained model**: every learned component (RF encoder ADR-146/150, OccWorld ADR-147) is random-weight or absent; sensing claims beyond coherence/occupancy heuristics cannot ship | High | Critical | ADR-147 "random weights"; ADR-146/150 Proposed |
|
||||
| R3 | **Synthetic-validation bias**: ruvsense/engine/swarm tests and the sota-loop results (e.g., R3 "100% (synthetic)", ADR-113 placement numbers) are simulation-derived; real-room domain gap unquantified | High | High | `00-summary.md:45`; PRODUCTION-ROADMAP 2.3 ("turns synthetic numbers into validated numbers") |
|
||||
| R4 | **Witness chain incomplete at frame level**: `CsiFrame.data` is still `serde(skip)` (ADR-136 Gap 2); AC6 replay-witness key and AC7 cross-architecture matrix not landed — deterministic replay is a design, not a property | Medium | High | ADR-136 §1.1, §8 |
|
||||
| R5 | **Float nondeterminism in fusion** across thread counts could silently break the witness/replay contract once wired | Medium | High | ADR-136 §3.3 risk table (project's own assessment) |
|
||||
| R6 | **Privacy bypass via unwired paths**: BFLD invariants are enforced per-module, but until the engine is the *only* route from ingest to API, a sensing-server endpoint can emit ungated state (sensing-server already has 30+ modules incl. pose/vitals APIs predating the control plane) | Medium | Critical | `sensing-server/src/` module list vs engine isolation |
|
||||
| R7 | **Hardware dependence + scale**: multistatic TDMA/channel-hopping timing validated on small ESP32 sets; ADR-148 M3 explicitly blocked on real hardware; clock-quality model in engine uses a hardcoded `ClockQualityScore` (`engine/src/lib.rs:384`) | Medium | High | ADR-148:946; hardcoded 50 µs stdev |
|
||||
| R8 | **ADR/doc/status drift**: 150 ADRs with near-universal "Proposed" status, stale in-source status headers (`bfld/src/lib.rs:12`), CLAUDE.md "16 ruvsense modules" vs 22 on disk, duplicate ADR numbers (two ADR-050s, two ADR-147s, two ADR-149s, ADR-052 ×2) — institutional-memory value degrades | High | Medium | `ls docs/adr/`; this review §3 |
|
||||
| R9 | **Workspace breadth vs maintenance capacity**: 38 workspace crates + 4 vendored subtrees + Python archive + firmware; several crates have 0 tests (`homecore-server`, `nvsim-server`, `wifi-densepose-wasm`, `homecore-plugin-example`); bus factor appears to be ~1 | High | Medium | crate test-count table §2 |
|
||||
| R10 | **Eval debt**: no end-to-end accuracy benchmark on real CSI with ground truth exists in-repo (ADR-145 harness is scaffolding; ADR-079 camera ground truth not exercised here) — "beyond SOTA" claims are currently unfalsifiable | High | High | ADR-145 status note; absence of ground-truth datasets in tree |
|
||||
|
||||
---
|
||||
|
||||
## 8. Measurement Appendix
|
||||
|
||||
- Test counts: `grep -r '#[test]'` / `#[tokio::test]` per crate directory, 2026-06-09. Workspace totals: 3,890 / 121. Top crates: `wasm-edge` 643, `sensing-server` 557+14, `signal` 473, `bfld` 369, `ruv-neural` 364, `train` 312, `mat` 165+9, `wifiscan` 150, `hardware` 137, `ruvector` 136, `ruview-swarm` 115+19.
|
||||
- ruvsense per-file lines/tests: `wc -l` + per-file `grep -c '#[test]'` (table in §3).
|
||||
- Crate inventory: `ls v2/crates/` → 38 directories.
|
||||
- ADR inventory: `ls docs/adr/` → 150 numbered files (with the duplicate numbers noted in R8); `docs/adr/README.md` self-reports "45 ADRs" (stale).
|
||||
- Caveats: static `#[test]` counts include `#[cfg(feature = ...)]`-gated and ignored tests; they are an upper bound on what `cargo test --workspace --no-default-features` runs. No cargo build/test was executed for this review.
|
||||
|
||||
*Next in series: 01+ documents should target the R1/R2/R10 axis — wiring the live path, training the RF encoder, and standing up a falsifiable real-CSI benchmark — before any "beyond SOTA" claim is made.*
|
||||
@@ -0,0 +1,191 @@
|
||||
# SOTA Landscape 2026 — The Bar a Beyond-SOTA RuView Must Clear
|
||||
|
||||
**Series**: ruview-beyond-sota (01)
|
||||
**Date**: 2026-06-09
|
||||
**Status**: Research survey / target definition
|
||||
**Builds on (does not duplicate)**: `docs/research/sota-2026-05-22/00-summary.md` (physics floors, placement, privacy chain), `docs/research/BFLD/01-sota-survey.md` (beamforming-feedback leakage SOTA), `docs/research/neural-decoding/21-sota-neural-decoding-landscape.md` (sensor-fidelity framing), `docs/research/rf-topological-sensing/00-rf-topological-sensing-index.md` (mincut/topology resolution limits), ADR-150 (RF foundation encoder + measured MM-Fi campaign), ADR-147 (OccWorld benchmark proof).
|
||||
|
||||
## 0. Evidence legend
|
||||
|
||||
Every claim in this document carries one of three tags. **No RuView benchmark number in this document is invented**; all RuView numbers come from repo-internal measured artifacts.
|
||||
|
||||
| Tag | Meaning |
|
||||
|-----|---------|
|
||||
| **[V]** | Verified in this session via web search (June 2026); source linked in §8 |
|
||||
| **[K]** | Training-knowledge claim (pre-2026 literature); plausible but **not re-verified** — treat as needing citation check before external publication |
|
||||
| **[I]** | Internal RuView measurement or artifact (ADR, issue, witness bundle) — measured, not literature |
|
||||
|
||||
---
|
||||
|
||||
## 1. SOTA reference table per capability axis
|
||||
|
||||
### 1.1 Pose estimation (WiFi CSI)
|
||||
|
||||
| Method | Year | Metric | Dataset / protocol | Tag |
|
||||
|--------|------|--------|--------------------|-----|
|
||||
| DensePose From WiFi (Geng, Huang, De la Torre) | 2023 | Dense-pose UV regions from CSI, "comparable to image-based approaches" (same-layout); commonly cited AP≈43.5 / AP@50≈87.2 | 3×3 antenna, single-layout lab | exact AP numbers **[K]**; paper existence **[V]** (arXiv 2301.00250) |
|
||||
| MetaFi++ (Zhou et al.) | 2023 | PCK@50 = **97.30%** same-domain real-world (MetaFi: 95.23%); drops to **81.7–86.5%** under stricter protocols | Own capture; protocol-sensitive | **[V]** |
|
||||
| Person-in-WiFi 3D (CVPR 2024) | 2024 | End-to-end multi-person 3D; 20.4 M params, **54 FPS**; MPJPE ≈ 90–100 mm on own dataset | Own multi-person dataset | FPS/params **[V]**; MPJPE range **[K]** |
|
||||
| GraphPose-Fi (arXiv 2511.19105) | 2025 | SOTA on MM-Fi random split: **MPJPE 160.6 mm**, best PCK at all thresholds | MM-Fi, random split (S1) | **[V]** |
|
||||
| CSDS (Electronics 14(4):756) | 2025 | Wi-Pose: PCK@5 = **0.6407**, PCK@50 = **0.8824** | Wi-Pose | **[V]** |
|
||||
| PerceptAlign (arXiv 2601.12252) | 2026 | Cross-layout 3D: MPJPE **222.4 mm** (Scene 4) / **317.1 mm** (Scene 5), >54% better than prior cross-layout SOTA; in easier settings MPJPE 181.5 mm, PCK@20/50 = 44.2/79.5 | Cross-layout protocol | **[V]** |
|
||||
| WiFlow (arXiv 2602.08661) | 2026 | Lightweight continuous HPE, spatio-temporal decoupling | — | **[V]** (existence; numbers not extracted) |
|
||||
| **RuView / AetherArena** | 2026 | **81.63% torso-PCK@20 in-domain (random split), beating MultiFormer's 72.25%** on metric/protocol-matched MM-Fi; **leakage-free cross-subject collapses to ~11.6% torso-PCK zero-shot**; official-split harness baseline ~63–65% PCK@20; **11 KB LoRA few-shot calibration → 72.5%** | MM-Fi (issue #876, ADR-150 §3) | **[I]** |
|
||||
|
||||
**The honest reading of the pose axis**: same-domain WiFi pose is "solved-looking" (PCK@50 in the 90s) and meaningless for deployment. The 2025–2026 literature has shifted to cross-layout/cross-subject protocols, where numbers collapse (PerceptAlign PCK@20 = 44.2 cross-layout **[V]**; RuView cross-subject zero-shot 11.6% **[I]**). ADR-150's measured finding — that the cross-subject gap is **subject-distribution shift, not an algorithmic gap**, and that **few-shot in-room calibration (5–200 frames) closes it** — is ahead of where the published literature is: no published WiFi-pose paper we found ships a per-room ~11 KB adapter calibration mechanism. **[I]**
|
||||
|
||||
### 1.2 Presence / person count
|
||||
|
||||
| Method | Year | Metric | Tag |
|
||||
|--------|------|--------|-----|
|
||||
| Large-scale commodity router deployment (>10 M routers) | 2025 | **92.6% motion-detection accuracy** across diverse homes | **[V]** (ISAC survey, arXiv 2510.14358) |
|
||||
| LeakyBeam (NDSS 2025) | 2025 | Occupancy through walls at 20 m from **plaintext BFI alone**: TPR 82.7%, TNR 96.7% | **[V]** (also in BFLD survey §4.2) |
|
||||
| Time-Selective RNN multi-room presence (arXiv 2304.13107) | 2023 | Device-free multi-room presence from CSI | **[V]** (existence) |
|
||||
| Academic person counting (0–5 occupants, lab) | 2020–2024 | typically 90–97% exact-count accuracy, degrading sharply >5 people | **[K]** |
|
||||
| **RuView** | 2026 | `cog-person-count` ships with calibrated uncertainty (`count_p95_low/high`); multistatic placement recipe with **100% coverage for 1–4 occupants at N=5 nodes (synthetic physics)** | **[I]** (sota-2026-05-22 R6.2.5, ADR-113) |
|
||||
|
||||
### 1.3 Vital signs (HR / BR)
|
||||
|
||||
| Method | Year | Metric | Tag |
|
||||
|--------|------|--------|-----|
|
||||
| PhaseBeat (ACM Health) | 2020 | HR median error **1.19 bpm**; BR median error **0.25 breaths/min** | **[V]** |
|
||||
| MDPI Sensors 24(7):2111 non-contact HR | 2024 | HR accuracy 96.8%, **median error 0.8 bpm** | **[V]** |
|
||||
| PulseFi (arXiv 2510.24744) | 2025 | Low-cost ML cardiopulmonary + **apnea** monitoring from CSI | **[V]** (existence; numbers not extracted) |
|
||||
| mmWave FMCW vitals (60 GHz class) | 2023–2026 | HR MAE typically 1–3 bpm at 1–3 m, single subject; age-balanced reference dataset published (Sci Data 2026) | dataset **[V]**; MAE range **[K]** |
|
||||
| Contactless blood pressure (WiFi-band) | — | **NEGATIVE** — below classical physics floor; recoverable only via quantum magnetometry path | **[I]** (R13/R20 arc, ADR-114) |
|
||||
| **RuView** | 2026 | `wifi-densepose-vitals` (ADR-021) extracts HR/BR from ESP32 CSI; chest-centric placement gives **+27 pp coverage** for vitals cogs (synthetic) | **[I]** — **no accuracy-vs-ECG validation number exists in-repo yet; do not claim one** |
|
||||
|
||||
**Bar**: published single-subject, line-of-sight, 1–3 m WiFi HR is ~0.8–1.2 bpm median error **[V]**. Nobody credibly publishes multi-person, through-wall, walking-subject HR at that accuracy — that is open territory.
|
||||
|
||||
### 1.4 Localization (ToA / CRLB)
|
||||
|
||||
| Method | Year | Metric | Tag |
|
||||
|--------|------|--------|-----|
|
||||
| 802.11mc FTM | shipped | 1–2 m typical accuracy | **[V]** (FTM survey, arXiv 2509.03901) |
|
||||
| 802.11az (+ 802.11bk) | released | **sub-1 m**, 160 MHz channels, secured ranging, HE-LTF repetitions | **[V]** |
|
||||
| AI single-link decimeter localization | 2025 | **0.63 m average error** single-link, beating Widar2.0 / Dynamic-MUSIC | **[V]** |
|
||||
| SpotFi / Chronos / Widar lineage | 2015–2021 | 0.4–1 m with multi-AP CSI AoA/ToF | **[K]** |
|
||||
| **RuView** | 2026 | CRLB / Fisher-information machinery in `ruvector/src/viewpoint/geometry.rs`; tomography ISTA voxel grid; **theoretical** limits derived internally: 30–60 cm at 16 nodes/1 m spacing, 8.8 cm information-theoretic dense limit | **[I]** (rf-topological-sensing doc 09 — synthetic derivations, no bench numbers) |
|
||||
|
||||
### 1.5 Through-wall
|
||||
|
||||
| Method | Year | Metric | Tag |
|
||||
|--------|------|--------|-----|
|
||||
| RF-Pose / RF-Pose3D (MIT, FMCW 5.4–7.2 GHz) | 2018 | Through-wall skeletal pose, ~specialized radar not commodity WiFi | **[K]** |
|
||||
| Commodity 2.4 GHz through-wall imaging (arXiv 1903.03895) | 2019 | Coarse imaging through walls with commodity WiFi | **[V]** (existence) |
|
||||
| Radio tomographic imaging (RTI) lineage | 2010–2013 | Through-wall tracking via RSS networks, ~0.5–1 m tracking error | **[V]** (papers) / error figure **[K]** |
|
||||
| LeakyBeam (NDSS 2025) | 2025 | Through-wall **occupancy** at 20 m, passive, commodity | **[V]** |
|
||||
| **RuView** | 2026 | RF tomography module (`tomography.rs`, ISTA L1 voxel solver) + CIR (ADR-134) exist as code; **PABS structure detection: 1,161× static / 9.36× dynamic intruder lift (synthetic)** | **[I]** |
|
||||
|
||||
Notably, the 2025–2026 web literature shows through-wall *pose* (not just presence) on commodity WiFi remains essentially where it was in 2019 — no verified commodity-WiFi through-wall pose benchmark surfaced in our searches. The frontier moved to privacy attacks (BFI) instead.
|
||||
|
||||
### 1.6 Identity / re-ID (capability and threat simultaneously)
|
||||
|
||||
| Method | Year | Metric | Tag |
|
||||
|--------|------|--------|-----|
|
||||
| BFId (KIT, ACM CCS 2025) | 2025 | **~99.5% (near-100%) re-ID across 197 subjects** from beamforming feedback alone, ≥5 s of BFI | **[V]** (also BFLD survey §4.1) |
|
||||
| Transformer CSI identification | 2025 | **99.82%** on stationary subjects | **[V]** |
|
||||
| WhoFi (arXiv 2507.12869) | 2025 | Deep person re-ID via WiFi channel encoding, ~95% rank-1 class results | existence **[V]**; exact number **[K]** |
|
||||
| Wi-Gait | 2023 | 92.9% over 10 subjects, robust to walking cofactors | **[V]** |
|
||||
| **RuView** | 2026 | AETHER contrastive re-ID embeddings (ADR-024) in pose tracker; **BFLD**: first *defensive* identity-leak detector (identity_risk_score) — the literature attacks, RuView audits | **[I]** |
|
||||
|
||||
### 1.7 Adjacent modality: mmWave radar (the accuracy ceiling WiFi is chasing)
|
||||
|
||||
| Method | Year | Metric | Tag |
|
||||
|--------|------|--------|-----|
|
||||
| mmChainPose | 2025 | **27.0 mm MPJPE** / 0.8706 OKS on MARS (mmWave point cloud) | **[V]** |
|
||||
| ProbRadarM3F (arXiv 2405.05164) | 2024–25 | SOTA AP across joints, probability-map fusion | **[V]** |
|
||||
| Seeed MR60BHA2-class 60 GHz FMCW | shipped | Commodity $15 HR/BR/presence module — already in RuView's hardware table | **[I]** |
|
||||
|
||||
mmWave is ~6× better than the best WiFi MPJPE (27 mm vs 160 mm) **[V]**. The strategic implication: WiFi will not beat mmWave on raw geometry; it wins on ubiquity, cost, through-wall propagation, and standardized waveforms (§2). RuView already hedges with the ESP32-C6 + MR60BHA2 fusion node. **[I]**
|
||||
|
||||
---
|
||||
|
||||
## 2. IEEE 802.11bf — status and implications
|
||||
|
||||
**Status (verified)**: IEEE **802.11bf-2025 is ratified and published** (IEEE SA lists the amendment; ratification late 2024 / publication 2025) **[V]**. It amends MAC/PHY of HE (Wi-Fi 6) and EHT (Wi-Fi 7) plus DMG/EDMG (60 GHz) to support WLAN sensing in 1–7.125 GHz and >45 GHz bands **[V]**. The Wi-Fi Alliance has Wi-Fi Sensing as an active certification work area built on 802.11bf (presence/proximity, gestures, vital signs) **[V]**. Market reports claim >47 chipset vendors with 802.11bf-compatible programs as of early 2026 — single weak source, treat as directional **[V, low confidence]**.
|
||||
|
||||
**What it implies for RuView**:
|
||||
|
||||
1. **Sounding-on-demand becomes standard.** 802.11bf defines a sensing-measurement procedure (sensing initiator/responder, trigger-based sounding, threshold-based reporting). Today RuView relies on Espressif's vendor CSI API and Nexmon firmware patches; post-bf, commodity Wi-Fi 7 silicon will expose scheduled sensing measurements without firmware hacks. The rvCSI normalized `CsiFrame` schema is the right abstraction layer to absorb a future bf adapter (`rvcsi-adapter-*`). **[I]**
|
||||
2. **The moat moves up the stack.** When every router can sense, raw CSI access stops being differentiating. Differentiators become: multistatic fusion, coherence gating / anti-hallucination, calibration mechanisms, witness-grade verification, and privacy auditing — exactly RuView's existing bets (ADR-029/135/150/028, BFLD). **[I]**
|
||||
3. **Privacy pressure intensifies.** 802.11bf standardizes the capability that BFId/LeakyBeam exploit. BFLD's identity-leak detection and the ADR-105–109 privacy/PQC chain become regulatory assets, not nice-to-haves. **[V]+[I]**
|
||||
4. **Threshold-based reporting** in bf (report only when channel changes exceed threshold) is architecturally the same idea as RuView's coherence gate — validation that the gate belongs at the protocol layer. **[K]** (bf reporting detail from training knowledge)
|
||||
|
||||
---
|
||||
|
||||
## 3. RF foundation model landscape ("GPT for RF")
|
||||
|
||||
Verified 2025–2026 attempts, all young, none dominant:
|
||||
|
||||
| Model | Approach | Downstream tasks | Tag |
|
||||
|-------|----------|------------------|-----|
|
||||
| **LWM (Large Wireless Model)** | Pretrained on large-scale CSI → general channel embeddings | LoS/NLoS, beats raw features in low-data regimes | **[V]** |
|
||||
| **LatentWave** (arXiv 2606.06373) | JEPA pretraining on wireless spectrograms + CSI | RF classification, 5G NR positioning, beam prediction, LoS/NLoS | **[V]** |
|
||||
| **WirelessJEPA** (arXiv 2601.20190) | Multi-antenna spatio-temporal latent prediction | Cross-task transfer | **[V]** |
|
||||
| **IQFM** | Contrastive SSL on raw I/Q | Modulation classification, beam prediction, RF fingerprinting, few-shot | **[V]** |
|
||||
| **Multimodal Wireless FMs** (arXiv 2511.15162), **WMFM** (arXiv 2512.23897), **SoM** (arXiv 2506.07647) | Vision + RF multimodal for 6G ISAC | Sensing-communication integration | **[V]** |
|
||||
| **DeepSig OmniSIG** | Commercial AI-native RF sensing, 500 MHz/GPU spectrum | Signal ID (LTE/5G/Wi-Fi) | **[V]** |
|
||||
|
||||
**Critical observation**: every verified RF foundation model targets *communication-side* tasks (beam prediction, LoS/NLoS, modulation, positioning). **None of them is a human-sensing foundation model** — none pretrains for pose/vitals/identity invariances. ADR-150's measured negative result is the sharpest data point in this space: pose-contrastive pretraining across subjects **failed on MM-Fi because the invariance is not in the data** (loss never left the ln(B) floor) **[I]**. The literature has not yet published this failure mode; the field's "GPT for RF sensing" narrative is ahead of its evidence. The defensible foundation-model objective (per ADR-150 §3.5–3.6) is **reduce few-shot calibration cost**, not zero-shot invariance. **[I]**
|
||||
|
||||
---
|
||||
|
||||
## 4. "Beyond SOTA" for RuView — precise definition
|
||||
|
||||
Targets below are **bar definitions**, not claims. RuView numbers in the "current" column are measured [I]; targets must be proven via the AetherArena witness protocol (ADR-149) before being asserted anywhere.
|
||||
|
||||
| Capability | Published SOTA (2026) | RuView measured today | RuView beyond-SOTA target | Key obstacle |
|
||||
|------------|----------------------|----------------------|---------------------------|--------------|
|
||||
| Pose, in-domain (MM-Fi) | GraphPose-Fi 160.6 mm MPJPE; MultiFormer 72.25% torso-PCK@20 **[V]** | **81.63% torso-PCK@20** (already > published) **[I]** | Hold #1 under leakage-free audit + per-joint tables published with witness rows | Protocol fragmentation; reviewers distrust WiFi-pose numbers |
|
||||
| Pose, cross-subject zero-shot | ~collapse everywhere; PerceptAlign PCK@20 44.2 cross-layout **[V]** | 11.6% torso zero-shot; 63–65% in-harness official split **[I]** | Stop chasing it (measured dead end); instead **few-shot frontier** below | Subject-distribution shift is in the data, not the model (ADR-150 §3.2) |
|
||||
| Pose, deployment calibration | **No published per-room adapter mechanism found** | **11 KB LoRA, 100–200 frames → 72.5%; cross-env K=5 → 60.1%** **[I]** | ≤20 frames → ≥70% PCK@20, adapter ≤11 KB, 30 s on-site; publish as the first calibration-service benchmark | Needs diverse-room capture fleet to validate beyond MM-Fi |
|
||||
| Presence/motion (commodity) | 92.6% across 10 M routers **[V]** | Synthetic placement recipe 100% coverage N=5 **[I]** | ≥99% presence with calibrated p95 bounds on $6–15 ESP32 mesh, bench-validated | All placement numbers are synthetic; Tier-2.3 bench validation outstanding |
|
||||
| Person count | ~90–97% lab, ≤5 people **[K]** | cog ships uncertainty intervals **[I]** | Exact count 1–6 people ≥95% with honest intervals, multistatic, real bench | Multi-person CSI superposition; no public multi-occupancy benchmark |
|
||||
| Vital signs HR | 0.8–1.2 bpm median, single subject, LoS, 1–3 m **[V]** | No in-repo ECG-validated number — **must not be claimed** | ≤1.5 bpm MAE vs ECG ground truth, *multi-person or through-wall*, witness-bundled | R13 physics floor: ~5 dB shortfall at distance; needs chest-centric placement + PABS |
|
||||
| Vital signs BP | NEGATIVE at WiFi band (matches internal R13) | nvsim quantum path only **[I]** | First validated quantum-classical fused bedside vitals (ADR-114) | NV-diamond hardware maturity, 2028+ |
|
||||
| Localization | 0.63 m single-link AI; sub-1 m 802.11az **[V]** | CRLB machinery, no bench number **[I]** | ≤30 cm multistatic on ESP32 mesh (internal theory says feasible at N=16) | ESP32 clock sync / phase offset (TDM protocol exists, unproven at this accuracy) |
|
||||
| Through-wall | Occupancy yes (LeakyBeam); commodity pose: nothing credible **[V]** | tomography + CIR code, PABS 9.36× lift (synthetic) **[I]** | First witnessed commodity-WiFi through-wall *person localization* (not pose) ≤1 m | Wall attenuation eats the R6.1 4.7 dB multi-scatterer budget |
|
||||
| Identity / re-ID | ~99.5% @ 197 subjects (attack) **[V]** | AETHER + **BFLD defensive auditing** (no published competitor) **[I]** | Ship the first identity-leak risk score with DP budget hook; keep re-ID opt-in only | Calibrating risk score at 802.11ax 4/2-bit quantization (BFLD open Q2) |
|
||||
| Verification | **Nothing comparable published** — no WiFi-sensing paper ships deterministic re-verification | ADR-028 witness bundles, SHA-256 proof, 7/7 self-verify, 1,031+ tests **[I]** | Make witness-grade reproduction the *expected* standard: every public claim = one-command verification | Community adoption, not technology |
|
||||
| Foundation encoder | Comms-task FMs only (LWM/JEPA family) **[V]** | Masked-CSI + coherence head planned; pose-contrastive refuted **[I]** | First *sensing* FM whose acceptance metric is calibration-sample reduction (frames-to-72% halved) | SSL must match production CSI pipeline (ADR-149 resampling risk) |
|
||||
|
||||
---
|
||||
|
||||
## 5. Where RuView already matches/exceeds published work
|
||||
|
||||
1. **In-domain MM-Fi pose** — 81.63% torso-PCK@20 vs MultiFormer 72.25%, metric- and protocol-matched (issue #876). **[I]**
|
||||
2. **Deployment-calibration mechanism** — the 11 KB LoRA per-room adapter with measured frames-to-accuracy curves (§3.4–3.6 of ADR-150) has no published equivalent; the literature is still arguing about zero-shot generalization that ADR-150 measured to be a data property.
|
||||
3. **Deterministic witness verification** — ADR-028's SHA-256 pipeline proof + self-verifying bundles exceeds the reproducibility practice of every WiFi-sensing paper surveyed (none ship deterministic re-verification).
|
||||
4. **Multistatic cost point** — $6–15/node ESP32 mesh with TDM sync, channel hopping, placement recipes (ADR-113) vs literature setups using Intel 5300/AX210 laptops or USRPs; ~$30/bed vs $3,000 clinical monitor framing (R16).
|
||||
5. **Defensive identity auditing (BFLD)** — the field publishes attacks (BFId, LeakyBeam, WhoFi); RuView is building the only detector/auditor, plus a PQC-hardened federation privacy chain (ADR-105–109) with no published counterpart.
|
||||
6. **Anti-hallucination coherence gating** — confidence gated by RF integrity (ADR-135, ADR-150 §2.4); WiFi-pose papers uniformly lack a "the model knows when the channel is bad" signal.
|
||||
7. **Negative-result discipline** — physics floors (R13 BP, R6.1 4.7 dB), refuted pose-contrastive pretraining — published SOTA papers do not report these, which inflates the apparent literature bar.
|
||||
|
||||
## 6. Where RuView lags
|
||||
|
||||
1. **Bench validation** — nearly all multistatic/placement/tomography numbers are synthetic-physics; the 92.6%-on-10M-routers deployment **[V]** is real-world evidence at a scale RuView cannot approach.
|
||||
2. **Vital-sign ground truth** — no in-repo ECG/respiration-belt validated HR/BR error; published work has 0.8 bpm median **[V]**. This is the most urgent claim gap.
|
||||
3. **Raw geometric accuracy** — mmWave (27 mm MPJPE **[V]**) and even best-WiFi MPJPE (160.6 mm **[V]**) have no RuView MPJPE counterpart published; AetherArena reports PCK only.
|
||||
4. **802.11bf-native capture** — RuView is on vendor CSI APIs and Nexmon patches; no bf sensing-procedure adapter exists yet in rvCSI.
|
||||
5. **Multi-person pose** — Person-in-WiFi-3D does end-to-end multi-person at 54 FPS **[V]**; RuView's pose path is effectively single-person (multi-person exists only in count/placement work).
|
||||
6. **Dataset scale and diversity** — MM-Fi only; ADR-150 §3.3 shows the binding constraint is room/device/protocol diversity, which requires the capture fleet that doesn't exist yet.
|
||||
|
||||
## 7. Strategic synthesis
|
||||
|
||||
The 2026 bar is bimodal: **lab in-domain numbers are saturated** (PCK@50 > 95%, HR < 1 bpm) and **deployment numbers are collapsed** (cross-layout PCK@20 ≈ 44, zero-shot cross-subject ≈ 11%). 802.11bf-2025 commoditizes raw sensing; foundation models commoditize comms-side embeddings. "Beyond SOTA" for RuView is therefore *not* a leaderboard delta — it is owning the three layers the field hasn't built: **(a)** witnessed, deterministic, leakage-audited evaluation; **(b)** the few-shot calibration service (11 KB adapters) as the deployment answer the zero-shot literature lacks; **(c)** the privacy/integrity layer (BFLD + coherence gate) that 802.11bf-era regulation will demand. Each row in §4's target table is gated on the AetherArena witness protocol — a target becomes a claim only when it ships with a one-command reproduction.
|
||||
|
||||
---
|
||||
|
||||
## 8. Verified sources (accessed 2026-06-09 via web search)
|
||||
|
||||
Pose: [GraphPose-Fi](https://arxiv.org/html/2511.19105v1) · [PerceptAlign / cross-layout](https://arxiv.org/html/2601.12252) · [CSDS](https://www.mdpi.com/2079-9292/14/4/756) · [Person-in-WiFi 3D](https://aiotgroup.github.io/Person-in-WiFi-3D/) · [DensePose From WiFi](https://arxiv.org/abs/2301.00250) · [MetaFi++](https://www.researchgate.net/publication/369644995_MetaFi_WiFi-Enabled_Transformer-based_Human_Pose_Estimation_for_Metaverse_Avatar_Simulation) · [WiFlow](https://arxiv.org/html/2602.08661v2)
|
||||
Vitals: [PhaseBeat](https://dl.acm.org/doi/abs/10.1145/3377165) · [Non-contact HR (Sensors 24:2111)](https://www.mdpi.com/1424-8220/24/7/2111) · [PulseFi](https://arxiv.org/pdf/2510.24744) · [mmWave vitals dataset (Sci Data)](https://www.nature.com/articles/s41597-026-07172-9)
|
||||
Localization: [FTM survey 802.11mc/az/bk](https://arxiv.org/abs/2509.03901) · [Decimeter single-link](https://www.ncbi.nlm.nih.gov/pmc/articles/PMC12846125/) · [SelfLoc 802.11az](https://www.mdpi.com/2079-9292/14/13/2675)
|
||||
802.11bf: [IEEE SA 802.11bf-2025](https://standards.ieee.org/ieee/802.11bf/11574/) · [TGbf](https://www.ieee802.org/11/Reports/tgbf_update.htm) · [NIST overview](https://www.nist.gov/publications/ieee-80211bf-enabling-widespread-adoption-wi-fi-sensing) · [Wi-Fi Alliance work areas](https://www.wi-fi.org/current-work-areas) · [ISAC survey (10M-router 92.6%)](https://arxiv.org/pdf/2510.14358)
|
||||
Identity: [BFId / KIT CCS 2025 coverage](https://www.gblock.app/articles/wifi-signal-person-identification-surveillance-study-may-2026) · [WhoFi](https://arxiv.org/html/2507.12869v1) · [Wi-Gait](https://www.sciencedirect.com/science/article/abs/pii/S1389128623001962) · [LeakyBeam NDSS 2025](https://www.ndss-symposium.org/ndss-paper/lend-me-your-beam-privacy-implications-of-plaintext-beamforming-feedback-in-wifi/)
|
||||
Through-wall: [RTI through-wall](https://ieeexplore.ieee.org/document/6214374/) · [Commodity 2.4 GHz imaging](https://arxiv.org/pdf/1903.03895) · [Multi-room presence](https://arxiv.org/pdf/2304.13107)
|
||||
Foundation models: [LatentWave](https://arxiv.org/html/2606.06373) · [WirelessJEPA](https://arxiv.org/pdf/2601.20190) · [Multimodal Wireless FMs](https://arxiv.org/pdf/2511.15162) · [WMFM](https://arxiv.org/html/2512.23897) · [SoM](https://arxiv.org/pdf/2506.07647) · [RF-native AI / LWM, IQFM, OmniSIG](https://aicompetence.org/rf-native-ai-models-for-the-invisible-spectrum/)
|
||||
mmWave: [mmChainPose](https://www.sciencedirect.com/science/article/abs/pii/S0925231225026918) · [ProbRadarM3F](https://arxiv.org/html/2405.05164v3)
|
||||
|
||||
Internal [I] sources: ADR-150 (§1, §3.2–3.6), ADR-147, ADR-028, ADR-113/114, issue #876, `docs/research/sota-2026-05-22/00-summary.md`, `docs/research/BFLD/01-sota-survey.md`, `docs/research/rf-topological-sensing/`.
|
||||
@@ -0,0 +1,282 @@
|
||||
# RuView Beyond-SOTA Target Architecture
|
||||
|
||||
**Series:** ruview-beyond-sota (02)
|
||||
**Date:** 2026-06-09
|
||||
**Status:** Research design — components marked **PROPOSED** do not exist yet; everything else cites real code.
|
||||
**Governing constraint:** ADR-136 §2.1 explicitly rejects renaming/rewriting the workspace. This document designs an **evolution** of the existing 38-crate `v2/` workspace (`v2/Cargo.toml`), not a new system. Every beyond-SOTA layer attaches to the ADR-136 `Stage<I,O>` / `FrameMeta` / `CanonicalFrame` contracts (`docs/adr/ADR-136-ruview-streaming-engine-frame-contracts.md` §2.2–2.5) and preserves the ADR-028 witness chain.
|
||||
|
||||
---
|
||||
|
||||
## 1. Where the system is today (grounding)
|
||||
|
||||
The ADR-136 ten-role pipeline (ingest → signal → fusion → world → models → privacy → store → api → eval → observe) is already mapped 1:1 onto existing crates (ADR-136 §2.1, normative table). The composition root exists: `v2/crates/wifi-densepose-engine/src/lib.rs` wires ADR-135..146 blocks into one `StreamingEngine::process_cycle` that emits a `TrustedOutput` carrying fusion `QualityScore`, privacy class, `SemanticProvenance`, RF-SLAM (`RfSlam` field), and a BLAKE3 `witness: [u8; 32]`.
|
||||
|
||||
Key existing substrate this design builds on:
|
||||
|
||||
| Substrate | Path | What it gives us |
|
||||
|---|---|---|
|
||||
| Frame contracts + witness | `v2/crates/wifi-densepose-core/src/types.rs` (`CsiFrame`, `CsiMetadata` + `calibration_id`/`model_id`/`model_version`), ADR-136 `ComplexSample`/`CanonicalFrame` | Deterministic LE bytes, BLAKE3 witness, provenance-append-only boundary rule |
|
||||
| Six-stage signal pipeline | `v2/crates/wifi-densepose-signal/src/ruvsense/mod.rs` (+22 modules incl. `cir.rs`, `calibration.rs`, `tomography.rs`, `rf_slam.rs`, `fusion_quality.rs`, `array_coordinator.rs`) | CSI→CIR, baseline calibration, multistatic fusion, coherence gating |
|
||||
| Fusion quality + evidence | ADR-137; `ruvsense/multistatic.rs`, `ruvsense/fusion_quality.rs`, `wifi-densepose-ruvector/src/viewpoint/fusion.rs` | `QualityScore` with `EvidenceRef`/`ContradictionFlag`, privacy demotion on contradiction |
|
||||
| Digital twin | `v2/crates/wifi-densepose-worldgraph/src/lib.rs` (typed `StableDiGraph`, mandatory `SemanticProvenance`) | Persistent room/sensor/track/belief graph |
|
||||
| World model bridge | `v2/crates/wifi-densepose-worldmodel/src/lib.rs` (`OccWorldBridge`, `TrajectoryPrior`, ADR-147) | Occupancy prediction priors into the Kalman tracker |
|
||||
| NN + training | `v2/crates/wifi-densepose-train/src/{model.rs,rapid_adapt.rs,ablation.rs,proof.rs,eval.rs,ruview_metrics.rs}`, `wifi-densepose-nn` | Shared backbone + 2 heads, `AdaptationLoss::ContrastiveTTT`, ADR-145 ablation matrix, seeded proof harness |
|
||||
| Swarm | `v2/crates/ruview-swarm/src/` (`sensing/{multiview.rs,payload.rs,occworld_bridge.rs}`, `marl/`, `topology.rs`) | Raft/hierarchical-mesh drone coordination with CSI payload (ADR-148) |
|
||||
| Edge WASM | `v2/crates/wifi-densepose-wasm-edge/src/lib.rs` (WASM3 on ESP32-S3, `on_frame` host ABI), `wifi-densepose-wasm` | Hot-loadable on-device sensing modules |
|
||||
| Quantum-adjacent sim | `v2/crates/nvsim/src/lib.rs` (deterministic NV-magnetometry forward pipeline, SHA-256 witness, WASM-ready) | Honest classical-quantum hybrid substrate (ADR-089) |
|
||||
| Semantic record + agents | ADR-140 (`wifi-densepose-sensing-server/src/semantic/`), `homecore-assist` | Provenance-bearing semantic states, Ruflo agent bridge |
|
||||
|
||||
---
|
||||
|
||||
## 2. Target architecture diagram
|
||||
|
||||
The beyond-SOTA layers (★ = new/PROPOSED, ☆ = exists-but-not-wired) wrap the ADR-136 pipeline; nothing replaces it.
|
||||
|
||||
```
|
||||
╔═══════════════════ BEYOND-SOTA CONTROL PLANE ═══════════════════╗
|
||||
║ P6 Continual adaptation loop (TTT + EWC★) P5 Swarm aperture ║
|
||||
║ rapid_adapt.rs → encoder LoRA deltas planner★ (Raft) ║
|
||||
╚════════════▲══════════════════════▲══════════════▲══════════════╝
|
||||
│ adaptation deltas │ quality │ tasking
|
||||
[ingest] [signal] │ [fusion] │ [world] │ [models]
|
||||
ESP32/Pi mesh ─► RuvSensePipeline ──────┴──► fuse_scored ──────┴─► WorldGraph ┴──► RF Foundation
|
||||
+ drone payload multiband→phase_align (ADR-137 (ADR-139 │ Encoder (P1)
|
||||
(ruview-swarm →calibration(135) QualityScore, twin) ◄───────┘ 7 heads + UQ
|
||||
sensing/payload) →cir(134)→multistatic EvidenceRef, ▲ │ (ADR-146/150)
|
||||
│ →coherence→gate Contradiction) │ ▼ │
|
||||
│ │ │ RF-SLAM(143)──OccWorld │
|
||||
▼ ▼ │ rf_slam.rs worldmodel ▼
|
||||
P7 WASM edge P2 Differentiable RF │ (P3 closed loop ☆) P4 cross-modal
|
||||
inference★ forward model★ │ distilled student★
|
||||
(wasm-edge, (tomography.rs + │ (camera-free deploy)
|
||||
deterministic cir.rs ISTA as seed) │
|
||||
replay) │ residuals feed fusion as EvidenceRef★
|
||||
│ ▼
|
||||
│ P8 NV-magnetometry fusion★ (nvsim forward model as a sensing node class)
|
||||
▼
|
||||
─────────────────────── ADR-136 CONTRACT SPINE (unchanged) ───────────────────────────────────
|
||||
CsiFrame{ComplexSample, FrameMeta{calibration_id, model_id, model_version}} → Stage<I,O>
|
||||
→ CanonicalFrame::witness_hash() at EVERY stage boundary (BLAKE3, LE-deterministic)
|
||||
───────────────────────────────────────────────────────────────────────────────────────────────
|
||||
│ │ │ │
|
||||
[privacy] [store] [api] [eval] [observe]
|
||||
wifi-densepose-bfld homecore-recorder homecore-api ADR-145 ablation homecore-
|
||||
gate + demotion + replay corpus★ /HA/Matter/HAP (train/ablation.rs automation,
|
||||
(ADR-141) + P1-P8 variants) Ruflo (ADR-140)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. The eight pillars
|
||||
|
||||
Each pillar: what / why beyond-SOTA / builds-on / contract sketch / feasibility. All trait sketches are **PROPOSED** unless a path is cited.
|
||||
|
||||
### P1 — RF Foundation Encoder with multitask uncertainty heads (ADR-146 + ADR-150)
|
||||
|
||||
**What.** One shared, self-supervised RF encoder (`wifi-densepose-nn`) with seven typed heads (pose, presence, count, activity, vitals, gait, identity-embedding), each emitting calibrated uncertainty via the ADR-136 `QualityScored` trait, trained with the ADR-150 pose-contrastive objective (same-pose-across-subjects = positive) plus a coherence head that exposes channel instability.
|
||||
|
||||
**Why beyond SOTA.** Published WiFi-pose systems (MultiFormer, GraphPose-Fi lineage) report in-domain accuracy and hallucinate under domain shift. ADR-150 documents the real measured frontier: 81.63% torso-PCK@20 in-domain on MM-Fi vs ~11.6% leakage-free cross-subject, and that DANN and bigger capacity both failed (ADR-150 §1). A foundation encoder whose loss stack explicitly separates pose / identity / room / device factors *and* emits an RF-integrity signal per prediction is not in the published literature as a deployed, auditable artifact. Target (not a claim): close the cross-subject gap materially while every head output carries `confidence_bounds()`.
|
||||
|
||||
**Builds on.** `v2/crates/wifi-densepose-train/src/model.rs` (`WiFiDensePoseModel`, `kp_head`/`dp_head`); `v2/crates/wifi-densepose-sensing-server/src/embedding.rs` (`ProjectionHead` + LoRA + `info_nce_loss` — the existing seventh head, ADR-146 §1.1); `v2/crates/wifi-densepose-train/src/rapid_adapt.rs` (ContrastiveTTT precedent); ADR-146 §1.4 head fan-out; ADR-150 §2 loss stack.
|
||||
|
||||
**Contract sketch** (lands in `wifi-densepose-nn`, per ADR-146 §1.3):
|
||||
```rust
|
||||
pub trait RfEncoder: Send + Sync {
|
||||
fn encode(&self, window: &CsiWindowTensor) -> Embedding; // z ∈ R^d_model
|
||||
fn model_id(&self) -> u16; // FrameMeta binding (ADR-136 §2.2)
|
||||
}
|
||||
pub trait TaskHead<O: QualityScored>: Send + Sync {
|
||||
fn name(&self) -> &'static str;
|
||||
fn forward(&self, z: &Embedding) -> O; // value + uncertainty bounds
|
||||
}
|
||||
pub struct MultiTaskOutput { /* per-head QualityScored outputs + coherence: f32 */ }
|
||||
```
|
||||
|
||||
**Feasibility: HIGH for the architecture, MEDIUM for the headline result.** The pure-Rust f32 ABI is proven (`embedding.rs`), the head taxonomy is specified (ADR-146), and the ablation harness to measure it exists (`wifi-densepose-train/src/ablation.rs`). The risk is scientific, not engineering: ADR-150's own data shows naive approaches fail; the pose-contrastive objective is plausible but unproven at scale. Mitigation: ADR-150 §3's frozen-decoder three-variant experiment gates promotion.
|
||||
|
||||
### P2 — Physics-informed differentiable RF forward model (PROPOSED)
|
||||
|
||||
**What.** A differentiable forward model `render(scene, link_geometry) -> predicted CSI/CIR` used three ways: (1) as a regularizer in encoder training (predictions must be consistent with a Born-approximation scattering model), (2) as an analysis-by-synthesis residual at inference (`|observed − rendered|` becomes an ADR-137 `EvidenceRef`), (3) as a synthetic-data generator complementing MM-Fi (ADR-015).
|
||||
|
||||
**Why beyond SOTA.** Published WiFi sensing is almost entirely discriminative; physics-informed neural fields exist for vision (NeRF) and acoustics but no deployed RF-human-sensing stack closes the loop *forward model → residual → fusion evidence → privacy decision*. Making physics disagreement a first-class, witnessed contradiction flag is novel system design, not just a model.
|
||||
|
||||
**Builds on.** The codebase already contains the seed of the forward model: `v2/crates/wifi-densepose-signal/src/ruvsense/tomography.rs` (`RfTomographer`, `LinkGeometry`, `OccupancyVolume` — a linear shadowing forward model inverted by ISTA), `ruvsense/cir.rs` (sub-DFT sensing matrix Φ, ISTA L1 — ADR-134), ADR-143 §1.3 (bistatic excess-delay geometry, the exact ray equations), and `nvsim` as the in-repo precedent for a *deterministic, witness-hashed forward physics pipeline* (`v2/crates/nvsim/src/{propagation.rs,pipeline.rs,proof.rs}`).
|
||||
|
||||
**Contract sketch** (new module `wifi-densepose-signal/src/ruvsense/forward_model.rs`, PROPOSED):
|
||||
```rust
|
||||
pub trait RfForwardModel: Versioned {
|
||||
/// Predict per-link CSI given a voxel scene + body primitive set.
|
||||
fn render(&self, scene: &OccupancyVolume, links: &[LinkGeometry]) -> Vec<PredictedCsi>;
|
||||
/// Physics residual in [0,1]; 0 = perfectly Maxwell/Born-consistent.
|
||||
fn residual(&self, observed: &CsiFrame, rendered: &PredictedCsi) -> PhysicsResidual; // → EvidenceRef
|
||||
}
|
||||
```
|
||||
|
||||
**Feasibility: MEDIUM, with one honest line drawn.** A full Maxwell FDTD-in-the-loop solver is **infeasible** at 20 Hz on this hardware and is a non-goal (§6). What is feasible: a first-order Born / ray-tracing bistatic model (the ADR-143 spheroid geometry generalized), differentiable through finite differences or a small Candle graph, validated against recorded calibration captures (ADR-135 baselines give per-link empty-room ground truth for free). "Maxwell-consistent" should be read as "consistent with a stated first-order approximation, with the approximation order recorded in the witness metadata."
|
||||
|
||||
### P3 — RF-SLAM × WorldGraph × OccWorld closed loop (exists in parts, wiring is the work)
|
||||
|
||||
**What.** Close the loop: RF-SLAM discovers reflectors/anchors → WorldGraph persists them as `object_anchor` nodes → OccWorld consumes graph occupancy → `TrajectoryPrior`s feed the Kalman tracker → improved tracks refine SLAM association. The environment model becomes self-acquiring and self-correcting (furniture moved ⇒ `BaselineTopologyChange` ⇒ recalibration trigger, ADR-143 §1.4).
|
||||
|
||||
**Why beyond SOTA.** Published RF-SLAM work maps *or* tracks; no published consumer system maintains a persistent, provenance-bearing, privacy-rolled-up environmental digital twin (`PrivacyRollup` in `wifi-densepose-worldgraph/src/graph.rs`) that is simultaneously the SLAM map, the automation substrate, and the audit record. The differentiator is the closed loop with evidence edges (`supports`/`contradicts`).
|
||||
|
||||
**Builds on.** All three vertices exist: `v2/crates/wifi-densepose-signal/src/ruvsense/rf_slam.rs` (`RfSlam::observe`, line 176, already a field of `StreamingEngine` — `wifi-densepose-engine/src/lib.rs:116`); `v2/crates/wifi-densepose-worldgraph/src/lib.rs`; `v2/crates/wifi-densepose-worldmodel/src/{bridge.rs,occupancy.rs}` (`worldgraph_to_occupancy`, `OccWorldBridge::predict`). The engine already upserts SLAM output and person tracks into the graph. Missing: prior-injection back into `ruvsense/pose_tracker.rs`, and the topology-change → ADR-135 recalibration edge.
|
||||
|
||||
**Contract sketch** (extends existing types):
|
||||
```rust
|
||||
impl StreamingEngine {
|
||||
/// PROPOSED: inject OccWorld priors into the next tracker cycle.
|
||||
pub fn apply_trajectory_priors(&mut self, priors: &[TrajectoryPrior]) -> Vec<WorldId>;
|
||||
}
|
||||
// WorldEdge gains (PROPOSED): PredictedBy { model_id: u16 } — prior provenance edge
|
||||
```
|
||||
|
||||
**Feasibility: HIGH.** This is mostly integration glue between tested crates. The two real risks are already named by ADR-143: no ground-truth oracle in a live home (mitigated by the v1-fixed / v2-flagged rollout, `#[cfg(feature = "rf-slam-v2")]`), and OccWorld's Python subprocess (ADR-147: 375 ms/inference) being off the deterministic path — priors must be treated as advisory, never witness-bearing (§5).
|
||||
|
||||
### P4 — Cross-modal distillation: camera-teacher → RF-student, privacy-preserving deployment (PROPOSED)
|
||||
|
||||
**What.** Train-time-only camera supervision: a vision pose teacher labels synchronized CSI (MM-Fi already provides paired modalities, ADR-015), distilling dense pose + uncertainty into the P1 encoder. Deployed systems ship **no camera and no camera-derived identity features**; the ADR-145 privacy-leakage metric (membership-inference score in `wifi-densepose-train/src/ablation.rs`) gates that the student does not retain identity.
|
||||
|
||||
**Why beyond SOTA.** Camera-supervised WiFi pose is the original DensePose-WiFi recipe; what is *not* published is distillation with a measured, CI-enforced privacy-leakage budget and a witnessed claim that the deployed artifact is camera-free. The beyond-SOTA move is making "privacy-preserving" a *measured property of the release pipeline*, not a marketing adjective.
|
||||
|
||||
**Builds on.** `v2/crates/wifi-densepose-train/src/{trainer.rs,losses.rs,dataset.rs}` (training substrate); ADR-015 paired datasets; ADR-145 `FeatureSet` matrix + privacy-leakage scalar; `v2/crates/wifi-densepose-bfld` (`privacy_gate.rs`, `signature_hasher.rs` — runtime identity controls, ADR-120 invariants I1–I3).
|
||||
|
||||
**Contract sketch** (in `wifi-densepose-train`, PROPOSED):
|
||||
```rust
|
||||
pub struct DistillationLoss { pub teacher: TeacherSource, pub temperature: f32, pub uq_transfer: bool }
|
||||
pub enum TeacherSource { CachedPoseLabels(PathBuf), /* never a live camera in the serving graph */ }
|
||||
/// Release gate: leakage(student) ≤ budget, asserted by the ADR-145 harness per variant.
|
||||
pub struct PrivacyBudget { pub max_mia_score: f32 }
|
||||
```
|
||||
|
||||
**Feasibility: HIGH.** All ingredients exist; the work is a loss term, a label cache format, and a CI gate. The honest caveat: MIA-based leakage scores are a lower bound on real leakage; the budget is a regression tripwire, not a formal guarantee.
|
||||
|
||||
### P5 — Swarm-distributed multistatic sensing with Raft-coordinated apertures (ADR-148, partially built)
|
||||
|
||||
**What.** Treat the drone swarm + fixed ESP32 mesh as one *reconfigurable multistatic aperture*: a Raft-elected cluster head plans node positions/channel assignments to maximize geometric diversity (GDI) for the current sensing task; per-node frames flow into the same `MultistaticFuser` path as fixed nodes.
|
||||
|
||||
**Why beyond SOTA.** Published multistatic WiFi sensing assumes fixed geometry. Closed-loop aperture optimization — moving the sensors to where the Fisher information is — driven by the GDI/Cramér–Rao machinery that already exists in `v2/crates/wifi-densepose-ruvector/src/viewpoint/geometry.rs` (per CLAUDE.md module table: `GeometricDiversityIndex`, Cramér-Rao bounds) is a genuinely new system class for SAR/MAT scenarios.
|
||||
|
||||
**Builds on.** `v2/crates/ruview-swarm/src/sensing/{multiview.rs,payload.rs,occworld_bridge.rs}`, `topology.rs`, `planning.rs`, `marl/` (MAPPO, `candle_ppo.rs`); `ruvsense/multistatic.rs` + `array_coordinator.rs` (ADR-138 clock-quality gating — moving nodes will stress exactly this); `wifi-densepose-mat` (the MAT use case).
|
||||
|
||||
**Contract sketch** (in `ruview-swarm`, PROPOSED):
|
||||
```rust
|
||||
pub trait AperturePlanner: Send + Sync {
|
||||
/// Given current twin + task, propose node placements maximizing expected GDI.
|
||||
fn plan(&self, twin: &WorldGraphSnapshot, task: &SwarmTask) -> Vec<(NodeId, Position3D)>;
|
||||
}
|
||||
// Output flows through Raft (topology.rs) as a normal SwarmTask; frames return as ArrayNodeInput.
|
||||
```
|
||||
|
||||
**Feasibility: MEDIUM.** Coordination, MARL, and fusion code exist and are tested; the hard physical problems are honest unknowns: airborne CSI phase stability (rotor vibration), clock sync across mobile nodes (ADR-138 gate will reject a lot initially), and ADR-148 §1.3's own regulatory scoping. Simulation-first via `ruview-swarm/src/evals.rs` + `bench_support.rs`; hardware validation is Phase 3.
|
||||
|
||||
### P6 — Continual / test-time adaptation with EWC-style forgetting control (PROPOSED on existing TTT)
|
||||
|
||||
**What.** Promote `rapid_adapt.rs` from a per-deployment trick to a managed continual-learning loop: TTT/entropy adaptation produces LoRA deltas on the P1 encoder; an EWC (elastic weight consolidation) penalty — **which does not exist in the workspace today** (no EWC match in `wifi-densepose-train/src/rapid_adapt.rs`) — anchors weights important to previously-validated environments; every adaptation step is versioned as a new `model_version` (u16, ADR-136 §2.2) and must re-pass the ADR-145 acceptance matrix before activation.
|
||||
|
||||
**Why beyond SOTA.** TTT papers adapt and hope; nothing published couples adaptation to a *deterministic regression gate with witness hashes*, where an adapted model that regresses tier or leaks identity is automatically rejected and the `model_version` provenance lets any semantic state be traced to the exact adaptation step.
|
||||
|
||||
**Builds on.** `v2/crates/wifi-densepose-train/src/rapid_adapt.rs` (`AdaptationLoss::ContrastiveTTT`, entropy-minimization variant — lines 8–16); LoRA adapters in `sensing-server/src/embedding.rs` (rank-4 `lora_1`/`lora_2`); ADR-027 MERIDIAN evaluator (`train/src/eval.rs`); ADR-146 §2 calibration-robustness loss.
|
||||
|
||||
**Contract sketch** (in `wifi-densepose-train`, PROPOSED):
|
||||
```rust
|
||||
pub struct EwcPenalty { pub fisher_diag: Vec<f32>, pub anchor: Vec<f32>, pub lambda: f32 }
|
||||
pub struct AdaptationStep {
|
||||
pub parent_model_version: u16, pub new_model_version: u16,
|
||||
pub loss: AdaptationLoss, pub ewc: Option<EwcPenalty>,
|
||||
pub acceptance: RuViewAcceptanceResult, // must be ≥ parent tier
|
||||
pub witness: [u8; 32], // hash of delta + acceptance
|
||||
}
|
||||
```
|
||||
|
||||
**Feasibility: HIGH.** EWC over a small LoRA delta is cheap (Fisher diagonal over the replay corpus); the acceptance gate and proof seeds exist (`proof.rs`, `PROOF_SEED = 42`). Risk: online Fisher estimation from unlabeled home data is noisy — start with adaptation restricted to LoRA parameters only, backbone frozen.
|
||||
|
||||
### P7 — On-device WASM edge inference with deterministic replay (extends existing Tier-3)
|
||||
|
||||
**What.** Push P1 head subsets (presence, vitals, coarse activity) into hot-loadable WASM modules on ESP32-S3, and onto browsers/workers via `wifi-densepose-wasm`. Every edge module's output is replayable: the same `CanonicalFrame` input bytes through the same module hash produce the same output bytes, verified in CI on x86_64/aarch64/wasm32.
|
||||
|
||||
**Why beyond SOTA.** Edge WiFi-sensing exists; *bit-deterministic, witness-hashed edge inference with hot-swap and replay parity against the server pipeline* does not appear in published systems. It turns the edge from a trust hole into a witness-chain extension.
|
||||
|
||||
**Builds on.** `v2/crates/wifi-densepose-wasm-edge/src/lib.rs` (WASM3 host ABI: `csi_get_*`, `on_frame` at ~20 Hz, ADR-040 Tier 3); `nvsim` as the proof that a no-std-time, no-OS-entropy, seeded-PRNG crate runs identically on wasm32 (`nvsim/src/lib.rs` doc); ADR-136 AC7 cross-architecture byte-stability test.
|
||||
|
||||
**Contract sketch** (PROPOSED additions to the wasm-edge host ABI):
|
||||
```rust
|
||||
// exports added to module lifecycle:
|
||||
// on_replay_begin(seed: u64) — pins any module-internal PRNG
|
||||
// witness_digest(buf_ptr: i32) -> i32 — module returns BLAKE3 of its output stream
|
||||
pub trait EdgeStage: Stage<CsiFrameView, EdgeEvent> { fn module_hash(&self) -> [u8; 32]; }
|
||||
```
|
||||
|
||||
**Feasibility: HIGH for presence/vitals heads, LOW for full pose on-ESP32.** WASM3 interpretation on Xtensa caps throughput; full 7-head inference stays on Pi/Hailo/browser. Float determinism across native vs WASM needs care (no fast-math, fixed reduction order — same obligation ADR-136 §3.2 already accepts).
|
||||
|
||||
### P8 — NV-magnetometry fusion: an honest classical-quantum hybrid (PROPOSED, simulation-first)
|
||||
|
||||
**What.** Add `nvsim`-modeled NV-magnetometer nodes as a *fourth sensing modality class* (after CSI, mmWave/ADR-021, BFLD) in the multistatic fusion: near-range (≤ tens of cm, per the physics review) cardiac/respiratory magnetic signatures fused with CSI/mmWave vitals under the ADR-137 evidence contract. Simulation-first: the modality lands end-to-end against `nvsim` before any hardware exists.
|
||||
|
||||
**Why beyond SOTA.** Not range — the Ghost Murmur review (`docs/research/quantum-sensing/16-ghost-murmur-ruview-spec.md`) documents why multi-mile cardiac magnetometry contradicts published physics, and this design adopts that conclusion. The beyond-SOTA element is architectural honesty: a fusion engine that can ingest a quantum-sensor modality with explicit, witnessed physics bounds (`nvsim`'s forward model states its approximations and hashes its output, `nvsim/src/proof.rs`), so that when real NV hardware matures, the integration path and the anti-hype guardrails already exist. No published consumer sensing stack has this.
|
||||
|
||||
**Builds on.** `v2/crates/nvsim/src/` (scene→source→attenuation→NV ensemble→digitiser, SHA-256 witness, ADR-089); `nvsim-server`; `wifi-densepose-vitals` (mmWave HR/BR — the modality NV would cross-validate); `ruvsense/multistatic.rs` fusion + ADR-137 `EvidenceRef`.
|
||||
|
||||
**Contract sketch** (PROPOSED): a `SensorModality::NvMagnetometer` variant on the existing `wifi-densepose-worldgraph` `SensorModality` enum, plus an `ArrayNodeInput` adapter from `nvsim` frames; vitals agreement/disagreement between NV and mmWave becomes an `EvidenceRef`/`ContradictionFlag` pair.
|
||||
|
||||
**Feasibility: HIGH in simulation, SPECULATIVE on hardware.** The sim path is days of glue; COTS NV magnetometers with the required sensitivity at consumer cost do not exist in 2026. This pillar's deliverable is the *contract and the simulated validation*, explicitly labeled as such.
|
||||
|
||||
---
|
||||
|
||||
## 4. Phased implementation plan
|
||||
|
||||
Phases are gated by the Pre-Merge Checklist (CLAUDE.md) and the witness chain (§5). Crate names per the ADR-136 §2.1 normative map — no new `ruview_*` crates except where a crate already exists (`ruview-swarm`).
|
||||
|
||||
**Phase 0 — Hardening (close the ADR-136 "integration glue" debt).**
|
||||
- `wifi-densepose-signal`: wire the full 600-frame `Stage`-chain replay (ADR-136 AC6) and register `streaming_engine_replay_v1` in `archive/v1/data/proof/expected_features.sha256`.
|
||||
- CI: cross-architecture witness matrix x86_64/aarch64 (AC7); add wasm32 lane for `nvsim` + `wifi-densepose-wasm`.
|
||||
- `wifi-densepose-engine`: populate `FrameMeta.calibration_id`/`model_id` from the live calibration and model-binding stages (currently defaulted — ADR-136 §8).
|
||||
- `homecore-recorder`: define the **replay corpus** format (canonical-bytes frame streams + witness manifest) that P4/P6 training and all ablations consume.
|
||||
|
||||
**Phase 1 — Encoder + measurement (P1, P4 groundwork, P6 skeleton).**
|
||||
- `wifi-densepose-nn`: `RfEncoder`/`TaskHead` traits, seven-head fan-out, UQ layer (ADR-146); relocate `ProjectionHead` from `sensing-server/src/embedding.rs`.
|
||||
- `wifi-densepose-train`: `ContrastiveBatcher`, ADR-150 loss stack, distillation loss + cached-teacher format (P4), `EwcPenalty` + `AdaptationStep` (P6); extend `ablation.rs` `FeatureSet` with per-head and per-pillar variants; pin `expected_ablation_*.sha256`.
|
||||
- Run the ADR-150 three-variant frozen-decoder experiment; promotion gate on cross-subject delta.
|
||||
|
||||
**Phase 2 — Closed loop + edge (P3, P7).**
|
||||
- `wifi-densepose-engine`: `apply_trajectory_priors` (OccWorld → `pose_tracker.rs`); `PredictedBy` provenance edge in `wifi-densepose-worldgraph`; topology-change → ADR-135 recalibration trigger.
|
||||
- `wifi-densepose-wasm-edge`: replay ABI (`on_replay_begin`, `witness_digest`), presence/vitals head modules; parity test vs server pipeline on identical canonical bytes.
|
||||
- Enable `rf-slam-v2` feature on the 7-day validation dataset (ADR-143 gate).
|
||||
|
||||
**Phase 3 — Frontier (P2, P5, P8).**
|
||||
- `wifi-densepose-signal/src/ruvsense/forward_model.rs`: Born/ray forward model seeded from `tomography.rs`; `PhysicsResidual` → `EvidenceRef`; synthetic-data generator into `train/src/dataset.rs`.
|
||||
- `ruview-swarm`: `AperturePlanner` over GDI (`ruvector/src/viewpoint/geometry.rs`); simulation evals in `evals.rs`; airborne CSI stability study before any hardware claim.
|
||||
- `nvsim` ↔ `wifi-densepose-engine`: `SensorModality::NvMagnetometer` adapter, simulated NV+mmWave vitals cross-validation in the ablation matrix.
|
||||
|
||||
---
|
||||
|
||||
## 5. Determinism & witness-chain preservation
|
||||
|
||||
The non-negotiable invariant (ADR-136 §2.5–2.6, ADR-028): replaying recorded canonical bytes through the pipeline twice yields byte-identical outputs and equal BLAKE3 witness hashes. Strategy per component class:
|
||||
|
||||
1. **Everything on the trust path implements `CanonicalFrame`.** New frame types (`MultiTaskOutput`, `PhysicsResidual`, `AdaptationStep`, edge events, NV frames) get fixed-field-order LE encodings and `witness_hash()`; encoders are the only serializers (no ad-hoc serde on the witness path).
|
||||
2. **Inference is witnessed by (input hash, model hash, output hash).** `model_id`/`model_version` on `FrameMeta` already bind frames to models; P1 adds a weights digest so the triple is closed. Pure-Rust f32 inference (ADR-146 ABI) with fixed reduction order; no GPU nondeterminism on the witness path — GPU/libtorch is training-only, and training determinism is pinned by the existing seeds (`proof.rs`: `PROOF_SEED = 42`, `MODEL_SEED = 0`).
|
||||
3. **Advisory vs witnessed split.** Components that cannot be made deterministic — the OccWorld Python subprocess (ADR-147), live MARL exploration, any future LLM/agent output (ADR-140 Ruflo) — are **advisory**: their outputs may bias estimates but never enter `to_canonical_bytes()` directly; instead the *decision to use them* is recorded (prior id + content hash) so replay reproduces the decision even if the producer cannot be re-run. The Kalman tracker consumes priors as explicit inputs recorded in the replay corpus.
|
||||
4. **Adaptation is a chain of witnessed steps.** P6's `AdaptationStep.witness` hashes (parent version ‖ delta ‖ acceptance result); the active model at any timestamp is reconstructible from the step chain — the model-weights analogue of the frame witness chain.
|
||||
5. **Edge parity.** P7 modules must produce the same `witness_digest` as the server-side reference implementation on the AC6 fixture; the module hash joins the firmware `source-hashes.txt` in the ADR-028 witness bundle.
|
||||
6. **Witness bundle growth is mechanical.** Each pillar adds expected-hash keys (`forward_model_residual_v1`, `edge_presence_replay_v1`, `nvsim` already ships `proof.rs`) to the existing `verify.py` chain rather than inventing new verification mechanisms.
|
||||
|
||||
---
|
||||
|
||||
## 6. Explicit non-goals
|
||||
|
||||
- **No workspace rename or rewrite.** Reaffirms ADR-136 §2.1/§4.1: no `ruview_*` crate prefix migration, no umbrella crate; pillars land inside the existing crates listed above.
|
||||
- **No full-wave Maxwell solver in the runtime loop.** P2 is first-order Born/ray, with the approximation order declared. "Physics-informed" never means FDTD at 20 Hz.
|
||||
- **No long-range cardiac magnetometry claims.** P8 is bounded by the physics review in `docs/research/quantum-sensing/16-ghost-murmur-ruview-spec.md`; ranges beyond published MCG physics are out of scope permanently, not just deferred.
|
||||
- **No camera in any deployed serving graph** (P4 teachers are train-time, cached-label only) and **no identity recognition as a product feature** — identity embeddings remain in-RAM, hash-rotated (ADR-120 invariants).
|
||||
- **No weaponization or LAWS capability in P5**, per ADR-148 §1.3; swarm work targets SAR/MAT and stays behind the ADR-148 regulatory gates.
|
||||
- **No fabricated benchmarks.** All pillar performance statements in this document are targets; promotion of any pillar requires the ADR-145 ablation matrix delta plus pinned determinism hashes, in CI, before any external claim.
|
||||
- **No new verification mechanisms.** The witness chain extends `verify.py` / BLAKE3 / `expected_*.sha256`; we do not introduce a second, parallel proof system.
|
||||
|
||||
---
|
||||
|
||||
## 7. Open questions for the next document in this series
|
||||
|
||||
1. Airborne CSI phase stability (P5): what does the ADR-138 clock-quality gate measure on a real quadrotor payload?
|
||||
2. Forward-model fidelity floor (P2): what Born-residual magnitude on the ADR-135 empty-room captures is "good enough" to be a useful contradiction signal?
|
||||
3. Replay-corpus governance (Phase 0): retention, privacy class of recorded canonical bytes, and consent — the recorder stores signal evidence, which is itself sensitive.
|
||||
@@ -0,0 +1,384 @@
|
||||
# Beyond-SOTA Validation, Test & Benchmark Methodology
|
||||
|
||||
**Series:** `docs/research/ruview-beyond-sota/` · Document 03
|
||||
**Date:** 2026-06-09
|
||||
**Scope:** How RuView proves (and gates) beyond-SOTA claims using the verification
|
||||
infrastructure that already exists in this repository. Every number below is sourced
|
||||
from a cited file in this repo; nothing is invented.
|
||||
|
||||
---
|
||||
|
||||
## 1. The Layered Validation Pyramid
|
||||
|
||||
Six layers, cheapest/most-deterministic at the bottom, most expensive/most-credible at
|
||||
the top. A beyond-SOTA claim must survive **every layer below it** before it may be
|
||||
published from the layer it lives at.
|
||||
|
||||
| Layer | What it proves | Tooling | Frequency | Determinism |
|
||||
|-------|----------------|---------|-----------|-------------|
|
||||
| **L0** Unit/integration tests | Code correctness | `cargo test --workspace --no-default-features` + pytest | per commit | exact |
|
||||
| **L1** Deterministic proof + witness bundle | Pipeline is real, unchanged, reproducible | `archive/v1/data/proof/verify.py`, `scripts/generate-witness-bundle.sh` | per merge / release | exact (SHA-256) |
|
||||
| **L2** Criterion micro-benchmarks | Compute latency only — never quality (ADR-149 §2) | 15 bench targets across `v2/crates/*/benches/` | nightly / pre-release | statistical |
|
||||
| **L3** Dataset-level accuracy eval | Pose/presence/vitals quality vs published SOTA | MM-Fi / Wi-Pose (ADR-015), `ruview_metrics.rs` tiers, ADR-145 ablation harness | per model release | seeded |
|
||||
| **L4** Hardware-in-loop | Real CSI on real ESP32, no mocks | COM9 (S3) / COM12 (C6) protocol, witness firmware hashes | per firmware release | A/B controlled |
|
||||
| **L5** Field trials / live capture | End-to-end behavior in a real room | live-session captures (e.g. `benchmark_baseline.json`) | campaign | statistical |
|
||||
|
||||
### 1.1 L0 — Workspace tests (current counts)
|
||||
|
||||
- ADR-028 audit (2026-03-01): **1,031 passed, 0 failed, 8 ignored** for
|
||||
`cargo test --workspace --no-default-features`
|
||||
(`docs/adr/ADR-028-esp32-capability-audit.md` §2).
|
||||
- Current `CHANGELOG.md` (Unreleased, cross-platform fix entry): **2,682 workspace
|
||||
tests pass / 0 fail on Windows** — the suite has more than doubled since the audit.
|
||||
- `CLAUDE.md` pre-merge gate still cites "1,031+ passed, 0 failed" as the floor.
|
||||
|
||||
**Rule:** the post-change test count may never be lower than the pre-change count, and
|
||||
failures must be 0. The witness bundle records the full log
|
||||
(`test-results/rust-workspace-tests.log`) and an aggregated `summary.txt`
|
||||
(`scripts/generate-witness-bundle.sh` step 3).
|
||||
|
||||
### 1.2 L1 — Deterministic proof ("Trust Kill Switch") + witness bundle
|
||||
|
||||
`archive/v1/data/proof/verify.py` (header comment): feeds 1,000 synthetic CSI frames
|
||||
(seed=42, `sample_csi_data.json`) through the **production** `CSIProcessor`
|
||||
(`src/core/csi_processor.py`), hashes the first 100 frames' feature output
|
||||
(`VERIFICATION_FRAME_COUNT = 100`), and compares against
|
||||
`archive/v1/data/proof/expected_features.sha256`.
|
||||
|
||||
- **Current published hash (file contents, verified during this investigation):**
|
||||
`f8e76f21a0f9852b70b6d9dd5318239f6b20cbcb4cdd995863263cecdc446f7a`
|
||||
- The hash is **environment-coupled** and has been legitimately regenerated before:
|
||||
ADR-028 §5.3 recorded `8c0680d7…` under numpy 2.4.2/scipy 1.17.1; `CHANGELOG.md`
|
||||
(#560 fix) recorded `667eb054…` after 6-decimal quantization + single-thread BLAS
|
||||
pinning (`OMP_NUM_THREADS=1` etc.). Each regeneration must follow the documented
|
||||
procedure: `python verify.py --generate-hash` then `python verify.py` → `VERDICT: PASS`.
|
||||
|
||||
`scripts/generate-witness-bundle.sh` packages: witness log + ADR-028, the Python proof
|
||||
(verify.py + expected hash + reference-signal metadata), full Rust test log + summary,
|
||||
the ADR-134 CIR proof, firmware source/binary SHA-256s, crate version manifest, npm
|
||||
tarball SHA-256, and a recipient-side `VERIFY.sh`.
|
||||
|
||||
**Accuracy note on check counts:** `CLAUDE.md` describes the recipient verification as
|
||||
"7/7 PASS"; the current `VERIFY.sh` embedded in the script performs **10** `check()`
|
||||
assertions (witness log, ADR, proof-hash file, tests, firmware hashes, crate manifest,
|
||||
npm manifest, Python proof, CIR proof, CIR hash file) but prints a hardcoded
|
||||
`"ALL CHECKS PASSED (8/8)"` string (`generate-witness-bundle.sh` line 293). The
|
||||
hardcoded count is stale relative to the actual check list — fix it to print
|
||||
`${PASS_COUNT}/${PASS_COUNT+FAIL_COUNT}` so the verdict can never silently desynchronize
|
||||
from the check inventory.
|
||||
|
||||
### 1.3 L2 — Criterion micro-benchmark inventory (all 15 targets)
|
||||
|
||||
All bench sources read directly. Per ADR-149 §2 these are **latency regression gates
|
||||
only, never quality evidence**.
|
||||
|
||||
| Bench target | Crate | Benchmark functions / groups | What it measures | Recorded value or in-source target (citation) |
|
||||
|---|---|---|---|---|
|
||||
| `engine_cycle.rs` | wifi-densepose-engine | `process_cycle_4nodes_56sc` | One full `StreamingEngine::process_cycle` (fuse + quality + calibration provenance + privacy gate + WorldGraph node), 4-node/56-subcarrier ESP32-S3 HT20 mesh | Budget: **50 ms** (20 Hz) — bench header |
|
||||
| `signal_bench.rs` | wifi-densepose-signal | `CSI Preprocessing`, `Phase Sanitization`, `Feature Extraction`, `Motion Detection`, `Full Pipeline` | SOTA signal stages (ADR-014) at varying frame sizes | no recorded baseline |
|
||||
| `cir_bench.rs` | wifi-densepose-signal | `cir_estimate` (HT20/HT40/HE20/HE40), `cir_estimate_12link`, `cir_estimator_new` | ADR-134 `CirEstimator::estimate()` per tier; 12-link multistatic amortization; cold-start | no recorded baseline |
|
||||
| `calibration_bench.rs` | wifi-densepose-signal | `bench_recorder_record`, `bench_recorder_finalize`, `bench_deviation`, `bench_record_600`, `bench_to_bytes` (K=52/114/242/484) | ADR-135 empty-room baseline recorder + deviation scoring | no recorded baseline |
|
||||
| `aether_prefilter_bench.rs` | wifi-densepose-signal | `aether_search_d…_n…_k…` (search vs prefilter) | ADR-084 Pass-2: `EmbeddingHistory::search_prefilter` vs brute force, prefilter_factor=8 | Pass: **≥4× at n=1024** — bench header |
|
||||
| `sketch_bench.rs` | wifi-densepose-ruvector | `compare_d128/256/512` × `float_l2`/`float_cosine`/`sketch_hamming` | ADR-084 sketch-vs-float per-pair compare cost (AETHER 128-d, spectrogram 256-d) | Pass: **sketch ≥8× faster** at every dim (ADR-084 threshold 8×–30×) — bench header |
|
||||
| `crv_bench.rs` | wifi-densepose-ruvector | `gestalt_classify_single/batch_100`, `sensory_encode_single`, `pipeline_full_session`, `convergence_two_sessions`, `crv_session_create`, `crv_embedding_dimension_scaling` (32/128/384), `crv_stage_vi_partition` | CRV integration throughput | no recorded baseline |
|
||||
| `inference_bench.rs` | wifi-densepose-nn | `tensor_ops` (relu/sigmoid/tanh), `densepose_inference`, `translator_inference`, `mock_inference`, `batch_inference` | NN forward-pass cost by input/batch size | no recorded baseline; **`mock_inference` group must never be quoted as a pipeline number** (§6) |
|
||||
| `training_bench.rs` | wifi-densepose-train | `interp_114_to_56_batch32`, `interp_scaling`, `compute_interp_weights_114_56`, `synthetic_dataset_get`, `synthetic_epoch`, `config_validate`, PCK over 100 samples | Training preprocessing + metrics hot paths; fixtures fully deterministic (no `rand`) — header | no recorded baseline |
|
||||
| `detection_bench.rs` | wifi-densepose-mat | `breathing_detection`, `heartbeat_detection`, `movement_classification`, `detection_pipeline`, localization (triangulation/depth), alert generation | MAT survivor-detection algorithms at varying signal lengths / noise | no recorded baseline |
|
||||
| `transport_bench.rs` | wifi-densepose-hardware | `beacon_serialize_16byte/28byte_auth/quic_framed`, `auth_beacon_verify`, `replay_window`, `framed_message` encode/decode, `secure_tdm_cycle` (manual vs QUIC) | TDM beacon crypto + transport | no recorded baseline |
|
||||
| `mqtt_throughput.rs` | wifi-densepose-sensing-server | `discovery::build_*`, `state::*`, `rate_limiter::allow_*`, `privacy::decide_*`, `semantic::bus_tick_all_10_primitives` | ADR-115 MQTT hot path | Targets (header): discovery **<5 µs**, state encode **<2 µs**, rate limit **<100 ns**, privacy **<50 ns**, bus tick **<10 µs** |
|
||||
| `swarm_bench.rs` | ruview-swarm | `marl_actor_inference`, `rrt_apf_100iter`, `multiview_fusion_3drones`, `demo_coverage_estimate`, `ppo_update_64transitions` | ADR-148 swarm control-loop compute | Measured: **3.3 µs / 43 µs / 54–58.5 ns / 100 ps / 248 µs** (ADR-149 §4.3; `CHANGELOG.md` Performance section) |
|
||||
| `pipeline_throughput.rs` | nvsim | `pipeline_run` (sample-count sweep), `witness::run` vs `run_with_witness` | NV-diamond sim throughput + witness overhead | Acceptance: **≥1 kHz** simulated samples/s on Cortex-A53-class CPU — bench header |
|
||||
| `state_machine.rs` | homecore | `set` first/warm/no-op, `get` hit/miss, `all_snapshot`, `all_by_domain_light_20_of_100`, `broadcast_fan_out` | HOMECORE state-machine hot paths | no recorded baseline |
|
||||
|
||||
**Honest gap — `benchmark_baseline.json` is not a criterion baseline.** The repo-root
|
||||
`benchmark_baseline.json` (369.9 KB) contains **1,566 live-capture samples** from a
|
||||
2-node session (fields: `tick`, `n_nodes`, `variance`, `motion`, `presence`,
|
||||
`confidence`, `est_persons`, `n_persons_rendered`, `kp_spread`, `rssi`) plus a summary
|
||||
block — it records **field-trial telemetry (L5)**, not micro-benchmark latencies.
|
||||
No file in the repo references it (`grep -rn benchmark_baseline` → 0 hits outside the
|
||||
file itself); its producer must be identified and committed (§5.3). Summary values
|
||||
(all from the file's `summary` object):
|
||||
|
||||
| Metric | Baseline value |
|
||||
|---|---:|
|
||||
| `total_frames` | 1,566 |
|
||||
| `presence_ratio` | 0.9336 (1,462/1,566 frames presence-true) |
|
||||
| `confidence_mean` | 0.6433 |
|
||||
| `variance_mean` / `variance_std` | 109.36 / 154.13 |
|
||||
| `kp_spread_mean` / `kp_spread_std` | 86.73 / 4.52 |
|
||||
| `person_count_changes` | 10 |
|
||||
|
||||
Criterion latencies that *have* been recorded live in ADR documents instead
|
||||
(ADR-147-benchmark-proof.md, ADR-149 §4.3, CHANGELOG Performance) — §5 below defines
|
||||
how to consolidate them into a real machine-readable criterion baseline.
|
||||
|
||||
### 1.4 L3 — Dataset-level accuracy evaluation
|
||||
|
||||
- **Datasets (ADR-015):** primary **MM-Fi** (40 subjects × 27 actions × ~320K frames,
|
||||
1TX×3RX, 114 subcarriers @100 Hz, 17-keypoint COCO + DensePose UV, CC BY-NC 4.0);
|
||||
secondary **Wi-Pose** (12 volunteers × 12 actions × 166,600 packets, 3×3, 30
|
||||
subcarriers). 114→56 subcarrier interpolation via `subcarrier.rs`; validation split =
|
||||
subjects 33–40 held out (ADR-015 Phase 1).
|
||||
- **Acceptance tiers:** `wifi-densepose-train/src/ruview_metrics.rs` —
|
||||
PCK@0.2 / OKS / MOTA / vitals rolled into `RuViewTier`
|
||||
(Fail/Bronze/Silver/Gold) (ADR-145 §1.1).
|
||||
- **Ablation harness (ADR-145):** 6-variant matrix (`csi_only`, `cir_only`,
|
||||
`csi_plus_cir`, `plus_doppler`, `plus_bfld`, `plus_uwb`-skipped), each variant
|
||||
producing acceptance tier + `SpecMetrics` (presence ≥0.90, localization ≤0.50 m,
|
||||
activity ≥0.70, FP ≤0.05, FN ≤0.10), `LatencyProfile` (p95 ≤100 ms), and
|
||||
`PrivacyLeakage` (MIA `leakage_score` ≤0.05), SHA-256-pinned per variant under
|
||||
`PROOF_SEED=42` (ADR-145 §2.2–2.6). Built at commit `0f336b7d3` (ADR-145
|
||||
implementation status); CLI auto-mode wiring is pending.
|
||||
- **Cross-environment:** ADR-027 MERIDIAN `CrossDomainEvaluator`
|
||||
(`wifi-densepose-train/src/eval.rs`) — `domain_gap_ratio`, extended by ADR-145
|
||||
`cross_room_degradation()` with a 17-joint PCK-delta heatmap.
|
||||
|
||||
### 1.5 L4 — Hardware-in-loop
|
||||
|
||||
- Real CSI nodes: ESP32-S3 on **COM9**, ESP32-C6 + MR60BHA2 on **COM12** (`CLAUDE.md`
|
||||
hardware table). ADR-018 binary frame protocol over UDP:5005 (ADR-028 §3.2/§3.4).
|
||||
- ADR-145 Tier-4 test (gated, `#[cfg(feature = "hardware-test")]`): replay a live 30 s
|
||||
COM9 capture through `csi_only` and `csi_plus_cir`; assert no presence regression and
|
||||
p95 < 100 ms.
|
||||
- A/B board protocol precedent (`CHANGELOG.md` #987): fixed vs unmodified control board
|
||||
against Apple-Watch ground truth (control pegged 40–49 BPM; fixed 88–91 vs 87 GT) —
|
||||
this fixed-board/control-board + external ground-truth pattern is the required design
|
||||
for all hardware vital-sign claims.
|
||||
- Witness bundle pins firmware: per-file SHA-256 of all sources + release binaries
|
||||
(`generate-witness-bundle.sh` step 5).
|
||||
|
||||
### 1.6 L5 — Field trials
|
||||
|
||||
Live multi-node sessions captured as JSONL/JSON with summary statistics —
|
||||
`benchmark_baseline.json` (§1.3) is the existing exemplar. ADR-149 §6 adds the seeded
|
||||
`evals/` episode harness (Stage 1 kinematic full-matrix, Stage 2 Gazebo/PX4 SITL on the
|
||||
3 median seeds) for the swarm domain.
|
||||
|
||||
---
|
||||
|
||||
## 2. Beyond-SOTA Acceptance Criteria per Capability Axis
|
||||
|
||||
A claim is "beyond SOTA" only with: a named external baseline, an exact metric and
|
||||
protocol match, the dataset/split named, the threshold pre-registered, and the
|
||||
statistical procedure of §3 followed. Current axes with measured status:
|
||||
|
||||
| Axis | Metric (exact) | Dataset / protocol | SOTA baseline | Beyond-SOTA threshold | Measured status (cited) |
|
||||
|---|---|---|---|---|---|
|
||||
| In-domain pose accuracy | torso-PCK@20: `‖pred−gt‖ ≤ 0.2·‖R-shoulder−L-hip‖` | MM-Fi `random_split` (ratio 0.8, seed 0) | MultiFormer **72.25%** (Table VII); CSI2Pose 68.41% | > 72.25% with 95% CI lower bound above it | Flagship **83.59%**; micro (75,237 params) **74.30%** (`docs/benchmarks/wifi-pose-efficiency-frontier.md`) |
|
||||
| Edge efficiency frontier | torso-PCK@20 at deployed precision + params + batch-1 latency | same | MultiFormer 72.25% at full size | Pareto-dominance: smaller **and** above 72.25% at the deployed precision | int8 73.5 KB **74.70%**; int4-QAT 36.7 KB **74.46%**; shipped int4 verified **74.08%**, 0.135 ms 1-thread x86 (same file) |
|
||||
| Cross-subject generalization | torso-PCK@20, official MM-Fi cross-subject split (256,608 train / 64,152 test) | leakage-free split | own zero-shot baseline 63.99% | ADR-150 §4 gate: **+≥6 pts cross-subject without losing >2 pts random-split** | Best zero-shot **64.92%** (mixup+TTA+3-seed); gate judged unreachable without new capture (ADR-150 §3.2) |
|
||||
| Few-shot calibration (deployment) | PCK@20 after K labeled in-room samples; adapter size | MM-Fi cross-subject & cross-environment splits | zero-shot (64% / 10.6%) | SOTA-level (≳72%) from ≤200 samples with ≤~11 KB per-room adapter | cross-subject ~**72%** @100–200 samples (3 seeds); cross-env **10.6→73.1%** @200, 60.1% @5 (ADR-150 §3.5–3.6) |
|
||||
| Swarm SAR localization | CEP50/CEP95 (m), GDOP-stratified | seeded episode distribution (ADR-149 §6), not single geometry | Wi2SAR **5 m** (arxiv 2604.09115, paper-to-paper) | CEP50 < 5 m, IQM over ≥10 seeds, 95% CI excluding 5 m | 1.732 m single synthetic geometry — graded **Low–Medium**, not yet claimable (ADR-149 §7) |
|
||||
| Swarm coverage | coverage-rate@240 s; time-to-95% | episode rollouts | Wi2SAR 160k m²/13.5 min | rollout (not analytic) mean+CI beating baseline | 223 s is an analytic estimate — graded **Low** (ADR-149 §7) |
|
||||
| Control-loop latency | criterion wall-clock | local hardware, named | 10 ms / 100 Hz budget | all stages ≪ budget | 3.3 µs MARL / 43 µs RRT-APF / 54 ns fusion / 248 µs PPO (ADR-149 §4.3) |
|
||||
| World-model trajectory | MDE (m) at 5-frame horizon | RuView CSI-derived occupancy | pre-fine-tune random-weight baseline 9.49 m MDE | **≤1.0 m (2.0 vox)** at 5-frame horizon (ADR-147 §5 target, cited in benchmark-proof §4) | 9.49 m / FDE 16.23 m random weights; 208.45 ms median latency on real CSI (ADR-147-benchmark-proof §4, §7) |
|
||||
| Privacy leakage | MIA `leakage_score = 2·(AUC−0.5)` | fixed replay, fixed-seed shadow classifier | chance (0) | ≤ **0.05** (attacker AUC ≤ 0.525) | gate defined, harness built (ADR-145 §2.3) |
|
||||
| Vitals (hardware) | BPM error vs wearable ground truth | live A/B board protocol | control board behavior | within physiological agreement of ground truth, stable spread | 88–91 BPM vs 87 GT, spread 59→0 (CHANGELOG #987) |
|
||||
|
||||
### Claim-language discipline (from ADR-149 §7 grading)
|
||||
|
||||
| Evidence | Permitted language |
|
||||
|---|---|
|
||||
| Single run / single geometry / analytic estimate | "directional", never "beats SOTA" |
|
||||
| Seeded multi-run with CIs vs paper baseline | "exceeds the published X result paper-to-paper" |
|
||||
| Same metric, same split, same protocol, CI excludes baseline | "beyond SOTA on <dataset>/<split>" |
|
||||
| No public leaderboard exists (swarm CSI-SAR) | never claim "leaderboard standing" (ADR-149 §3) |
|
||||
|
||||
---
|
||||
|
||||
## 3. Statistical Procedure for Honest Claims
|
||||
|
||||
Adopted from ADR-149 §5 (Agarwal 2021 / Gorsane 2022 standard) and the practices
|
||||
already used in ADR-150/efficiency-frontier measurements:
|
||||
|
||||
1. **Seeds.** ≥10 independent seeds for RL/episodic claims (ADR-149 §5); ≥3 seeds
|
||||
minimum for supervised dataset evals (ADR-150 §3.5 used 3 seeds; report all).
|
||||
Training seeds, eval seeds, and split files are versioned and committed.
|
||||
2. **Aggregate.** IQM (not mean/median) for episodic metrics + performance profiles;
|
||||
for dataset accuracy report mean across seeds with each seed's value listed.
|
||||
3. **Confidence intervals.** 95% stratified bootstrap, 1,000 resamples (ADR-149 §5;
|
||||
reference impl: `rliable`).
|
||||
4. **Paired comparisons.** When comparing model A vs B (e.g. `csi_plus_cir` vs
|
||||
`csi_only`, or ours vs a reproduced baseline), evaluate both on the **identical
|
||||
frozen test frames** and use a paired bootstrap over per-sample correctness
|
||||
(PCK hit/miss is per-joint binary — pair at the joint-sample level). For
|
||||
paper-to-paper comparisons where the baseline cannot be re-run, state so
|
||||
explicitly ("paper-to-paper", ADR-149 §2) and require the CI lower bound to clear
|
||||
the published point value.
|
||||
5. **Pre-registration.** The threshold lives in an ADR **before** the run
|
||||
(precedent: ADR-150 §4 gate written before §3.2 measurements; the measurements
|
||||
honestly reported the gate as not met).
|
||||
6. **Negative results are recorded.** ADR-150 §1/§3.2 keeps DANN-failed,
|
||||
capacity-hurts, and KD-didn't-help results in the record — required practice.
|
||||
7. **Eval episodes (swarm):** 50 fixed, versioned episodes per policy
|
||||
(10 victim layouts × 5 CSI-noise levels), ≥3 baselines (random walk,
|
||||
boustrophedon+triangulation, IPPO) (ADR-149 §5).
|
||||
8. **GDOP stratification** for any localization claim, so geometry artifacts cannot
|
||||
produce the headline (ADR-149 §6.3).
|
||||
|
||||
---
|
||||
|
||||
## 4. Regression-Gate Design (CI Enforcement)
|
||||
|
||||
### 4.1 Three gate classes, three tolerances
|
||||
|
||||
| Gate class | Source of truth | Tolerance | On breach |
|
||||
|---|---|---|---|
|
||||
| Determinism hashes | `expected_features.sha256`, `expected_cir_features.sha256`, `expected_calibration_features.sha256`, future `expected_ablation_<slug>.sha256` | **exact (0%)** | exit 1 = FAIL; exit 2 = SKIP only for placeholder hashes (proof.rs `0/1/2` convention, ADR-145 §2.4) |
|
||||
| Accuracy / quality metrics | per-variant canonical bytes, quantized 1e-3 (ADR-145 §2.6) | exact after quantization | FAIL CI; tier change requires ADR amendment |
|
||||
| Latency / throughput | criterion estimates JSON | **% tolerance per scale** (below) | FAIL on regression beyond tolerance; trend everything |
|
||||
|
||||
### 4.2 Criterion baseline file (replaces the current gap)
|
||||
|
||||
Today criterion numbers live in prose (ADR-147-benchmark-proof, ADR-149 §4.3,
|
||||
CHANGELOG). Formalize:
|
||||
|
||||
1. `cargo bench --workspace -- --save-baseline main` on a **named, fixed runner**
|
||||
(ADR-147 used RTX 5080 / specific host; record host + toolchain in the file).
|
||||
2. Export `target/criterion/*/estimates.json` point estimates into a committed
|
||||
`v2/benchmarks/criterion-baseline.json`: `{bench_id, crate, p50_ns, host, commit}`.
|
||||
3. CI compares new runs against it with scale-aware tolerance — wall-clock noise is
|
||||
proportionally larger at small magnitudes:
|
||||
|
||||
| Magnitude | Tolerance | Rationale |
|
||||
|---|---|---|
|
||||
| < 1 µs (e.g. fusion 54 ns, privacy decide <50 ns target) | ±25% | timer/jitter dominated |
|
||||
| 1 µs – 1 ms (MARL 3.3 µs, RRT-APF 43 µs, PPO 248 µs) | ±15% | criterion CI typically <5%, leave CI-runner headroom |
|
||||
| > 1 ms (engine cycle vs 50 ms budget, OccWorld ~209 ms) | ±10% **and** absolute budget (50 ms / 500 ms ADR-147 §6) | budgets are the contract |
|
||||
|
||||
4. Hard in-source acceptance thresholds remain authoritative regardless of baseline:
|
||||
sketch ≥8× (`sketch_bench.rs`), prefilter ≥4× (`aether_prefilter_bench.rs`),
|
||||
nvsim ≥1 kHz (`pipeline_throughput.rs`), MQTT header targets, ADR-145 p95 ≤100 ms.
|
||||
5. Latency stays **out of determinism hashes** (ADR-145 §2.6) but **in** the trended
|
||||
`summary.json`, so sub-threshold drift is visible (ADR-145 §3.2 mitigation).
|
||||
|
||||
### 4.3 Live-capture baseline gate (`benchmark_baseline.json`)
|
||||
|
||||
Adopt the file as the L5 regression anchor with documented provenance, then gate a
|
||||
re-capture of the same scenario (same 2-node placement, same room class) against the
|
||||
summary block:
|
||||
|
||||
| Field | Baseline | Suggested gate |
|
||||
|---|---:|---|
|
||||
| `presence_ratio` | 0.9336 | ≥ 0.90 for an occupied-room session |
|
||||
| `confidence_mean` | 0.6433 | within ±0.10 |
|
||||
| `kp_spread_std` | 4.52 | ≤ 2× baseline (skeleton stability) |
|
||||
| `person_count_changes` | 10 / 1,566 frames | ≤ 2× baseline (count flapping — see CHANGELOG #803/#894 clamp bugs this metric would have caught) |
|
||||
|
||||
Field-trial gates are **soft** (warn + require human sign-off), never auto-merge
|
||||
blockers — environments differ; the gate exists to force an explanation.
|
||||
|
||||
### 4.4 Wiring
|
||||
|
||||
Pre-merge (`CLAUDE.md` checklist): L0 + L1. Nightly: L2 criterion + ADR-145 Tier-3
|
||||
ablation matrix (minutes-scale, ADR-145 §3.2). Release: full witness bundle +
|
||||
`VERIFY.sh` + L4 on real COM-port hardware (`CLAUDE.md` firmware rule 6/7).
|
||||
|
||||
---
|
||||
|
||||
## 5. Reproducibility & External-Witness Requirements
|
||||
|
||||
Anyone outside the project must be able to re-run every claimed result:
|
||||
|
||||
1. **One command per layer.** `cargo test --workspace --no-default-features`;
|
||||
`python archive/v1/data/proof/verify.py`; `bash scripts/generate-witness-bundle.sh`
|
||||
then `bash VERIFY.sh` inside the bundle; per ADR-150 §4 every accuracy result needs
|
||||
"one-command reproduction" (efficiency frontier publishes its exact command:
|
||||
`python aether-arena/staging/train_efficiency_pareto.py npy/X.npy npy/Y.npy npy/split_random.npy`).
|
||||
2. **Pinned numerical environment.** The Python proof requires single-threaded BLAS
|
||||
(`OMP_NUM_THREADS=1`, `OPENBLAS_NUM_THREADS=1`, `MKL_NUM_THREADS=1`,
|
||||
`VECLIB_MAXIMUM_THREADS=1`, `NUMEXPR_NUM_THREADS=1`) and 6-decimal quantization
|
||||
(`HASH_QUANTIZATION_DECIMALS=6`) — the #560 fix in `CHANGELOG.md`; Rust proof
|
||||
runners use coarse u16 quantization at 1e-3 in natural order
|
||||
(`calibration_proof_runner.rs` pattern, ADR-145 §2.6) for libm portability.
|
||||
3. **Seeds are constants, committed:** `PROOF_SEED=42`, `MODEL_SEED=0`
|
||||
(`proof.rs`, ADR-015 Phase 5); dataset splits committed as `.npy`
|
||||
(`split_random.npy`); swarm configs as versioned YAML with all seeds (ADR-149 §5).
|
||||
4. **Artifacts carry hashes.** Published model artifacts include SHA-256 (HuggingFace
|
||||
`pose_micro_int4.npz`, sha256 `c03eeb…` — efficiency-frontier doc); witness bundle
|
||||
has a `MANIFEST.sha256` over every file; provenance fields
|
||||
(`replay_sha256`, `model_sha256`, `calibration_version`, `privacy_mode`) are bound
|
||||
into ablation proof hashes (ADR-145 §2.7) so a metric cannot be quoted without its
|
||||
exact model + calibration + privacy decision.
|
||||
5. **Hardware claims name the hardware.** ADR-147 records RTX 5080 / CUDA 12.8 /
|
||||
PyTorch 2.10.0; nvsim states the Cortex-A53 scaling caveat in the bench header;
|
||||
efficiency-frontier flags ARM validation as pending. Copy this discipline.
|
||||
6. **Witness rows.** Every new proof gains rows in `docs/WITNESS-LOG-028.md`
|
||||
(ADR-145 §5.3 adds W-39…W-41) and the bundle's `source-hashes.txt`.
|
||||
7. **Secret hygiene in evidence.** Bundle logs pass through
|
||||
`scripts/redact-secrets.py` (ADR-110 wave-5 incident note in
|
||||
`generate-witness-bundle.sh` step 4) — external evidence must never embed `.env`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Known Measurement Pitfalls (WiFi-sensing specific)
|
||||
|
||||
| # | Pitfall | Repo evidence | Mitigation in this methodology |
|
||||
|---|---|---|---|
|
||||
| 1 | **Subject leakage / split optimism.** In-domain `random_split` has temporal/subject-adjacency effects; the same model family scores 83.6% random-split but ~11.6% torso-PCK on the leakage-free cross-subject split | efficiency-frontier "Controlled claim" footnote; ADR-150 §1, §3.2 | Always report the split name; publish random-split and cross-subject numbers side by side; cross-subject claims only on the official split |
|
||||
| 2 | **Per-environment overfitting.** Zero-shot cross-environment collapses to 10.6%; subject-scaling saturates ~63.7% past 16–20 subjects because the residual is room/device shift | ADR-150 §3.3, §3.6 | Cross-room degradation + 17-joint heatmap in every ablation (ADR-145 §2.5); claim deployment accuracy only with the calibration protocol stated (K samples, adapter size) |
|
||||
| 3 | **Mock-mode contamination.** Mock firmware missed a real Kconfig threshold bug; the nn crate ships a `mock_inference` criterion group that must never be quoted as pipeline performance | `CLAUDE.md` firmware rule 7; `inference_bench.rs` `bench_mock_inference` | L4 mandatory before firmware release ("Always test with real WiFi CSI, not mock mode"); label mock benches in reports; ADR-147 §7 re-ran the benchmark on real CSI explicitly "no mocks" |
|
||||
| 4 | **Single-run point estimates.** 1.732 m localization from one synthetic geometry; 223 s coverage from an analytic formula | ADR-149 §1, §7 | §3 seed/CI protocol; evidence-grade table before publication |
|
||||
| 5 | **Random-weight / untrained baselines read as results.** OccWorld MDE 9.49 m is a pre-fine-tuning random-weight reading | ADR-147-benchmark-proof §4 | Label baseline-vs-target explicitly; never aggregate untrained-model numbers into capability claims |
|
||||
| 6 | **Latency conflated with quality.** Criterion µs numbers prove no compute bottleneck, nothing about accuracy | ADR-149 §2, §4.3 | L2 is gate-only; quality claims live in L3+ |
|
||||
| 7 | **Floating-point nondeterminism breaking proofs.** SciPy FFT SIMD reordering + multithreaded BLAS produced different hashes across CI microarchitectures | CHANGELOG #560; `calibration_proof_runner.rs` lines 1–13 (cited in ADR-145 §2.3) | Quantize before hashing; pin thread env vars; exclude wall-clock from hashes |
|
||||
| 8 | **Hash churn without procedure.** Three distinct historical values of the proof hash exist (`8c0680d7…` ADR-028, `667eb054…` CHANGELOG #560, `f8e76f21…` current file) | cited files | Every regeneration via `--generate-hash` + re-verify + CHANGELOG entry + witness bundle refresh |
|
||||
| 9 | **Aggregation bugs masking accuracy.** Person count clamped to 1 by EMA mapping; eigenvalue path leaking counts up to 10; both invisible to unit tests for months | CHANGELOG #803, #894 | L5 summary gates on `person_count_changes`/count distributions; convergence tests replaying the live loop |
|
||||
| 10 | **Stale verification claims.** `VERIFY.sh` prints hardcoded "(8/8)" over 10 actual checks; `CLAUDE.md` says "7/7" | `generate-witness-bundle.sh` line 293; `CLAUDE.md` | Compute the verdict count; audit doc claims against scripts each release |
|
||||
| 11 | **Licensing limits on the eval set.** MM-Fi is CC BY-NC — weights trained solely on it cannot back commercial claims | ADR-015 Consequences | Track dataset license alongside every published number |
|
||||
|
||||
---
|
||||
|
||||
## 7. Gap List (what must be built to fully execute this methodology)
|
||||
|
||||
| Gap | Owner layer | Source |
|
||||
|---|---|---|
|
||||
| Machine-readable criterion baseline (`v2/benchmarks/criterion-baseline.json`) + CI comparison job | L2 | §4.2 (numbers currently only in ADR prose) |
|
||||
| Provenance + producer script for `benchmark_baseline.json`; soft-gate job | L5 | §1.3, §4.3 (zero code references today) |
|
||||
| `ruview-cli --ablation mode=auto` wiring + `expected_ablation_<slug>.sha256` (currently placeholders → exit 2) | L3 | ADR-145 implementation status |
|
||||
| Seeded swarm `evals/` harness + `evals/RESULTS.md` internal leaderboard | L3/L5 | ADR-149 §6, §8 open issues |
|
||||
| Fix `VERIFY.sh` hardcoded verdict count; reconcile `CLAUDE.md` "7/7" | L1 | §1.2 |
|
||||
| Curated paired room-A/room-B labeled replay set (frozen, SHA-pinned, never trained on) | L3 | ADR-145 §3.2 |
|
||||
| ARM/edge on-device latency validation for the int4 model (x86-only today) | L4 | efficiency-frontier doc ("Pi fleet pending") |
|
||||
| Bench validation of the antenna-placement matrix on real hardware | L4 | PRODUCTION-ROADMAP.md Tier 2.3 |
|
||||
|
||||
---
|
||||
|
||||
## Update — falsifiable occupancy benchmark implemented
|
||||
|
||||
`wifi-densepose-train::occupancy_bench` (added this branch) makes the
|
||||
presence/person-count claim **falsifiable in code**, directly enforcing the L3
|
||||
discipline above. It grades predictions vs ground truth and gates a SOTA claim
|
||||
behind a single `claim_allowed` invariant that requires **all** of:
|
||||
|
||||
1. `DataProvenance::Measured` — synthetic/mock data is scorable for regression
|
||||
but **never claimable** (anti-mock-contamination; the CLAUDE.md Kconfig-bug
|
||||
lesson made structural).
|
||||
2. A leak-free `EvalSplit` — `validate()` refuses any split where a subject *or*
|
||||
environment id appears in both train and test (subject leakage / per-env
|
||||
overfitting).
|
||||
3. `n_test ≥ min_test_samples` (small-N guard).
|
||||
4. Presence F1 whose **bootstrap-CI lower bound** (deterministic splitmix64,
|
||||
seeded) clears the threshold — not the point estimate.
|
||||
5. Count MAE within threshold.
|
||||
|
||||
The claim string is unreadable except through the gate (returns `NO_CLAIM`
|
||||
otherwise) — same discipline as the `ruview-gamma` acceptance gate. 10 tests
|
||||
cover each refusal path. What remains is *data*, not *method*: feed it a frozen,
|
||||
SHA-pinned, subject/environment-disjoint **measured** replay set (the curated
|
||||
room-A/room-B item above) and the "beyond SOTA" claim becomes a passing or
|
||||
failing test, not a slogan.
|
||||
|
||||
---
|
||||
|
||||
*All values cited from: `benchmark_baseline.json`, `v2/crates/*/benches/*.rs` (15
|
||||
files), `docs/adr/ADR-147-benchmark-proof.md`,
|
||||
`docs/adr/ADR-149-swarm-benchmarking-evaluation-methodology.md`,
|
||||
`docs/adr/ADR-145-ablation-eval-harness-privacy-leakage.md`,
|
||||
`docs/adr/ADR-028-esp32-capability-audit.md`,
|
||||
`docs/adr/ADR-015-public-dataset-training-strategy.md`,
|
||||
`docs/adr/ADR-150-rf-foundation-encoder.md`,
|
||||
`docs/benchmarks/wifi-pose-efficiency-frontier.md`,
|
||||
`scripts/generate-witness-bundle.sh`, `archive/v1/data/proof/verify.py`,
|
||||
`archive/v1/data/proof/expected_features.sha256`, `CHANGELOG.md`, `CLAUDE.md`,
|
||||
`docs/research/sota-2026-05-22/PRODUCTION-ROADMAP.md`.*
|
||||
@@ -0,0 +1,252 @@
|
||||
# RuView Beyond-SOTA — 04: Performance Review & Optimization Roadmap
|
||||
|
||||
**Scope:** the streaming sensing pipeline (CSI ingest → multistatic fusion → CIR gate →
|
||||
pose publish) in `v2/`, hot-path crates `wifi-densepose-signal` (ruvsense),
|
||||
`wifi-densepose-engine`, `wifi-densepose-ruvector`, plus build-profile and edge-target
|
||||
(Pi 5-class, WASM) considerations.
|
||||
|
||||
**Hard constraint (non-negotiable):** the witness chain (ADR-028, ADR-136 §2.5 replay
|
||||
contract, ADR-137 §2.7 BLAKE3 witness in
|
||||
`v2/crates/wifi-densepose-engine/src/lib.rs:437-448`) requires **bit-exact deterministic
|
||||
float output**. Every recommendation below is tagged with its determinism risk. Anything
|
||||
that reorders float additions, enables FMA contraction, fast-math, or parallel reduction
|
||||
**changes the witness hash** and requires a coordinated proof-hash regeneration
|
||||
(`verify.py --generate-hash`) plus witness-bundle re-issue.
|
||||
|
||||
---
|
||||
|
||||
## 1. What we actually have measured (and what we don't)
|
||||
|
||||
`/home/user/RuView/benchmark_baseline.json` is a **signal-quality soak baseline**, not a
|
||||
latency benchmark: 1,566 samples (ticks 51131–52395) of
|
||||
`variance / motion / presence / confidence / est_persons / kp_spread / rssi`, with a
|
||||
summary block (`confidence_mean: 0.643`, `presence_ratio: 0.934`,
|
||||
`kp_spread_mean: 86.7`, `person_count_changes: 10`). **It contains zero timing data.**
|
||||
It is the accuracy guardrail for any optimization (post-change soak must reproduce these
|
||||
distributions), not a latency baseline.
|
||||
|
||||
Latency benchmarks exist but no committed results were found in the repo:
|
||||
|
||||
| Bench | File | What it measures |
|
||||
|---|---|---|
|
||||
| `process_cycle_4nodes_56sc` | `v2/crates/wifi-densepose-engine/benches/engine_cycle.rs:34-48` | One full engine cycle, 4 nodes × 56 subcarriers, vs. the documented 50 ms budget (`engine_cycle.rs:3-6`) |
|
||||
| `cir_bench` | `v2/crates/wifi-densepose-signal/benches/cir_bench.rs` | `CirEstimator::estimate()` per tier (HT20/HT40/HE20/HE40) + 12-link amortization |
|
||||
| `sketch_bench` | `v2/crates/wifi-densepose-ruvector/benches/sketch_bench.rs:86-175` | Hamming sketch vs. float L2/cosine compare; top-K over 1,024-sketch bank |
|
||||
| `signal_bench`, `calibration_bench`, `aether_prefilter_bench` | `v2/crates/wifi-densepose-signal/benches/` | Signal-path and ADR-135 calibration throughput |
|
||||
|
||||
**Action zero of the roadmap is to run these on a Pi 5 and commit the criterion
|
||||
baselines.** All impact classes below are derived from operation counts read out of the
|
||||
code (cited), not invented measurements.
|
||||
|
||||
---
|
||||
|
||||
## 2. Latency budget model — streaming pipeline
|
||||
|
||||
Two clock domains exist and must not be conflated:
|
||||
|
||||
- **TDMA sensing cycle: 20 Hz / 50 ms** — the architecture's own budget
|
||||
(`v2/crates/wifi-densepose-signal/src/ruvsense/mod.rs:5`, `RuvSenseConfig::target_hz =
|
||||
20.0` at `mod.rs:258`, and the bench doc `engine_cycle.rs:3`).
|
||||
- **CSI ingest: 100 Hz per node** — raw frames arrive ~5× faster than the fused output
|
||||
rate; per-frame ingest work (parse, normalize, calibrate, window) must therefore fit a
|
||||
**10 ms** per-frame envelope while the fused path fits **< 50 ms end-to-end**.
|
||||
|
||||
Proposed per-stage budget for the 50 ms end-to-end target (4 nodes, HT20 / 56
|
||||
subcarriers — the configuration the engine bench encodes):
|
||||
|
||||
| # | Stage | Code | Budget | Risk (from code reading) |
|
||||
|---|---|---|---|---|
|
||||
| 1 | Ingest + hardware normalize (per 100 Hz frame) | `hardware_norm`, `multiband.rs` | 2 ms | Low — vector ops on 56 floats |
|
||||
| 2 | Calibration apply (ADR-135) | `ruvsense/calibration.rs` | 2 ms | Low — Welford lookups |
|
||||
| 3 | Phase alignment | `phase_align.rs:117-152` | 1 ms | Low — ≤ 20 iterations over ≤ 17 static subcarriers (`config.max_iterations: 20`, `phase_align.rs:57`); allocation churn only (§3) |
|
||||
| 4 | Multistatic fusion (attention + softmax) | `multistatic.rs:512-598` | 2 ms | Low — O(nodes × 56); but does duplicate work in `fuse_scored` (§3, F2) |
|
||||
| 5 | **CIR gate (ISTA L1)** | `multistatic.rs:440-475` → `cir.rs:601-654` | 15 ms | **HIGH** — dominant cost, scales badly with PHY tier (below) |
|
||||
| 6 | Coherence score + gate decision | `coherence.rs`, `coherence_gate.rs` | 2 ms | Low — z-scores over 56 subcarriers |
|
||||
| 7 | Tomography (ADR-030 tier 2, when enabled) | `tomography.rs:236-323` | 8 ms | **Medium** — per-iteration allocation + loose step size (§3, F8/F9) |
|
||||
| 8 | Pose tracker (17-kp Kalman + re-ID) | `pose_tracker.rs` | 8 ms | Medium — sketch prefilter (ADR-084) already mitigates the re-ID scan |
|
||||
| 9 | Engine: quality score, privacy gate, WorldGraph node, BLAKE3 witness | `engine/src/lib.rs:304-368` | 5 ms | Low per cycle, but **unbounded memory growth** (§4) |
|
||||
| 10 | Publish (WS/serde) | sensing-server | 5 ms | Low |
|
||||
| | **Total** | | **50 ms** | |
|
||||
|
||||
### Why stage 5 is the at-risk stage — operation counts from the code
|
||||
|
||||
`ista_solve` (`cir.rs:601-654`) runs **two dense complex mat-vecs per iteration**
|
||||
(`matvec_phi` at `cir.rs:717-726`, `matvec_phi_h` at `cir.rs:730-745`), each O(K·G)
|
||||
complex MACs (≈ 8 FLOPs each), up to `max_iters: 100` (`cir.rs:176`). Per
|
||||
`CirConfig` (`cir.rs:164-233`):
|
||||
|
||||
| Tier | K (active) | G (taps) | FLOPs/iter (2·K·G·8) | FLOPs @100 iters |
|
||||
|---|---|---|---|---|
|
||||
| HT20 | 52 | 156 | ≈ 0.13 M | ≈ 13 M |
|
||||
| HT40 | 114 | 342 | ≈ 0.62 M | ≈ 62 M |
|
||||
| HE20 | 242 | 726 | ≈ 2.8 M | ≈ 0.28 G |
|
||||
| HE40 | 484 | 1,452 | ≈ 11.2 M | ≈ 1.1 G |
|
||||
|
||||
HT20 fits the 15 ms budget comfortably on a Pi 5; **HE40 at worst-case iteration count
|
||||
is ~1.1 GFLOP of scalar, cache-unfriendly work per estimate and will not fit any 50 ms
|
||||
budget without structural change** (F4 below). Today the gate runs once per cycle on the
|
||||
first link only (`multistatic.rs:452-463`), which contains the damage; the 12-link
|
||||
amortization pattern in `cir_bench.rs` shows the intended scale-up, which multiplies
|
||||
this cost ×12.
|
||||
|
||||
---
|
||||
|
||||
## 3. Findings table — optimization opportunities
|
||||
|
||||
Impact: relative cycle-time/memory effect at the 4-node HT20 operating point unless
|
||||
noted. Determinism: **EXACT** = bit-identical output guaranteed; **TIE** = only
|
||||
tie-breaking/ordering may differ; **CHANGES-FLOATS** = output bits change, witness/proof
|
||||
hash must be regenerated.
|
||||
|
||||
| ID | Finding (file:line) | Impact | Effort | Determinism |
|
||||
|---|---|---|---|---|
|
||||
| F1 | `FusedSensingFrame` deep-copies every input frame each cycle: `node_frames: node_frames.to_vec()` (`multistatic.rs:282`) — clones all per-node amplitude+phase vectors per 50 ms cycle even when downstream geometry consumers don't need them | Med | Low (Arc/Cow or borrow) | EXACT |
|
||||
| F2 | `fuse_scored` re-derives the per-node amplitude views and recomputes `node_attention_weights` after `fuse` already computed them inside `attention_weighted_fusion` (`multistatic.rs:311-321` duplicating `multistatic.rs:520`) — full cosine-sim + softmax done twice per cycle | Low-Med | Low (return weights from `fuse`) | EXACT (same math, computed once) |
|
||||
| F3 | CIR gate rebuilds a heap `CsiFrame` per cycle: `build_csi_frame_from_channel` allocates an `Array2<Complex64>` and converts amplitude/phase via `from_polar` per subcarrier (`multistatic.rs:488-506`, called from `multistatic.rs:462`), then `extract_csi_vector` converts back to `Complex32` (`cir.rs:505-530`) — f32→f64→f32 round-trip plus two allocations purely as glue | Med | Med (give `CirEstimator` a slice-based entry point) | EXACT if conversions reproduce exactly (f32→f64 is lossless; `from_polar` in f64 then truncate ≠ f32 polar — keep the f64 intermediate to stay exact, or accept CHANGES-FLOATS and regenerate hashes) |
|
||||
| F4 | ISTA inner loop uses dense O(K·G) mat-vecs (`cir.rs:717-745`) although Φ is a sub-sampled DFT (`cir.rs:539-558`) — the products Φx and Φᴴr are computable via an FFT of length G in O(G log G), an ~8–40× FLOP cut at HE20/HE40 (table §2) | **High** (the only path to HE40 real-time) | High | **CHANGES-FLOATS** (different summation order than the sequential dot product) — must ship behind a feature flag, A/B against `cir_proof_runner`, regenerate `expected_features.sha256` + witness bundle |
|
||||
| F5 | `neumann_warm_start` recomputes the diagonal of ΦᴴΦ with a full K×G pass **per frame** (`cir.rs:676-681`), rebuilds the COO→CSR diagonal matrix per frame (`cir.rs:683-685`), and collects `rhs_re`/`rhs_im` Vecs per frame (`cir.rs:689-690`) — yet `diag` depends only on Φ, which is fixed at `CirEstimator::new` | Med | Low (precompute diag+CSR in `new()`) | EXACT (same values, computed once) |
|
||||
| F6 | `phase_variance` collects a `Vec<f32>` of phases per call (`cir.rs:792`) — replaceable by a two-pass loop with zero allocation | Low | Low | EXACT |
|
||||
| F7 | Φ and Φᴴ are both stored densely (`cir.rs:546-547`): 2·K·G·8 bytes — Φᴴ entries are just conjugates of Φ (`cir.rs:555`), so a transposed-iteration kernel over Φ alone halves the footprint (HE40: 11.2 MB → 5.6 MB) | Low (latency) / Med (memory §4) | Med | EXACT (conjugation is exact; keep identical accumulation order in the transposed kernel) |
|
||||
| F8 | Tomography allocates the gradient vector **inside** the solver iteration loop: `let mut gradient = vec![0.0_f64; self.n_voxels]` (`tomography.rs:266`) — one heap alloc + zeroing per iteration, up to `max_iterations: 100` (`tomography.rs:75`); hoist and `fill(0.0)` | Med (for tier-2 deployments) | Low | EXACT |
|
||||
| F9 | Tomography step size uses the Frobenius-norm upper bound for the Lipschitz constant (`tomography.rs:253-259`, comment admits `‖WᵀW‖ ≤ ‖W‖_F²`) — a bound loose by up to the matrix rank, forcing proportionally more ISTA iterations than the power-method estimate used in `cir.rs:566-590` | Med | Low (reuse the cir.rs power-method pattern) | **CHANGES-FLOATS** (different step ⇒ different iterate path) |
|
||||
| F10 | `apply_phase_correction` clones the amplitude vector and allocates a fresh corrected-phase Vec per channel per cycle (`phase_align.rs:258-268`, `frame.amplitude.clone()` at `phase_align.rs:264`); `align` additionally `frames.to_vec()`s on the single-channel path (`phase_align.rs:128`) — an in-place `align_mut` avoids all of it | Low-Med | Low | EXACT |
|
||||
| F11 | Static-subcarrier selection fully sorts all subcarriers by variance (`phase_align.rs:180`) where `select_nth_unstable_by` suffices — trivial at 56 subcarriers, relevant at HE tiers (242–484) | Low | Low | **TIE** (equal-variance ties may select a different subcarrier set; pin a stable tie-break on index to stay EXACT) |
|
||||
| F12 | Engine clones each node's amplitude vector for the array coordinator every cycle: `cf.amplitude.clone()` (`engine/src/lib.rs:385`); also allocates a `Vec<Option<CalibrationId>>` per cycle (`lib.rs:293`) and `format!("{e:?}")` strings for every evidence ref (`lib.rs:337`) | Low | Low | EXACT |
|
||||
| F13 | `fuse_scored_calibrated` computes the modal calibration id in O(n²) (`multistatic.rs:404-410`) — harmless at n ≤ 15 nodes, noted for swarm-scale reuse (ADR-148) | Low | Low | EXACT |
|
||||
| F14 | **No `rayon` and no SIMD feature exists anywhere in the hot crates** (grep over `crates/*/Cargo.toml`: zero hits for rayon/simd/target-feature outside wasm-opt flags). The 12-link CIR pattern (`cir_bench.rs:4-5`) and the per-node ingest path are embarrassingly parallel **across independent links/nodes** | High (multi-link tiers) | Med | **EXACT if and only if** parallelism stays at link/node granularity with results collected in deterministic (index) order and no shared float accumulator; intra-link parallel reductions are CHANGES-FLOATS and are banned |
|
||||
| F15 | `Cir::top_k_taps` clones and fully sorts all G taps (`cir.rs:322-332`) — O(G log G) with a G-sized clone; a k-heap (the exact pattern already written in `sketch.rs:546-563`) is O(G log k) | Low | Low | TIE (equal-magnitude ordering; pin index tie-break) |
|
||||
| F16 | Core `CsiFrame` carries `Complex64` while the entire ruvsense DSP path computes in f32 (conversion at `cir.rs:525`) — 2× memory and bandwidth on every ingest for precision the pipeline immediately discards | Med (memory/bandwidth) | High (core type change ripples everywhere) | **CHANGES-FLOATS** at the boundary; defer until a major version |
|
||||
| F17 | Sketch path is already well-optimized: heap-based top-K with n ≤ k fast path (`sketch.rs:536-569`), 28-byte wire format (`sketch.rs:303`). Remaining win is build-level: `count_ones()` only lowers to POPCNT/NEON-vcnt when the target CPU enables it (see §5) | Low | Low | EXACT (integer ops) |
|
||||
|
||||
---
|
||||
|
||||
## 4. Memory-footprint analysis (Pi 5-class and WASM; ESP32 aggregation out of scope)
|
||||
|
||||
**Static, per-process (from struct definitions):**
|
||||
|
||||
| Component | Sizing source | Footprint |
|
||||
|---|---|---|
|
||||
| `CirEstimator` HT20 (Φ + Φᴴ, `Complex32`) | `cir.rs:546-547`, K=52 G=156 | 2 · 52 · 156 · 8 B ≈ **130 KB** |
|
||||
| `CirEstimator` HE20 | K=242 G=726 | ≈ **2.8 MB** |
|
||||
| `CirEstimator` HE40 | K=484 G=1452 | ≈ **11.2 MB** (halvable via F7) |
|
||||
| Tomography weight matrix | `tomography.rs:214-217`, sparse per-link (voxel,weight) pairs; default grid 8×8×4 = 256 voxels (`tomography.rs:70-73`) | tens of KB at default grid |
|
||||
| Sketch bank, 1,024 × 128-d | `sketch.rs` 1 bit/dim | 1,024 · 16 B ≈ **16 KB** (vs 512 KB float) |
|
||||
|
||||
A Pi 5 (4–8 GB) absorbs all of this trivially. The real memory risks are dynamic:
|
||||
|
||||
1. **Unbounded WorldGraph growth (the one genuine leak-class issue).** Every
|
||||
`process_cycle` appends a `SemanticState` node plus a `DerivedFrom` edge
|
||||
(`engine/src/lib.rs:346-352`), and change-points append `Event` nodes
|
||||
(`lib.rs:422-428`). At 20 Hz that is **1.73 M nodes/day** with no eviction anywhere
|
||||
in the engine. `snapshot_json` (`lib.rs:191-193`) then serializes the whole graph.
|
||||
**Required:** a retention/compaction policy (ring buffer or time-windowed rollup of
|
||||
SemanticStates). Determinism caveat: eviction changes snapshot *contents* (a product
|
||||
decision), not float math — the per-cycle witness (`lib.rs:437-448`) is unaffected.
|
||||
2. **Per-cycle allocation churn** (F1, F3, F5, F8, F10, F12): at 20 Hz this is dozens of
|
||||
short-lived heap allocations per cycle. On a Pi 5 this is allocator pressure and
|
||||
cache pollution rather than RSS growth; on WASM (bump-ish dlmalloc, no MADV_FREE) it
|
||||
inflates the linear memory high-water mark, which is never returned to the host.
|
||||
3. **WASM targets.** `wifi-densepose-wasm` is a browser binding crate (JS interop,
|
||||
serde, chrono — `crates/wifi-densepose-wasm/Cargo.toml`) and pulls `wifi-densepose-mat`
|
||||
optionally; it relies on `wasm-opt -O4` (`Cargo.toml` `[package.metadata.wasm-pack]`).
|
||||
`wifi-densepose-wasm-edge` is the disciplined one: `no_std` + `libm`, its own profile
|
||||
`opt-level = "s"`, lto, cgu=1 (`crates/wifi-densepose-wasm-edge/Cargo.toml`). Neither
|
||||
enables `+simd128` (§5). If the CIR estimator is ever compiled to wasm-edge, HE40's
|
||||
11.2 MB of sensing matrix alone is ~700 pages of linear memory — restrict edge WASM
|
||||
to HT20 (130 KB) or ship F4/F7 first.
|
||||
|
||||
---
|
||||
|
||||
## 5. Build-profile review & recommendations
|
||||
|
||||
Current release profile (`v2/Cargo.toml:213-218`) is already aggressive and correct:
|
||||
`opt-level = 3`, `lto = true` (fat), `codegen-units = 1`, `panic = "abort"`,
|
||||
`strip = true`; `bench` inherits release with debug symbols (`v2/Cargo.toml:225-227`).
|
||||
There is nothing wrong to fix here — the gains left are target- and feedback-driven:
|
||||
|
||||
1. **Per-target CPU tuning (EXACT, do first).** No `target-cpu` is set anywhere. For
|
||||
Pi 5 fleet builds: `RUSTFLAGS="-C target-cpu=cortex-a76"` — enables NEON scheduling
|
||||
and `vcnt` for the sketch path (F17) without changing IEEE semantics. LLVM does not
|
||||
reassociate float reductions or contract to FMA without explicit fast-math/contract
|
||||
flags, so scalar float results stay bit-exact. **Verify with the existing proof
|
||||
runners** (`cir_proof_runner`, `calibration_proof_runner`,
|
||||
`signal/Cargo.toml`) as the acceptance gate — that is exactly what they exist for.
|
||||
2. **WASM SIMD.** Add `-C target-feature=+simd128` for `wifi-densepose-wasm` builds and
|
||||
keep a non-SIMD artifact for older runtimes. Same determinism note as above; gate
|
||||
with the proof runners compiled to wasm where feasible.
|
||||
3. **PGO: feasible and determinism-safe.** PGO changes inlining/layout, never FP
|
||||
semantics. The repo already has ideal deterministic training workloads: the proof
|
||||
runner binaries plus `engine_cycle` / `cir_bench`. Pipeline: `cargo pgo build` →
|
||||
run proof runners + benches → `cargo pgo optimize`. Expect mid-single-digit to ~15%
|
||||
on branchy paths (gate decisions, tracker lifecycle); the dense ISTA loop will see
|
||||
little. Cost: CI complexity. Verdict: do it after F1–F12, not before.
|
||||
4. **Do not** enable `-ffast-math`-equivalents (`fadd_fast`, `core::intrinsics`,
|
||||
`-C llvm-args=-fp-contract=fast`) anywhere in the witness path. This must be a
|
||||
stated rule in CONTRIBUTING/ADR, not tribal knowledge.
|
||||
5. **BOLT / `opt-level` experiments are not worth it** ahead of F4; the pipeline is
|
||||
FLOP-bound in one loop, not front-end bound.
|
||||
|
||||
---
|
||||
|
||||
## 6. Prioritized 90-day plan
|
||||
|
||||
### Phase 0 — Measure (days 1–10)
|
||||
- Run and commit criterion baselines on a Pi 5 and an x86 dev box:
|
||||
`engine_cycle`, `cir_bench` (all four tiers), `sketch_bench`, `signal_bench`,
|
||||
`calibration_bench`. The 50 ms claim in `engine_cycle.rs:3` becomes a measured number.
|
||||
- Add a lightweight per-stage timing histogram (feature-gated, off in witness builds) at
|
||||
the §2 stage boundaries; wire a CI perf-regression gate (±10%) on the committed
|
||||
baselines.
|
||||
- Re-run the soak that produced `benchmark_baseline.json` and pin it as the accuracy
|
||||
guardrail for everything below.
|
||||
|
||||
### Phase 1 — Exact, zero-risk wins (days 10–35)
|
||||
All EXACT findings; no witness impact; each lands with proof-runner verification:
|
||||
- F5 (precompute warm-start diag/CSR in `CirEstimator::new`) — biggest exact CIR win.
|
||||
- F8 (hoist tomography gradient buffer), F6, F10, F12, F1, F2 (allocation/duplication
|
||||
removal), F15 + F11 with pinned index tie-breaks.
|
||||
- WorldGraph retention policy (the §4.1 unbounded-growth fix) — design ADR + ring-buffer
|
||||
implementation.
|
||||
- Expected outcome: measurable cycle-time reduction and flat memory under 24 h soak;
|
||||
**identical witness hashes**.
|
||||
|
||||
### Phase 2 — Determinism-managed structural wins (days 35–70)
|
||||
Each behind a feature flag, A/B'd against the legacy path (the `use_cir_gate` A/B switch
|
||||
at `multistatic.rs:103` is the template), with proof-hash regeneration as an explicit,
|
||||
witnessed release event:
|
||||
- **F4: FFT-based Φ/Φᴴ application in ISTA** — the headline item; the only route to
|
||||
HE20/HE40 real-time and the 12-link pattern. Acceptance: cir_bench speedup ≥ 5× at
|
||||
HE20, soak metrics within guardrail, new `expected_features.sha256` published in a
|
||||
fresh witness bundle.
|
||||
- F9 (power-method Lipschitz in tomography) riding the same hash-regen train.
|
||||
- F3 (slice-based CIR entry point), choosing the exact-f64-intermediate variant if the
|
||||
hash train slips.
|
||||
- F14: feature-gated `rayon` across **links/nodes only**, deterministic index-ordered
|
||||
collection; CI must run the determinism test (`engine/src/lib.rs:535-548`
|
||||
`cycle_is_deterministic`) with the feature on.
|
||||
|
||||
### Phase 3 — Platform & toolchain (days 70–90)
|
||||
- Pi 5 `target-cpu=cortex-a76` fleet builds + proof-runner verification (§5.1).
|
||||
- `+simd128` WASM artifact + size budget check for wasm-edge (§5.2, §4.3).
|
||||
- PGO pilot in CI using proof runners as the training corpus (§5.3).
|
||||
- Re-baseline: new criterion numbers, refreshed witness bundle, updated this document's
|
||||
§1 with real measured latencies.
|
||||
|
||||
**Out of 90-day scope, flagged for the architecture backlog:** F16 (Complex64→Complex32
|
||||
in core), F7 (single-matrix Φ kernel — bundle with F4), and HE40-on-edge (blocked on
|
||||
F4+F7).
|
||||
|
||||
---
|
||||
|
||||
## 7. Summary
|
||||
|
||||
The pipeline's only structural latency hazard is the dense ISTA CIR solver
|
||||
(`cir.rs:601-654` + `cir.rs:717-745`): fine at HT20, ~1.1 GFLOP worst-case per estimate
|
||||
at HE40, and slated to run per-link (×12). Everything else is allocation churn and
|
||||
duplicated work that can be removed with **bit-exact** refactors (F1–F12), plus one
|
||||
genuine memory bug-class issue: unbounded WorldGraph growth at 20 Hz
|
||||
(`engine/src/lib.rs:346-352`). The build profile is already optimal; remaining toolchain
|
||||
gains (target-cpu, wasm simd128, PGO) are determinism-safe and cheap. The determinism
|
||||
constraint is workable because the repo already owns the right tools — deterministic
|
||||
proof runners, an A/B gate pattern, and a per-cycle witness — so float-changing
|
||||
optimizations become scheduled, witnessed hash-regeneration events rather than risks.
|
||||
@@ -0,0 +1,298 @@
|
||||
<!--
|
||||
Publication-ready GitHub Gist. Publish as a SECRET gist with your own token:
|
||||
|
||||
gh gist create --desc "RuView Gamma — adaptive sensory neuromodulation" \
|
||||
docs/research/ruview-beyond-sota/GAMMA-GIST.md
|
||||
# (omit --public for a secret/unlisted gist; gh uses your stored PAT/token)
|
||||
|
||||
Or with curl + a fine-grained PAT that has the "gists" scope:
|
||||
curl -H "Authorization: Bearer $GH_TOKEN" \
|
||||
-X POST https://api.github.com/gists \
|
||||
-d '{"public":false,"files":{"ruview-gamma.md":{"content":"...">}}}'
|
||||
|
||||
Do NOT paste the token into this file or any committed file.
|
||||
SEO meta description (use as the gist description):
|
||||
"RuView Gamma: an open, governed engine for adaptive 40 Hz light-and-sound
|
||||
neuromodulation — personalized entrainment with proof discipline, built on
|
||||
RuView WiFi sensing and RuVector learning. Research platform, not a medical
|
||||
device."
|
||||
-->
|
||||
|
||||
# RuView Gamma — Adaptive Sensory Neuromodulation, Done Honestly
|
||||
|
||||
> **TL;DR** — RuView Gamma is the open-source **control brain** for an adaptive
|
||||
> light-and-sound (40 Hz "gamma") neuromodulation device. It personalizes the
|
||||
> stimulation to each person, watches the body as feedback, learns what works,
|
||||
> and **refuses to advertise any benefit it hasn't measured**. The most valuable
|
||||
> thing here is not 40 Hz — it is a governed personalization engine that won't
|
||||
> overpromise.
|
||||
>
|
||||
> **Not a medical device. Not medical advice.** It is a research and engineering
|
||||
> platform. It makes no Alzheimer's, disease, or treatment claims.
|
||||
|
||||
**Keywords:** gamma entrainment, 40 Hz stimulation, GENUS, sensory
|
||||
neuromodulation, WiFi sensing, adaptive personalization, Bayesian optimization,
|
||||
Rust, ESP32, digital therapeutics infrastructure, RuView, RuVector.
|
||||
|
||||
**Projects:** [RuView](https://github.com/ruvnet/ruview) ·
|
||||
RuVector (vector learning / response modeling) ·
|
||||
Branch: `claude/ruview-beyond-sota-xgv8aq` ·
|
||||
Crate: `v2/crates/ruview-gamma` · Firmware: `firmware/esp32-gamma-stim` ·
|
||||
Decision record: `docs/adr/ADR-250-adaptive-gamma-entrainment.md`.
|
||||
|
||||
---
|
||||
|
||||
## 1. The easy introduction
|
||||
|
||||
Research from MIT and others (the "GENUS" line of work) found that sitting in
|
||||
front of a light flickering ~40 times per second, with a matching pulsing
|
||||
sound, can drive a brain rhythm called **gamma** — studied for Alzheimer's,
|
||||
post-stroke recovery, sleep, focus, and mood.
|
||||
|
||||
Today's devices play a **fixed 40 Hz to everyone**. But brains differ: your best
|
||||
frequency might be 38.5, 41.2, or somewhere else, and it changes with how calm,
|
||||
tired, or restless you are. There's no off-the-shelf software that personalizes
|
||||
this **safely and provably**.
|
||||
|
||||
RuView Gamma is that software. Four parts work together:
|
||||
|
||||
| Part | Role | Plain-English job |
|
||||
|------|------|-------------------|
|
||||
| **The device** | Actuator | Plays the light + sound |
|
||||
| **RuView** | Sensing | Reads the body as feedback (breathing, stillness, restlessness) over WiFi — no camera, no wearable |
|
||||
| **RuVector** | Learning | Builds a personal "response map" across sessions |
|
||||
| **RuFlo** | Governance | Safety stops, tamper-evident audit log, and the claim boundary |
|
||||
|
||||
The thesis in one line: **RuView turns the body into the feedback signal,
|
||||
RuVector turns repeated sessions into a personal response map, the device is the
|
||||
actuator, and RuFlo makes the whole loop governed, measurable, and auditable.**
|
||||
|
||||
---
|
||||
|
||||
## 2. How it works
|
||||
|
||||
```
|
||||
enroll (consent + epilepsy/photosensitivity screen)
|
||||
→ start from 40 Hz prior
|
||||
→ play a short calibration sweep (36–44 Hz)
|
||||
→ RuView reads body state each session
|
||||
→ score "safe entrainment" (not raw gamma)
|
||||
→ RuVector updates the personal response map
|
||||
→ Bayesian optimizer recommends the next best safe setting
|
||||
→ every session is witness-hashed into a tamper-evident log
|
||||
```
|
||||
|
||||
Key design choices that make it trustworthy:
|
||||
|
||||
- **40 Hz is a starting guess, not the answer.** A Gaussian-process optimizer
|
||||
searches the safe 36–44 Hz band for *your* peak — and proves it can recover a
|
||||
known peak within ±1 Hz in tests.
|
||||
- **Safety is a hard gate, not a weighted preference.** A latched safety monitor
|
||||
stops on adverse symptoms, a stop request, or low sensor confidence — in about
|
||||
**9 nanoseconds** per check — and once it fires, the session **cannot silently
|
||||
resume**.
|
||||
- **A compiled-in safety envelope** (36–44 Hz, capped brightness/volume/
|
||||
duration) bounds everything. The optimizer can never widen it.
|
||||
- **Cross-person warm-start without identity.** A new user can be seeded from
|
||||
anonymized, one-way-hashed profiles of similar responders — but borrowed
|
||||
expectations are **down-weighted** and never counted as your measured data.
|
||||
- **Tamper-evident proof.** Every session produces a SHA-256 witness over
|
||||
exactly what was played and sensed. Re-running the same inputs reproduces the
|
||||
identical hash — a regulator, clinician, or trial auditor can verify nothing
|
||||
was fudged. The pinned reference witness is `13cb164c…`.
|
||||
|
||||
### The hard claim gate (the important part)
|
||||
|
||||
A program may surface a benefit claim **only** if all four pass:
|
||||
|
||||
```
|
||||
claim_allowed = entrainment_pass AND safety_pass
|
||||
AND adherence_pass AND repeatability_pass
|
||||
```
|
||||
|
||||
Anything less returns `research use only — no claim`. The marketing claim is
|
||||
literally unreadable in the code except through this gate.
|
||||
|
||||
### It's a platform, not one gadget
|
||||
|
||||
Seven programs ship, each with its own safety envelope, objective, state-gating,
|
||||
evidence level, and a single non-disease claim:
|
||||
|
||||
| Program | Evidence | What it tunes for |
|
||||
|---------|----------|-------------------|
|
||||
| Alzheimer's research | medium preclinical / early human | entrainment + trial monitoring |
|
||||
| Post-stroke cognition | early human | gentle, recovery-state tracking |
|
||||
| Sleep optimization | early/plausible | audio-only, near-dark, timed to sleep state |
|
||||
| Attention / working memory | mixed | personal frequency discovery |
|
||||
| Mood / arousal | early human | calming response, avoid overstimulation |
|
||||
| Home wellness | speculative | safe personalization, no treatment claim |
|
||||
| Drug + device trial infra | strong (as infrastructure) | governed, reproducible measurement |
|
||||
|
||||
---
|
||||
|
||||
## 3. Research supporting it (and its honest limits)
|
||||
|
||||
- **Preclinical (strongest):** a 2024 *Nature* paper showed 40 Hz multisensory
|
||||
stimulation promoted cerebrospinal-fluid influx and amyloid clearance via the
|
||||
glymphatic system in Alzheimer's-model mice; blocking that clearance abolished
|
||||
the effect.
|
||||
- **Early human:** a 2022 study found 40 Hz sensory stimulation feasible and
|
||||
well-tolerated in mild Alzheimer's, with exploratory signals on structure,
|
||||
connectivity, sleep, and memory. A small 2025 two-year pilot reported safety
|
||||
and feasibility, but the sample was tiny and not definitive.
|
||||
- **Frequency is not one-size-fits-all:** a 2025 *PLOS One* study re-evaluated
|
||||
gamma frequency across 36–44 Hz — direct motivation for *measuring* the
|
||||
individual's frequency rather than assuming 40 Hz.
|
||||
- **Adjacent areas (early/mixed):** post-stroke cognition, sleep (40 Hz evoked
|
||||
without degrading sleep), attention/working-memory (mixed, protocol-dependent),
|
||||
and mood/arousal.
|
||||
|
||||
**Honest limits, encoded as non-goals:** RF sensing does not measure amyloid;
|
||||
personalized frequency improving clinical outcomes is unproven; consumer use
|
||||
without screening is not safe; 40 Hz is not always optimal. The software makes
|
||||
none of these claims.
|
||||
|
||||
---
|
||||
|
||||
## 4. How to use it
|
||||
|
||||
### Run the governed engine (Rust)
|
||||
|
||||
```bash
|
||||
git clone https://github.com/ruvnet/ruview
|
||||
cd ruview && git checkout claude/ruview-beyond-sota-xgv8aq
|
||||
cd v2
|
||||
cargo test -p ruview-gamma --no-default-features # 97 tests + 1 doctest
|
||||
cargo bench -p ruview-gamma --no-default-features # criterion micro-benchmarks
|
||||
```
|
||||
|
||||
```rust
|
||||
use ruview_gamma::{
|
||||
ruflo::{Consent, RufloGovernor},
|
||||
program::NeuroProgram,
|
||||
response::RuViewState,
|
||||
simulator::{LatentPerson, ResponseSimulator},
|
||||
stimulus::StimulusParameters,
|
||||
};
|
||||
|
||||
let mut gov = RufloGovernor::enroll_program(
|
||||
"subject-001", NeuroProgram::sleep_optimization(), &[], Consent::Granted,
|
||||
).expect("cleared to participate");
|
||||
|
||||
let sim = ResponseSimulator::new(42); // deterministic stand-in for hardware
|
||||
let latent = LatentPerson::from_id("subject-001");
|
||||
let state = RuViewState::calm_baseline();
|
||||
gov.run_calibration(&sim, &latent, &state, 5.0, 0).unwrap();
|
||||
|
||||
let rec = gov.recommend(&gov.prior()); // always inside the safety envelope
|
||||
```
|
||||
|
||||
### Run the device (ESP32)
|
||||
|
||||
```bash
|
||||
cd firmware/esp32-gamma-stim
|
||||
# Host-side safety-core tests — no hardware, no ESP-IDF:
|
||||
gcc -Wall -Wextra -Werror -O2 -I main tests/test_stim_core.c main/stim_core.c -o /tmp/t && /tmp/t
|
||||
# On hardware (ESP-IDF v5.2+):
|
||||
idf.py set-target esp32s3 && idf.py build flash monitor
|
||||
```
|
||||
|
||||
Serial protocol (frequency in millihertz, so 40.0 Hz = `40000`):
|
||||
|
||||
```
|
||||
START 40000 30 28 600 # 40 Hz, 30% brightness, 28% volume, 10 min
|
||||
STOP | STATUS | UNLOCK | VERSION
|
||||
```
|
||||
|
||||
### Benchmarks (indicative)
|
||||
|
||||
| Path | Time | Role |
|
||||
|------|------|------|
|
||||
| Safety tick | ~8 ns | real-time stop path |
|
||||
| Recommendation | ~15 µs | per-session decision |
|
||||
| Cohort kNN (500 profiles) | ~15 µs | warm-start matching |
|
||||
| Calibration sweep | ~115 µs | setup/tuning |
|
||||
| Full acceptance grading | ~425 µs | enrollment only |
|
||||
|
||||
---
|
||||
|
||||
## 5. Cautions (read these)
|
||||
|
||||
- ⚠️ **Flicker can trigger seizures.** Epilepsy and photosensitivity are **hard
|
||||
exclusions** in software; a hardware e-stop is mandatory for any human-facing
|
||||
run. Migraine/psychiatric instability/implanted neuro devices require
|
||||
clinical supervision.
|
||||
- ⚠️ **Not a treatment.** No Alzheimer's/disease/efficacy claim. The only
|
||||
product claim is "personalized entrainment optimization," and even that is
|
||||
gated behind measured entrainment + safety + adherence + repeatability.
|
||||
- ⚠️ **No hardware actuation in the core crate.** The Rust crate is a validated
|
||||
decision/safety/learning/audit engine tested against a simulator; the ESP32
|
||||
firmware is the actuator. Real EEG validation is a separate, deferred step.
|
||||
- ⚠️ **Optical safety is the integrator's job.** The firmware caps PWM duty, but
|
||||
absolute luminance/eye-safety belongs to the LED driver and optical design.
|
||||
- ⚠️ **Research/IRB context required** for any study with human subjects,
|
||||
especially clinical populations.
|
||||
|
||||
---
|
||||
|
||||
## 6. Advanced usages
|
||||
|
||||
- **Drug + device trials (strongest near-term use):** use RuFlo as the governed
|
||||
measurement layer — consent, inclusion/exclusion, **sham/blinding**, per-session
|
||||
witness hashes, and clinician export — to make *someone else's* therapy trial
|
||||
auditable and reproducible. The value is the instrument, not a therapy claim.
|
||||
- **Sham-controlled studies:** `TrialMode::Sham` logs the participant-facing
|
||||
protocol while delivering no entrainment, for blinded arms.
|
||||
- **Cohort transfer learning:** export anonymized response profiles and
|
||||
warm-start new participants via RuVector kNN — privacy-preserving, one-way
|
||||
hashed, never identity-bearing.
|
||||
- **Drift-triggered recalibration:** a Welford-centroid drift detector flags when
|
||||
a person's physiology has shifted enough to warrant re-running calibration.
|
||||
- **Hardware-in-the-loop acceptance:** capture LED frequency, A/V sync, and stop
|
||||
latency on the bench and grade them with `hil::verify_hil` against fixed
|
||||
targets (±0.1 Hz, <5 ms, <100 ms, 100% hash reproducibility, ≥20% EEG lift).
|
||||
- **Edge deployment:** the engine is dependency-light and deterministic; an HNSW
|
||||
(RuVector) backend drops in for cohort search past ~10⁵ profiles.
|
||||
|
||||
---
|
||||
|
||||
## 7. Credits
|
||||
|
||||
- **RuView** — WiFi/RF human sensing platform that supplies the passive body
|
||||
feedback signal. https://github.com/ruvnet/ruview
|
||||
- **RuVector** — vector learning / response-curve modeling (cohort warm-start,
|
||||
drift detection, clustering; HNSW-ready).
|
||||
- **RuFlo** — governance, audit, consent, and protocol execution layer.
|
||||
- Built by the RuView contributors on branch
|
||||
`claude/ruview-beyond-sota-xgv8aq`. Decision record: ADR-250.
|
||||
- Scientific inspiration: the GENUS / 40 Hz gamma-entrainment research community
|
||||
(MIT Tsai/Boyden labs and others). This project implements *engineering and
|
||||
governance*, not their clinical findings.
|
||||
|
||||
---
|
||||
|
||||
## 8. FAQ (SEO)
|
||||
|
||||
**What is gamma entrainment?** Driving ~40 Hz brain rhythms with rhythmic light
|
||||
and/or sound. RuView Gamma personalizes the exact frequency per person.
|
||||
|
||||
**Is RuView Gamma a medical device?** No. It is an open research and engineering
|
||||
platform that makes no treatment or disease claims.
|
||||
|
||||
**Does it cure or treat Alzheimer's?** No. It optimizes and audits stimulation
|
||||
protocols; clinical outcomes are explicitly out of scope.
|
||||
|
||||
**Can I run it on an ESP32?** Yes — `firmware/esp32-gamma-stim` drives the LED +
|
||||
audio flicker with a hardware emergency stop and a compiled-in safety envelope.
|
||||
|
||||
**Why is it "honest"?** A program's benefit claim is unreadable in code until it
|
||||
passes measured entrainment, safety, adherence, and repeatability.
|
||||
|
||||
**License / source:** see the RuView repository, branch
|
||||
`claude/ruview-beyond-sota-xgv8aq`.
|
||||
|
||||
---
|
||||
|
||||
*RuView Gamma — personalized neural-rhythm optimization with tamper-evident
|
||||
proof. Not a medical claim. Not a consumer miracle device. A tested, safety-first
|
||||
engine ready for hardware, EEG validation, and serious clinical research.*
|
||||
@@ -0,0 +1,96 @@
|
||||
# RuView Beyond-SOTA Research Series
|
||||
|
||||
Research swarm output (2026-06-09) defining what a beyond-state-of-the-art
|
||||
RuView implementation is, what the current system actually delivers, and the
|
||||
validation/benchmark/optimization evidence gathered in the same session.
|
||||
|
||||
Produced by a 5-agent hierarchical research swarm (system reviewer, SOTA
|
||||
surveyor, architect, benchmark methodologist, performance analyst) plus a
|
||||
validation pass run against the working tree.
|
||||
|
||||
## Documents
|
||||
|
||||
| Doc | Scope | One-line takeaway |
|
||||
|-----|-------|-------------------|
|
||||
| [00-system-review.md](00-system-review.md) | Capability audit of the current engine | Signal layer is the deepest asset (`ruvsense/` ≈14.4k lines, 310 in-module tests); the model tier is the emptiest (no trained checkpoint in-tree); the live 20 Hz path is the main integration gap |
|
||||
| [01-sota-landscape-2026.md](01-sota-landscape-2026.md) | Published SOTA per capability axis (web-verified) | Defines the beyond-SOTA bar: 12-row capability → published SOTA → RuView-today → target table; IEEE 802.11bf-2025 is ratified and moves the moat up-stack |
|
||||
| [02-beyond-sota-architecture.md](02-beyond-sota-architecture.md) | Target architecture | 8 pillars (RF foundation encoder + UQ heads, differentiable RF forward model, RF-SLAM×WorldGraph loop, camera→RF distillation, swarm apertures, continual adaptation, deterministic WASM edge, NV fusion) — all landing inside existing crates, no rewrite (per ADR-136 §2.1) |
|
||||
| [03-benchmark-validation-methodology.md](03-benchmark-validation-methodology.md) | Test/validation/benchmark methodology | 6-layer validation pyramid; 15 criterion bench targets inventoried; `benchmark_baseline.json` is a live-capture anchor, not a criterion baseline; statistical protocol from ADR-149 (≥10 seeds, IQM, bootstrap CIs) |
|
||||
| [04-optimization-roadmap.md](04-optimization-roadmap.md) | Performance review + 90-day plan | ISTA CIR solver is the dominant latency hazard (~1.1 GFLOP/frame at HE40); exact zero-risk wins identified; WorldGraph grows unboundedly (no eviction) — a real bug-class |
|
||||
|
||||
## Validation results (this session, 2026-06-09)
|
||||
|
||||
All measured on this branch (`claude/ruview-beyond-sota-xgv8aq`), Linux
|
||||
container, `cargo test --workspace --exclude wifi-densepose-desktop
|
||||
--no-default-features` (the desktop crate needs GTK system libraries absent in
|
||||
the container; this is an environment limitation, not a code failure).
|
||||
|
||||
| Layer | Command | Result |
|
||||
|-------|---------|--------|
|
||||
| L0 unit/integration | `cargo test --workspace --exclude wifi-densepose-desktop --no-default-features` | **154 suites, 2,797 passed, 0 failed** (pre-optimization baseline; re-run post-optimization also green) |
|
||||
| L1 deterministic proof | `python archive/v1/data/proof/verify.py` | **VERDICT: PASS** — hash `f8e76f21a0f9852b70b6d9dd5318239f6b20cbcb4cdd995863263cecdc446f7a` (bit-exact) |
|
||||
| L2 criterion (CIR) | `cargo bench -p wifi-densepose-signal --bench cir_bench --no-default-features --features cir` | Baselines captured pre/post optimization (below) |
|
||||
|
||||
~~Known pre-existing issue (not introduced here): `cargo check -p
|
||||
wifi-densepose-mat --no-default-features` fails standalone with 101 serde
|
||||
feature-unification errors; it builds and passes inside `--workspace` runs.~~
|
||||
**Fixed on this branch:** `pub mod api` (the only serde user) is now gated
|
||||
behind the `api` feature that owns the optional serde dependency; all feature
|
||||
combos compile.
|
||||
|
||||
## Optimizations applied (this session)
|
||||
|
||||
Two **exact** (bit-identical float results — summation order unchanged,
|
||||
witness chain unaffected) optimizations from the 04 roadmap's "zero-risk"
|
||||
tier were implemented and verified:
|
||||
|
||||
1. **`cir.rs` warm-start precompute** — the diagonal Tikhonov preconditioner
|
||||
`diag(Φ^H Φ) + λI` and its CSR matrix depend only on Φ and λ (fixed at
|
||||
`CirEstimator::new`) but were rebuilt on every frame (O(K·G) pass + CSR
|
||||
allocation). Moved to construction
|
||||
(`crates/wifi-densepose-signal/src/ruvsense/cir.rs`,
|
||||
`build_warm_start_system`).
|
||||
2. **`tomography.rs` solver hoisting** — the ISTA gradient `Vec` was
|
||||
allocated inside the 100-iteration loop and the Frobenius Lipschitz bound
|
||||
recomputed per `reconstruct` call; both hoisted
|
||||
(`crates/wifi-densepose-signal/src/ruvsense/tomography.rs`).
|
||||
|
||||
### Measured impact (criterion, paired pre/post baselines, same container)
|
||||
|
||||
| Bench | Pre-opt | Post-opt | Change | Significant? |
|
||||
|-------|---------|----------|--------|--------------|
|
||||
| `cir_estimate/he40` | 12.34 ms | 11.86 ms | **−3.9 %** | yes (p < 0.01) |
|
||||
| `cir_multiband_3band` (30 ms group) | 30.16 ms | 29.72 ms | −1.4 % | yes (p < 0.01) |
|
||||
| `cir_multiband` (142 ms group) | 141.9 ms | 140.1 ms | −1.2 % | yes (p < 0.01) |
|
||||
| `cir_estimate/ht40` | 11.73 ms | 11.78 ms | +0.4 % | no (p = 0.28) |
|
||||
| `cir_estimate/he20` | 2.49 ms | 2.49 ms | −0.1 % | no (p = 0.85) |
|
||||
| `cir_estimate/ht20` | 2.48 ms | 2.58 ms | +3.8 % | noise — see note |
|
||||
|
||||
Note on ht20: `cir_estimator_new/ht20` (construction, which now does strictly
|
||||
*more* work) also shows "+3 %", establishing a ≈3–4 % container noise floor;
|
||||
the ht20 estimate delta is within it. The honest summary: the warm-start
|
||||
precompute removes 1 of ~101 O(K·G) passes per frame, so the expected gain is
|
||||
≈1–4 % — consistent with what was measured. The dominant per-frame cost is
|
||||
the 100-iteration ISTA loop itself, which is exactly what the roadmap's
|
||||
flag-gated FFT-operator proposal (8–40× on the mat-vecs, requires witnessed
|
||||
hash regeneration) targets next.
|
||||
|
||||
Correctness post-optimization: `wifi-densepose-signal` 456 tests green;
|
||||
`wifi-densepose-engine` 11/11 green including `cycle_is_deterministic` and
|
||||
`calibration_mismatch_demotes_and_witness_stable` (witness-chain stability).
|
||||
|
||||
## Headline conclusions
|
||||
|
||||
1. **"Beyond SOTA" is currently unfalsifiable** without a real-CSI
|
||||
ground-truth benchmark — standing one up (per doc 03's acceptance table
|
||||
and ADR-149's statistical protocol) is the highest-leverage next step.
|
||||
2. **The path is evolution, not rewrite**: all eight architecture pillars in
|
||||
doc 02 land inside existing crates on the ADR-136 `Stage<I,O>`/`FrameMeta`
|
||||
contract spine.
|
||||
3. **The biggest engineering gaps** are the live 20 Hz ingest path, a trained
|
||||
RF encoder checkpoint, and WorldGraph retention/eviction — ahead of any
|
||||
frontier capability work.
|
||||
4. **Determinism is the differentiator**: every optimization and new pillar
|
||||
must preserve the witness chain; the advisory-vs-witnessed split (doc 02
|
||||
§determinism) is the mechanism that lets frontier components in without
|
||||
breaking it.
|
||||
@@ -0,0 +1,9 @@
|
||||
# ESP32 Gamma Stimulation Firmware (ADR-250 §21 M2 device harness)
|
||||
# Requires ESP-IDF v5.2+
|
||||
cmake_minimum_required(VERSION 3.16)
|
||||
|
||||
file(STRINGS "${CMAKE_CURRENT_LIST_DIR}/version.txt" PROJECT_VER LIMIT_COUNT 1)
|
||||
string(STRIP "${PROJECT_VER}" PROJECT_VER)
|
||||
|
||||
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
|
||||
project(esp32-gamma-stim VERSION ${PROJECT_VER})
|
||||
@@ -0,0 +1,130 @@
|
||||
# esp32-gamma-stim — ESP32 gamma stimulation actuator (ADR-250 §21 M2)
|
||||
|
||||
The **device harness** for `ruview-gamma`: an ESP32 that drives a light + sound
|
||||
flicker at a commanded frequency, gated by a hardware emergency stop, with a
|
||||
compiled-in safety envelope that mirrors `SafetyEnvelope::conservative()` in the
|
||||
Rust crate. This is the actuator the `hil::verify_hil` contract grades.
|
||||
|
||||
> **Not a medical device.** Research/engineering harness. The host
|
||||
> (`ruview-gamma`) decides *what* to play and never claims a therapeutic effect;
|
||||
> this firmware only plays it safely and reports exactly what it did.
|
||||
|
||||
## Design: safety core vs hardware binding
|
||||
|
||||
| File | Role | Tested |
|
||||
|------|------|--------|
|
||||
| `main/stim_core.{h,c}` | Pure C safety core: envelope validation, START/STOP/e-stop **latched** state machine, exact integer timing math, line protocol parser. No ESP-IDF deps. | `tests/test_stim_core.c` on the host (gcc), 15 tests |
|
||||
| `main/main.c` | ESP-IDF binding: GPTimer ISR toggling the **gate GPIO** (envelope + hard off path), LEDC PWM carriers (LED + audio, task-context only), sync GPIO, e-stop ISR, dead-man watchdog, USB-CDC console. Only moves registers. | on hardware (HIL) |
|
||||
|
||||
Every safety decision lives in the host-tested core — **defense in depth**: the
|
||||
Rust host gates the stimulus *and* the device gates it again independently, so a
|
||||
buggy or compromised host still cannot command an out-of-envelope output.
|
||||
|
||||
## Output gate wiring (hard off path) — REQUIRED
|
||||
|
||||
The LEDC driver APIs are **not ISR-safe** (non-IRAM, non-ISR internal locking),
|
||||
so the firmware never touches LEDC from an interrupt. Instead the emitted
|
||||
output is the **hardware AND** of two signals:
|
||||
|
||||
1. **LEDC carrier** (LED 19.5 kHz PWM / audio tone) at a *constant* duty for
|
||||
the whole session, programmed from task context at START.
|
||||
2. **Gate GPIO** (`GAMMA_STIM_GATE_GPIO`, default 10) — a plain GPIO toggled
|
||||
by the half-period ISR with a direct register write. It delivers the
|
||||
36–44 Hz envelope *and* is the hard real-time off path: the e-stop ISR and
|
||||
the dead-man watchdog force it low in microseconds.
|
||||
|
||||
Wiring requirements:
|
||||
|
||||
- Route the gate pin to the **enable input of the LED driver and the audio
|
||||
amplifier** (or a series MOSFET that interrupts both). Output must be
|
||||
physically impossible unless `gate == high` *and* the carrier is running.
|
||||
- Add an **external pulldown (~10 kΩ) on the gate pin** so the drivers stay
|
||||
disabled during the boot/reset window while the pin floats, and on firmware
|
||||
crash/brownout.
|
||||
- Avoid strapping pins for the gate (e.g. GPIO8/GPIO9 on ESP32-C6).
|
||||
|
||||
Without this wiring the LED pin would carry the bare carrier for the whole
|
||||
session (no envelope) — the gate is not optional.
|
||||
|
||||
## Independent dead-man watchdog
|
||||
|
||||
The session-duration cap is enforced by the same GPTimer ISR that toggles the
|
||||
gate, so a stalled/killed ISR could otherwise leave the envelope ON. A separate
|
||||
250 ms FreeRTOS software timer checks that `elapsed_half_periods` advanced
|
||||
since its last tick whenever the state is RUNNING (worst-case half-period is
|
||||
13.9 ms @ 36 Hz, so a healthy session advances ~18× per check). On a stall it
|
||||
forces the gate low and latches a **FAULT** — same latch semantics as the
|
||||
e-stop: the device prints the session record plus `EVT fault_latched`, and
|
||||
refuses START until `UNLOCK`.
|
||||
|
||||
## Run the safety-core tests (no hardware, no ESP-IDF)
|
||||
|
||||
```bash
|
||||
cd firmware/esp32-gamma-stim
|
||||
gcc -Wall -Wextra -Werror -O2 -I main tests/test_stim_core.c main/stim_core.c -o /tmp/test_stim && /tmp/test_stim
|
||||
# -> all 15 stim_core tests passed
|
||||
```
|
||||
|
||||
## Build & flash (ESP-IDF v5.2+)
|
||||
|
||||
```bash
|
||||
idf.py set-target esp32s3 # or esp32c6
|
||||
idf.py menuconfig # Gamma Stimulation -> pins, tone freq
|
||||
idf.py build flash monitor
|
||||
```
|
||||
|
||||
Default pins (Kconfig-overridable): LED GPIO 4, audio GPIO 5, sync-out GPIO 6,
|
||||
e-stop button GPIO 7 (to GND, active-low), output gate GPIO 10 (to the
|
||||
driver/amp enables, with external pulldown — see "Output gate wiring").
|
||||
|
||||
## Host protocol (line-based, 115200, USB-CDC/UART0)
|
||||
|
||||
```
|
||||
START <freq_mhz> <brightness_pct> <volume_pct> <duration_s>
|
||||
STOP
|
||||
STATUS
|
||||
UNLOCK # clear a latched e-stop/fault
|
||||
VERSION
|
||||
```
|
||||
|
||||
`UNLOCK` is refused with `ERR estop_button_pressed` while the physical e-stop
|
||||
button is still held down — release the button first. (A host retry loop can
|
||||
therefore never resume a session against a pressed button.) Lines longer than
|
||||
95 characters are discarded in full (`ERR parse_error`, then everything up to
|
||||
the next newline is swallowed).
|
||||
|
||||
Frequency is **millihertz** (40.0 Hz = `40000`) so the ±0.1 Hz HIL target is
|
||||
exact integer math (±100 mHz). Example — 40.0 Hz, 30% brightness, 28% volume,
|
||||
10 min:
|
||||
|
||||
```
|
||||
> START 40000 30 28 600
|
||||
OK start seq=1 half_period_us=12500
|
||||
... (session runs) ...
|
||||
SESSION {"seq":1,"freq_mhz":40000,"brightness_pct":30,"volume_pct":28,"duration_s":600,"half_periods":48000,"stop":"completed","fw":"0.1.0"}
|
||||
```
|
||||
|
||||
The `SESSION {...}` line is canonical (quantized integers, fixed field order) so
|
||||
the host pairs it with the RuFlo session builder to reproduce the witness hash
|
||||
(HIL: 100% hash reproducibility).
|
||||
|
||||
## How it maps to the HIL targets (`v2/crates/ruview-gamma/src/hil.rs`)
|
||||
|
||||
| HIL target | How this firmware meets it |
|
||||
|------------|----------------------------|
|
||||
| LED frequency ±0.1 Hz (incl. worst case over the session) | GPTimer at 1 MHz crystal-derived ticks; half-period from exact integer division; worst-case truncation at 44 Hz is ~3 mHz (35× inside budget) |
|
||||
| A/V sync < 5 ms | one **gate GPIO** enables both the LED driver and the audio amp; on/off skew is zero by construction |
|
||||
| Stop → actuator off < 100 ms | e-stop GPIO ISR drives the gate GPIO low with a **direct register write** (no driver calls in ISR context) — microseconds; LEDC shutdown follows in task context as defense in depth |
|
||||
| Stalled-ISR containment | independent 250 ms dead-man timer latches FAULT and forces the gate low if the half-period ISR stops advancing |
|
||||
| Half-period jitter | gate toggled in a GPTimer alarm ISR with auto-reload; jitter is ISR latency (µs-scale), measured at the sync pin |
|
||||
| Session-hash reproducibility 100% | canonical integer `SESSION {...}` record, no float formatting, deduped (one record per session seq — STOP-while-idle or e-stop-after-completion never re-print) |
|
||||
| EEG lift ≥ 20% vs fixed 40 Hz | provided by the host's adaptive optimizer choosing the frequency this firmware plays |
|
||||
|
||||
## Hardware notes
|
||||
|
||||
- **Drive the LED through a MOSFET/constant-current driver**, not the GPIO
|
||||
directly. Keep brightness within eye-safe flicker limits — the firmware caps
|
||||
duty at the envelope's 40%, but the optical design owns absolute luminance.
|
||||
- **Photosensitivity/epilepsy is a hard exclusion** at the host
|
||||
(`ExclusionScreen`); the device is the last line, not the only line.
|
||||
- The e-stop button is mandatory for any human-facing bench run.
|
||||
@@ -0,0 +1,5 @@
|
||||
idf_component_register(
|
||||
SRCS "main.c" "stim_core.c"
|
||||
INCLUDE_DIRS "."
|
||||
REQUIRES driver esp_timer
|
||||
)
|
||||
@@ -0,0 +1,55 @@
|
||||
menu "Gamma Stimulation (ADR-250)"
|
||||
|
||||
config GAMMA_STIM_LED_GPIO
|
||||
int "LED PWM output GPIO"
|
||||
default 4
|
||||
help
|
||||
GPIO driving the LED MOSFET/driver. The 36-44 Hz stimulus is the
|
||||
envelope on a 19.5 kHz PWM carrier.
|
||||
|
||||
config GAMMA_STIM_AUDIO_GPIO
|
||||
int "Audio tone output GPIO"
|
||||
default 5
|
||||
help
|
||||
GPIO driving the speaker/buzzer amplifier input (square tone,
|
||||
envelope-gated). Use an analog volume stage for real loudness control.
|
||||
|
||||
config GAMMA_STIM_SYNC_GPIO
|
||||
int "Sync-out GPIO (HIL measurement)"
|
||||
default 6
|
||||
help
|
||||
Mirrors the stimulation envelope for photodiode/logic-analyzer
|
||||
verification of the +/-0.1 Hz and <5 ms A/V sync HIL targets.
|
||||
|
||||
config GAMMA_STIM_ESTOP_GPIO
|
||||
int "Emergency-stop button GPIO (active low)"
|
||||
default 7
|
||||
help
|
||||
Button to GND. Falling edge latches an emergency stop: the output
|
||||
gate GPIO is driven low in the ISR (direct register write), sessions
|
||||
refused until UNLOCK. UNLOCK is refused while the button is still
|
||||
held. Stop latency target <100 ms; the gate write achieves
|
||||
microseconds.
|
||||
|
||||
config GAMMA_STIM_GATE_GPIO
|
||||
int "Output-enable gate GPIO (hard off path)"
|
||||
default 10
|
||||
help
|
||||
Plain GPIO that enables BOTH the LED driver and the audio amplifier
|
||||
(hardware AND with the LEDC carriers). The half-period ISR toggles it
|
||||
to deliver the 36-44 Hz envelope, and the e-stop ISR / dead-man
|
||||
watchdog force it low with a direct register write — the <100 ms
|
||||
stop guarantee rests on this pin, NOT on the LEDC driver. Wire it to
|
||||
the driver/amplifier enable inputs with an external pulldown so the
|
||||
outputs stay disabled while the pin floats during boot/reset. Avoid
|
||||
strapping pins (e.g. GPIO8/GPIO9 on ESP32-C6).
|
||||
|
||||
config GAMMA_STIM_AUDIO_TONE_HZ
|
||||
int "Audio tone carrier frequency (Hz)"
|
||||
range 200 2000
|
||||
default 440
|
||||
help
|
||||
The audible tone gated at the stimulation frequency. GENUS-style
|
||||
protocols use a click/tone burst per cycle.
|
||||
|
||||
endmenu
|
||||
@@ -0,0 +1,551 @@
|
||||
/*
|
||||
* esp32-gamma-stim — ESP-IDF hardware binding for the gamma stimulation core.
|
||||
*
|
||||
* Architecture (ADR-250 §21 M2 device harness, HIL targets in
|
||||
* v2/crates/ruview-gamma/src/hil.rs):
|
||||
*
|
||||
* GPTimer (1 MHz, crystal-derived) ─ ISR every half-period
|
||||
* ├── GATE: plain GPIO output-enable, toggled with a direct register
|
||||
* │ write (gpio_ll, IRAM-resident). Carries the 36-44 Hz
|
||||
* │ envelope AND the hard real-time off path.
|
||||
* └── SYNC: bare GPIO mirroring the envelope (logic-analyzer capture)
|
||||
*
|
||||
* LEDC (LED 19.5 kHz carrier, audio tone carrier) runs at a CONSTANT duty
|
||||
* for the whole session, programmed from task context only. The emitted
|
||||
* output is the hardware AND of (LEDC carrier duty) and (GATE asserted):
|
||||
* the gate pin must drive the enable of the LED driver / audio amplifier
|
||||
* (see README "Output gate wiring"). ISRs never call LEDC driver APIs —
|
||||
* they take a non-ISR spinlock and are not IRAM-resident, so calling them
|
||||
* from an ISR is undefined behavior. The stop guarantee rests on a single
|
||||
* GPIO register write instead.
|
||||
*
|
||||
* E-STOP button ─ GPIO ISR: gate low FIRST (register write, microseconds),
|
||||
* then latch LOCKED (stim_core) and queue an event; the main task zeroes
|
||||
* the LEDC duty afterwards (defense in depth, not the stop path).
|
||||
*
|
||||
* DEAD-MAN ─ independent 250 ms FreeRTOS software timer: while RUNNING the
|
||||
* half-period ISR must advance elapsed_half_periods every <= 13.9 ms
|
||||
* (36 Hz worst case); if a dead-man tick sees no progress the ISR/timer
|
||||
* has died with the envelope possibly ON, so it forces the gate low and
|
||||
* latches a FAULT (same latch semantics as the e-stop).
|
||||
*
|
||||
* GPTimer ownership: ONLY the console task starts/stops the GPTimer
|
||||
* (handle_start / STOP). ISRs and the dead-man never call gptimer
|
||||
* functions; after completion/e-stop/fault the timer may idle-tick
|
||||
* harmlessly (state != RUNNING -> gate stays low) until the next host
|
||||
* command reconfigures it. This keeps every driver call in task context.
|
||||
*
|
||||
* Host protocol: line-based over USB-CDC/UART0 console at 115200
|
||||
* (START/STOP/STATUS/UNLOCK/VERSION — see stim_core.h). Every session
|
||||
* ends with one "SESSION {...}" JSON line for the host to witness-hash.
|
||||
* UNLOCK is refused while the e-stop button is still held down.
|
||||
*
|
||||
* All safety decisions (envelope, latch, session math) are in stim_core.c,
|
||||
* which is unit-tested on the host. This file only moves registers.
|
||||
*/
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
#include "freertos/queue.h"
|
||||
#include "freertos/timers.h"
|
||||
|
||||
#include "driver/gptimer.h"
|
||||
#include "driver/ledc.h"
|
||||
#include "driver/gpio.h"
|
||||
#include "hal/gpio_ll.h"
|
||||
#include "soc/gpio_struct.h"
|
||||
#include "esp_log.h"
|
||||
|
||||
#include "stim_core.h"
|
||||
|
||||
static const char *TAG = "gamma-stim";
|
||||
|
||||
#define FIRMWARE_VERSION "0.1.1"
|
||||
|
||||
/* ---- Pins / peripherals (Kconfig-overridable) ----------------------------- */
|
||||
#define PIN_LED CONFIG_GAMMA_STIM_LED_GPIO
|
||||
#define PIN_AUDIO CONFIG_GAMMA_STIM_AUDIO_GPIO
|
||||
#define PIN_SYNC CONFIG_GAMMA_STIM_SYNC_GPIO
|
||||
#define PIN_ESTOP CONFIG_GAMMA_STIM_ESTOP_GPIO
|
||||
#define PIN_GATE CONFIG_GAMMA_STIM_GATE_GPIO
|
||||
|
||||
#define LEDC_LED_CH LEDC_CHANNEL_0
|
||||
#define LEDC_AUDIO_CH LEDC_CHANNEL_1
|
||||
#define LEDC_LED_TIMER LEDC_TIMER_0
|
||||
#define LEDC_AUDIO_TIMER LEDC_TIMER_1
|
||||
/* 12-bit duty at ~19.5 kHz LED carrier: flicker-free dimming far above the
|
||||
* envelope band; the 36-44 Hz stimulus is the *envelope*, not the carrier. */
|
||||
#define LED_CARRIER_HZ 19500
|
||||
#define LED_DUTY_RES LEDC_TIMER_12_BIT
|
||||
#define LED_DUTY_MAX ((1 << 12) - 1)
|
||||
/* Audio: square tone carrier gated by the envelope. */
|
||||
#define AUDIO_TONE_HZ CONFIG_GAMMA_STIM_AUDIO_TONE_HZ
|
||||
#define AUDIO_DUTY_RES LEDC_TIMER_12_BIT
|
||||
#define AUDIO_DUTY_MAX ((1 << 12) - 1)
|
||||
|
||||
/* Dead-man check period. Worst-case half-period is 13.9 ms (36 Hz), so a
|
||||
* healthy session advances elapsed_half_periods ~18+ times per check. */
|
||||
#define DEADMAN_PERIOD_MS 250
|
||||
|
||||
/* ---- Shared state ---------------------------------------------------------- */
|
||||
|
||||
static stim_ctx_t s_ctx; /* guarded by s_mux: ISRs + tasks */
|
||||
static portMUX_TYPE s_mux = portMUX_INITIALIZER_UNLOCKED;
|
||||
static gptimer_handle_t s_timer = NULL; /* started/stopped by console task ONLY */
|
||||
static QueueHandle_t s_evt_queue = NULL; /* events to the main task */
|
||||
static TimerHandle_t s_deadman = NULL;
|
||||
static uint32_t s_printed_seq = 0; /* guarded by s_mux: record dedupe */
|
||||
|
||||
typedef enum {
|
||||
EVT_SESSION_DONE = 1, /* duration elapsed (RUNNING -> IDLE edge) */
|
||||
EVT_ESTOP = 2, /* e-stop button latched */
|
||||
EVT_FAULT = 3, /* dead-man detected a stalled timer ISR */
|
||||
} stim_evt_kind_t;
|
||||
|
||||
typedef struct {
|
||||
stim_evt_kind_t kind;
|
||||
stim_ctx_t snap; /* context captured at the moment of the event */
|
||||
} stim_evt_t;
|
||||
|
||||
/* ---- ISR-safe GPIO writes (direct register path, inline => IRAM) ---------- */
|
||||
|
||||
static inline void IRAM_ATTR gate_write(bool on)
|
||||
{
|
||||
gpio_ll_set_level(&GPIO, PIN_GATE, on ? 1 : 0);
|
||||
}
|
||||
|
||||
static inline void IRAM_ATTR sync_write(bool on)
|
||||
{
|
||||
gpio_ll_set_level(&GPIO, PIN_SYNC, on ? 1 : 0);
|
||||
}
|
||||
|
||||
/* ---- LEDC carrier control (TASK CONTEXT ONLY — not ISR-safe) --------------- */
|
||||
|
||||
static void carrier_set(uint8_t brightness_pct, uint8_t volume_pct)
|
||||
{
|
||||
uint32_t led_duty = ((uint32_t)brightness_pct * LED_DUTY_MAX) / 100U;
|
||||
/* Volume cap is 40% -> max audio duty 20% of full scale: keep the square
|
||||
* tone gentle; real loudness control belongs to the analog stage. */
|
||||
uint32_t aud_duty = ((uint32_t)volume_pct * (AUDIO_DUTY_MAX / 2U)) / 100U;
|
||||
ESP_ERROR_CHECK(ledc_set_duty(LEDC_LOW_SPEED_MODE, LEDC_LED_CH, led_duty));
|
||||
ESP_ERROR_CHECK(ledc_update_duty(LEDC_LOW_SPEED_MODE, LEDC_LED_CH));
|
||||
ESP_ERROR_CHECK(ledc_set_duty(LEDC_LOW_SPEED_MODE, LEDC_AUDIO_CH, aud_duty));
|
||||
ESP_ERROR_CHECK(ledc_update_duty(LEDC_LOW_SPEED_MODE, LEDC_AUDIO_CH));
|
||||
}
|
||||
|
||||
static void carrier_off(void)
|
||||
{
|
||||
carrier_set(0, 0);
|
||||
}
|
||||
|
||||
/* ---- ISRs (no driver calls: register writes + queue only) ------------------ */
|
||||
|
||||
/* GPTimer alarm ISR: one half-period boundary. The envelope is delivered by
|
||||
* toggling the gate GPIO; the LEDC carriers are untouched here. */
|
||||
static bool IRAM_ATTR on_half_period(gptimer_handle_t timer,
|
||||
const gptimer_alarm_event_data_t *edata,
|
||||
void *user)
|
||||
{
|
||||
(void)timer; (void)edata; (void)user;
|
||||
BaseType_t hpw = pdFALSE;
|
||||
portENTER_CRITICAL_ISR(&s_mux);
|
||||
bool was_running = (s_ctx.state == STIM_RUNNING);
|
||||
bool running = stim_tick(&s_ctx);
|
||||
bool on = running && s_ctx.envelope_on;
|
||||
gate_write(on);
|
||||
sync_write(on);
|
||||
if (was_running && !running) {
|
||||
/* RUNNING -> done edge: exactly one completion event per session.
|
||||
* The timer is NOT stopped here (gptimer APIs stay in task context);
|
||||
* until the next host command it idle-ticks with the gate low. */
|
||||
stim_evt_t e = { .kind = EVT_SESSION_DONE, .snap = s_ctx };
|
||||
xQueueSendFromISR(s_evt_queue, &e, &hpw);
|
||||
}
|
||||
portEXIT_CRITICAL_ISR(&s_mux);
|
||||
return hpw == pdTRUE;
|
||||
}
|
||||
|
||||
/* E-stop button ISR. Hard real-time stop path = the first line: one direct
|
||||
* GPIO register write forcing the output gate low. Everything after that is
|
||||
* bookkeeping. LEDC shutdown happens later in the main task. */
|
||||
static void IRAM_ATTR on_estop(void *arg)
|
||||
{
|
||||
(void)arg;
|
||||
gate_write(false); /* outputs off: microseconds */
|
||||
BaseType_t hpw = pdFALSE;
|
||||
portENTER_CRITICAL_ISR(&s_mux);
|
||||
bool already_latched = (s_ctx.state == STIM_LOCKED);
|
||||
stim_estop(&s_ctx, STIM_STOP_BUTTON);
|
||||
sync_write(false);
|
||||
if (!already_latched) { /* debounce: one event per latch */
|
||||
stim_evt_t e = { .kind = EVT_ESTOP, .snap = s_ctx };
|
||||
xQueueSendFromISR(s_evt_queue, &e, &hpw);
|
||||
}
|
||||
portEXIT_CRITICAL_ISR(&s_mux);
|
||||
if (hpw == pdTRUE) {
|
||||
portYIELD_FROM_ISR();
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Dead-man watchdog (FreeRTOS timer task context) ----------------------- */
|
||||
|
||||
/* Independent of the GPTimer ISR: if state == RUNNING but the ISR stopped
|
||||
* advancing elapsed_half_periods between two checks, the toggling path is
|
||||
* dead — possibly with the gate left ON. Force outputs off and latch FAULT
|
||||
* (same latch as the e-stop: START refused until UNLOCK). */
|
||||
static void deadman_cb(TimerHandle_t t)
|
||||
{
|
||||
(void)t;
|
||||
static uint32_t dm_seq = 0; /* session_seq starts at 1; 0 = none */
|
||||
static uint32_t dm_elapsed = 0;
|
||||
bool fault = false;
|
||||
stim_evt_t e = { .kind = EVT_FAULT };
|
||||
portENTER_CRITICAL(&s_mux);
|
||||
if (s_ctx.state == STIM_RUNNING) {
|
||||
if (dm_seq == s_ctx.session_seq &&
|
||||
dm_elapsed == s_ctx.elapsed_half_periods) {
|
||||
gate_write(false);
|
||||
sync_write(false);
|
||||
stim_estop(&s_ctx, STIM_STOP_FAULT);
|
||||
e.snap = s_ctx;
|
||||
fault = true;
|
||||
} else {
|
||||
dm_seq = s_ctx.session_seq;
|
||||
dm_elapsed = s_ctx.elapsed_half_periods;
|
||||
}
|
||||
} else {
|
||||
dm_seq = 0;
|
||||
dm_elapsed = 0;
|
||||
}
|
||||
portEXIT_CRITICAL(&s_mux);
|
||||
if (fault) {
|
||||
carrier_off(); /* task context: LEDC allowed */
|
||||
xQueueSend(s_evt_queue, &e, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Peripheral setup -------------------------------------------------------- */
|
||||
|
||||
static void setup_ledc(void)
|
||||
{
|
||||
ledc_timer_config_t led_t = {
|
||||
.speed_mode = LEDC_LOW_SPEED_MODE,
|
||||
.timer_num = LEDC_LED_TIMER,
|
||||
.duty_resolution = LED_DUTY_RES,
|
||||
.freq_hz = LED_CARRIER_HZ,
|
||||
.clk_cfg = LEDC_AUTO_CLK,
|
||||
};
|
||||
ESP_ERROR_CHECK(ledc_timer_config(&led_t));
|
||||
ledc_channel_config_t led_c = {
|
||||
.gpio_num = PIN_LED,
|
||||
.speed_mode = LEDC_LOW_SPEED_MODE,
|
||||
.channel = LEDC_LED_CH,
|
||||
.timer_sel = LEDC_LED_TIMER,
|
||||
.duty = 0,
|
||||
.hpoint = 0,
|
||||
};
|
||||
ESP_ERROR_CHECK(ledc_channel_config(&led_c));
|
||||
|
||||
ledc_timer_config_t aud_t = {
|
||||
.speed_mode = LEDC_LOW_SPEED_MODE,
|
||||
.timer_num = LEDC_AUDIO_TIMER,
|
||||
.duty_resolution = AUDIO_DUTY_RES,
|
||||
.freq_hz = AUDIO_TONE_HZ,
|
||||
.clk_cfg = LEDC_AUTO_CLK,
|
||||
};
|
||||
ESP_ERROR_CHECK(ledc_timer_config(&aud_t));
|
||||
ledc_channel_config_t aud_c = {
|
||||
.gpio_num = PIN_AUDIO,
|
||||
.speed_mode = LEDC_LOW_SPEED_MODE,
|
||||
.channel = LEDC_AUDIO_CH,
|
||||
.timer_sel = LEDC_AUDIO_TIMER,
|
||||
.duty = 0,
|
||||
.hpoint = 0,
|
||||
};
|
||||
ESP_ERROR_CHECK(ledc_channel_config(&aud_c));
|
||||
}
|
||||
|
||||
static void setup_gpio(void)
|
||||
{
|
||||
/* Output-enable gate FIRST: drive it low before the LEDC carriers exist.
|
||||
* The board-level wiring must add an external pulldown so the LED driver
|
||||
* and audio amp stay disabled while this pin floats during boot/reset
|
||||
* (see README "Output gate wiring"). */
|
||||
gpio_config_t gate = {
|
||||
.pin_bit_mask = 1ULL << PIN_GATE,
|
||||
.mode = GPIO_MODE_OUTPUT,
|
||||
.pull_down_en = GPIO_PULLDOWN_ENABLE,
|
||||
};
|
||||
ESP_ERROR_CHECK(gpio_config(&gate));
|
||||
gpio_set_level(PIN_GATE, 0);
|
||||
|
||||
gpio_config_t sync = {
|
||||
.pin_bit_mask = 1ULL << PIN_SYNC,
|
||||
.mode = GPIO_MODE_OUTPUT,
|
||||
};
|
||||
ESP_ERROR_CHECK(gpio_config(&sync));
|
||||
gpio_set_level(PIN_SYNC, 0);
|
||||
|
||||
gpio_config_t estop = {
|
||||
.pin_bit_mask = 1ULL << PIN_ESTOP,
|
||||
.mode = GPIO_MODE_INPUT,
|
||||
.pull_up_en = GPIO_PULLUP_ENABLE, /* button to GND, active low */
|
||||
.intr_type = GPIO_INTR_NEGEDGE,
|
||||
};
|
||||
ESP_ERROR_CHECK(gpio_config(&estop));
|
||||
ESP_ERROR_CHECK(gpio_install_isr_service(0));
|
||||
ESP_ERROR_CHECK(gpio_isr_handler_add(PIN_ESTOP, on_estop, NULL));
|
||||
}
|
||||
|
||||
static void setup_timer(void)
|
||||
{
|
||||
gptimer_config_t cfg = {
|
||||
.clk_src = GPTIMER_CLK_SRC_DEFAULT,
|
||||
.direction = GPTIMER_COUNT_UP,
|
||||
.resolution_hz = 1000000, /* 1 us ticks, crystal-derived */
|
||||
};
|
||||
ESP_ERROR_CHECK(gptimer_new_timer(&cfg, &s_timer));
|
||||
gptimer_event_callbacks_t cbs = { .on_alarm = on_half_period };
|
||||
ESP_ERROR_CHECK(gptimer_register_event_callbacks(s_timer, &cbs, NULL));
|
||||
ESP_ERROR_CHECK(gptimer_enable(s_timer));
|
||||
}
|
||||
|
||||
/* ---- Session lifecycle ---------------------------------------------------------- */
|
||||
|
||||
/* Print the canonical witness record for a finished session. The snapshot is
|
||||
* captured at the event source (under s_mux), so a new session racing in
|
||||
* cannot corrupt the record. Deduped by session_seq: STOP-while-idle or an
|
||||
* e-stop pressed after completion must NOT re-print the previous record. */
|
||||
static void print_session_record(const stim_ctx_t *snap)
|
||||
{
|
||||
if (snap->session_seq == 0 || snap->state == STIM_RUNNING) {
|
||||
return; /* no finished session to report */
|
||||
}
|
||||
portENTER_CRITICAL(&s_mux);
|
||||
bool dup = (snap->session_seq == s_printed_seq);
|
||||
if (!dup) {
|
||||
s_printed_seq = snap->session_seq;
|
||||
}
|
||||
portEXIT_CRITICAL(&s_mux);
|
||||
if (dup) {
|
||||
return;
|
||||
}
|
||||
/* One canonical JSON line per finished session; the host pairs it with the
|
||||
* RuFlo session builder to compute the witness hash (HIL: 100% hash
|
||||
* reproducibility). Quantized integers only — no float formatting drift. */
|
||||
printf("SESSION {\"seq\":%u,\"freq_mhz\":%u,\"brightness_pct\":%u,"
|
||||
"\"volume_pct\":%u,\"duration_s\":%u,\"half_periods\":%u,"
|
||||
"\"stop\":\"%s\",\"fw\":\"%s\"}\n",
|
||||
(unsigned)snap->session_seq, (unsigned)snap->active.freq_mhz,
|
||||
(unsigned)snap->active.brightness_pct, (unsigned)snap->active.volume_pct,
|
||||
(unsigned)snap->active.duration_s, (unsigned)snap->elapsed_half_periods,
|
||||
stim_stop_str(snap->last_stop), FIRMWARE_VERSION);
|
||||
}
|
||||
|
||||
static void handle_start(const stim_params_t *p)
|
||||
{
|
||||
portENTER_CRITICAL(&s_mux);
|
||||
stim_rc_t rc = stim_start(&s_ctx, p);
|
||||
uint32_t seq = s_ctx.session_seq;
|
||||
portEXIT_CRITICAL(&s_mux);
|
||||
if (rc != STIM_OK) {
|
||||
printf("ERR %s\n", stim_rc_str(rc));
|
||||
return;
|
||||
}
|
||||
|
||||
/* Program the carriers at session intensity (task context). Nothing is
|
||||
* emitted yet: the gate GPIO stays low until the first ON half-period. */
|
||||
carrier_set(p->brightness_pct, p->volume_pct);
|
||||
|
||||
uint32_t half_us = stim_half_period_us(p->freq_mhz);
|
||||
gptimer_alarm_config_t alarm = {
|
||||
.alarm_count = half_us,
|
||||
.reload_count = 0,
|
||||
.flags.auto_reload_on_alarm = true,
|
||||
};
|
||||
gptimer_stop(s_timer); /* idempotent: may be idle-ticking or stopped */
|
||||
ESP_ERROR_CHECK(gptimer_set_raw_count(s_timer, 0));
|
||||
ESP_ERROR_CHECK(gptimer_set_alarm_action(s_timer, &alarm));
|
||||
|
||||
/* TOCTOU guard: an e-stop may have latched between the RUNNING commit
|
||||
* above and here. Start the timer under the same mux the e-stop ISR
|
||||
* takes, so "state == LOCKED" and "timer started for this session" can
|
||||
* never coexist. (gptimer_start error, if any, is checked outside the
|
||||
* critical section because it may log.) */
|
||||
esp_err_t start_err = ESP_OK;
|
||||
portENTER_CRITICAL(&s_mux);
|
||||
bool still_running =
|
||||
(s_ctx.state == STIM_RUNNING && s_ctx.session_seq == seq);
|
||||
if (still_running) {
|
||||
start_err = gptimer_start(s_timer);
|
||||
}
|
||||
portEXIT_CRITICAL(&s_mux);
|
||||
if (!still_running) {
|
||||
carrier_off(); /* gate already low (forced by the e-stop ISR) */
|
||||
printf("ERR %s\n", stim_rc_str(STIM_ERR_LOCKED));
|
||||
return;
|
||||
}
|
||||
ESP_ERROR_CHECK(start_err);
|
||||
printf("OK start seq=%u half_period_us=%u\n",
|
||||
(unsigned)seq, (unsigned)half_us);
|
||||
}
|
||||
|
||||
static void handle_line(const char *line)
|
||||
{
|
||||
stim_cmd_t cmd;
|
||||
stim_rc_t rc = stim_parse_line(line, &cmd);
|
||||
if (rc != STIM_OK) {
|
||||
printf("ERR %s\n", stim_rc_str(rc));
|
||||
return;
|
||||
}
|
||||
switch (cmd.kind) {
|
||||
case STIM_CMD_START:
|
||||
handle_start(&cmd.params);
|
||||
break;
|
||||
case STIM_CMD_STOP: {
|
||||
portENTER_CRITICAL(&s_mux);
|
||||
gate_write(false);
|
||||
sync_write(false);
|
||||
stim_stop_host(&s_ctx);
|
||||
stim_ctx_t snap = s_ctx;
|
||||
portEXIT_CRITICAL(&s_mux);
|
||||
gptimer_stop(s_timer); /* console task owns the timer; rc ignored */
|
||||
carrier_off();
|
||||
print_session_record(&snap); /* deduped: no-op if nothing new ran */
|
||||
printf("OK stop\n");
|
||||
break;
|
||||
}
|
||||
case STIM_CMD_STATUS: {
|
||||
portENTER_CRITICAL(&s_mux);
|
||||
stim_ctx_t snap = s_ctx;
|
||||
portEXIT_CRITICAL(&s_mux);
|
||||
const char *st = snap.state == STIM_RUNNING ? "running"
|
||||
: snap.state == STIM_LOCKED ? "locked"
|
||||
: "idle";
|
||||
printf("OK status state=%s seq=%u last_stop=%s\n", st,
|
||||
(unsigned)snap.session_seq, stim_stop_str(snap.last_stop));
|
||||
break;
|
||||
}
|
||||
case STIM_CMD_UNLOCK:
|
||||
/* Refuse to clear the latch while the physical e-stop button is still
|
||||
* held (active low): a host retry loop must never be able to resume
|
||||
* against a pressed button. If the button is pressed again right
|
||||
* after this check, the NEGEDGE ISR simply re-latches. */
|
||||
if (gpio_get_level(PIN_ESTOP) == 0) {
|
||||
printf("ERR estop_button_pressed\n");
|
||||
break;
|
||||
}
|
||||
portENTER_CRITICAL(&s_mux);
|
||||
stim_unlock(&s_ctx);
|
||||
portEXIT_CRITICAL(&s_mux);
|
||||
printf("OK unlock\n");
|
||||
break;
|
||||
case STIM_CMD_VERSION:
|
||||
printf("OK version fw=%s envelope=36000-44000mHz b<=%u%% v<=%u%% d<=%us\n",
|
||||
FIRMWARE_VERSION,
|
||||
(unsigned)s_ctx.envelope.max_brightness_pct,
|
||||
(unsigned)s_ctx.envelope.max_volume_pct,
|
||||
(unsigned)s_ctx.envelope.max_duration_s);
|
||||
break;
|
||||
default:
|
||||
printf("ERR %s\n", stim_rc_str(STIM_ERR_UNKNOWN_CMD));
|
||||
}
|
||||
}
|
||||
|
||||
/* Console reader: line-buffered stdin (USB-CDC / UART0). */
|
||||
static void console_task(void *arg)
|
||||
{
|
||||
(void)arg;
|
||||
char buf[96];
|
||||
size_t n = 0;
|
||||
bool discard = false; /* swallowing the tail of an overlong line */
|
||||
for (;;) {
|
||||
int ch = fgetc(stdin);
|
||||
if (ch == EOF) {
|
||||
vTaskDelay(pdMS_TO_TICKS(10));
|
||||
continue;
|
||||
}
|
||||
if (ch == '\r') {
|
||||
continue;
|
||||
}
|
||||
if (ch == '\n') {
|
||||
if (discard) {
|
||||
discard = false; /* overlong line fully consumed */
|
||||
n = 0;
|
||||
continue;
|
||||
}
|
||||
buf[n] = '\0';
|
||||
if (n > 0) {
|
||||
handle_line(buf);
|
||||
}
|
||||
n = 0;
|
||||
continue;
|
||||
}
|
||||
if (discard) {
|
||||
continue;
|
||||
}
|
||||
if (n + 1 < sizeof(buf)) {
|
||||
buf[n++] = (char)ch;
|
||||
} else {
|
||||
/* Overlong line: fail closed — drop it AND everything up to the
|
||||
* next newline, so the tail is never reinterpreted as a fresh
|
||||
* command. */
|
||||
discard = true;
|
||||
n = 0;
|
||||
printf("ERR %s\n", stim_rc_str(STIM_ERR_PARSE));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void app_main(void)
|
||||
{
|
||||
ESP_LOGI(TAG, "gamma-stim v%s (ADR-250 M2 device harness)", FIRMWARE_VERSION);
|
||||
s_evt_queue = xQueueCreate(8, sizeof(stim_evt_t));
|
||||
configASSERT(s_evt_queue != NULL);
|
||||
stim_init(&s_ctx, stim_envelope_conservative());
|
||||
setup_gpio(); /* gate low before the LEDC carriers are configured */
|
||||
setup_ledc();
|
||||
setup_timer();
|
||||
carrier_off();
|
||||
|
||||
s_deadman = xTimerCreate("deadman", pdMS_TO_TICKS(DEADMAN_PERIOD_MS),
|
||||
pdTRUE, NULL, deadman_cb);
|
||||
configASSERT(s_deadman != NULL);
|
||||
xTimerStart(s_deadman, 0);
|
||||
|
||||
xTaskCreate(console_task, "console", 4096, NULL, 5, NULL);
|
||||
ESP_LOGI(TAG, "ready: envelope 36.0-44.0 Hz, brightness<=%u%%, volume<=%u%%",
|
||||
(unsigned)s_ctx.envelope.max_brightness_pct,
|
||||
(unsigned)s_ctx.envelope.max_volume_pct);
|
||||
|
||||
stim_evt_t evt;
|
||||
for (;;) {
|
||||
if (xQueueReceive(s_evt_queue, &evt, portMAX_DELAY) == pdTRUE) {
|
||||
/* The hard off (gate low) already happened at the event source.
|
||||
* Zero the carriers here in task context — but never behind a
|
||||
* session that has legitimately started since the event fired. */
|
||||
bool cleanup;
|
||||
portENTER_CRITICAL(&s_mux);
|
||||
cleanup = (s_ctx.state != STIM_RUNNING);
|
||||
portEXIT_CRITICAL(&s_mux);
|
||||
if (cleanup) {
|
||||
carrier_off();
|
||||
}
|
||||
switch (evt.kind) {
|
||||
case EVT_SESSION_DONE:
|
||||
print_session_record(&evt.snap);
|
||||
break;
|
||||
case EVT_ESTOP:
|
||||
print_session_record(&evt.snap);
|
||||
printf("EVT estop_latched\n");
|
||||
break;
|
||||
case EVT_FAULT:
|
||||
print_session_record(&evt.snap);
|
||||
printf("EVT fault_latched\n");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
/*
|
||||
* stim_core.c — pure, host-testable core of the gamma stimulation firmware.
|
||||
* See stim_core.h for the contract. No ESP-IDF includes in this file.
|
||||
*/
|
||||
#include "stim_core.h"
|
||||
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
#include <ctype.h>
|
||||
|
||||
/* ---- Envelope ------------------------------------------------------------ */
|
||||
|
||||
stim_envelope_t stim_envelope_conservative(void)
|
||||
{
|
||||
stim_envelope_t e = {
|
||||
.min_freq_mhz = 36000, /* 36.0 Hz */
|
||||
.max_freq_mhz = 44000, /* 44.0 Hz */
|
||||
.max_brightness_pct = 40, /* SafetyEnvelope::conservative 0.40 */
|
||||
.max_volume_pct = 40,
|
||||
.max_duration_s = 900, /* 15 min */
|
||||
};
|
||||
return e;
|
||||
}
|
||||
|
||||
void stim_init(stim_ctx_t *ctx, stim_envelope_t envelope)
|
||||
{
|
||||
memset(ctx, 0, sizeof(*ctx));
|
||||
ctx->envelope = envelope;
|
||||
ctx->state = STIM_IDLE;
|
||||
ctx->last_stop = STIM_STOP_NONE;
|
||||
}
|
||||
|
||||
/* ---- Validation ----------------------------------------------------------- */
|
||||
|
||||
stim_rc_t stim_validate(const stim_ctx_t *ctx, const stim_params_t *p)
|
||||
{
|
||||
const stim_envelope_t *e = &ctx->envelope;
|
||||
if (p->freq_mhz < e->min_freq_mhz || p->freq_mhz > e->max_freq_mhz) {
|
||||
return STIM_ERR_FREQ_RANGE;
|
||||
}
|
||||
if (p->brightness_pct > e->max_brightness_pct) {
|
||||
return STIM_ERR_BRIGHTNESS_CAP;
|
||||
}
|
||||
if (p->volume_pct > e->max_volume_pct) {
|
||||
return STIM_ERR_VOLUME_CAP;
|
||||
}
|
||||
if (p->duration_s == 0) {
|
||||
return STIM_ERR_ZERO_DURATION;
|
||||
}
|
||||
if (p->duration_s > e->max_duration_s) {
|
||||
return STIM_ERR_DURATION_CAP;
|
||||
}
|
||||
return STIM_OK;
|
||||
}
|
||||
|
||||
/* ---- Transitions ----------------------------------------------------------- */
|
||||
|
||||
stim_rc_t stim_start(stim_ctx_t *ctx, const stim_params_t *p)
|
||||
{
|
||||
if (ctx->state == STIM_LOCKED) {
|
||||
return STIM_ERR_LOCKED;
|
||||
}
|
||||
if (ctx->state == STIM_RUNNING) {
|
||||
return STIM_ERR_BUSY;
|
||||
}
|
||||
stim_rc_t rc = stim_validate(ctx, p);
|
||||
if (rc != STIM_OK) {
|
||||
return rc; /* fail closed: state unchanged, outputs stay off */
|
||||
}
|
||||
ctx->active = *p;
|
||||
ctx->elapsed_half_periods = 0;
|
||||
ctx->envelope_on = false;
|
||||
ctx->session_seq += 1;
|
||||
ctx->last_stop = STIM_STOP_NONE;
|
||||
ctx->state = STIM_RUNNING;
|
||||
return STIM_OK;
|
||||
}
|
||||
|
||||
stim_rc_t stim_stop_host(stim_ctx_t *ctx)
|
||||
{
|
||||
if (ctx->state == STIM_RUNNING) {
|
||||
ctx->state = STIM_IDLE;
|
||||
ctx->envelope_on = false;
|
||||
ctx->last_stop = STIM_STOP_HOST;
|
||||
}
|
||||
/* STOP while idle/locked is a harmless no-op (idempotent). */
|
||||
return STIM_OK;
|
||||
}
|
||||
|
||||
void stim_estop(stim_ctx_t *ctx, stim_stop_reason_t why)
|
||||
{
|
||||
ctx->state = STIM_LOCKED; /* latched, from ANY state */
|
||||
ctx->envelope_on = false;
|
||||
ctx->last_stop = why;
|
||||
}
|
||||
|
||||
stim_rc_t stim_unlock(stim_ctx_t *ctx)
|
||||
{
|
||||
if (ctx->state == STIM_LOCKED) {
|
||||
ctx->state = STIM_IDLE;
|
||||
}
|
||||
return STIM_OK;
|
||||
}
|
||||
|
||||
bool stim_tick(stim_ctx_t *ctx)
|
||||
{
|
||||
if (ctx->state != STIM_RUNNING) {
|
||||
ctx->envelope_on = false;
|
||||
return false;
|
||||
}
|
||||
ctx->envelope_on = !ctx->envelope_on;
|
||||
ctx->elapsed_half_periods += 1;
|
||||
uint32_t total =
|
||||
stim_session_half_periods(ctx->active.freq_mhz, ctx->active.duration_s);
|
||||
if (ctx->elapsed_half_periods >= total) {
|
||||
ctx->state = STIM_IDLE;
|
||||
ctx->envelope_on = false;
|
||||
ctx->last_stop = STIM_STOP_COMPLETED;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
uint32_t stim_half_period_us(uint32_t freq_mhz)
|
||||
{
|
||||
if (freq_mhz == 0) {
|
||||
return 0;
|
||||
}
|
||||
/* half period [us] = 1e6 / (2 * f[Hz]) = 5e8 / f[mHz].
|
||||
* 64-bit intermediate; exact division for e.g. 40000 -> 12500 us. */
|
||||
return (uint32_t)(500000000ULL / (uint64_t)freq_mhz);
|
||||
}
|
||||
|
||||
uint32_t stim_session_half_periods(uint32_t freq_mhz, uint32_t duration_s)
|
||||
{
|
||||
/* half periods = duration * 2 * f[Hz] = duration * f[mHz] / 500.
|
||||
* 64-bit intermediate: 900 s * 44000 = 39.6e6, fine. */
|
||||
return (uint32_t)(((uint64_t)duration_s * (uint64_t)freq_mhz) / 500ULL);
|
||||
}
|
||||
|
||||
/* ---- Protocol parsing ------------------------------------------------------ */
|
||||
|
||||
/* Parse an unsigned decimal field; returns false on junk/overflow. */
|
||||
static bool parse_u32(const char **cursor, uint32_t *out)
|
||||
{
|
||||
const char *s = *cursor;
|
||||
while (*s == ' ') {
|
||||
s++;
|
||||
}
|
||||
if (!isdigit((unsigned char)*s)) {
|
||||
return false;
|
||||
}
|
||||
uint64_t v = 0;
|
||||
while (isdigit((unsigned char)*s)) {
|
||||
v = v * 10ULL + (uint64_t)(*s - '0');
|
||||
if (v > 0xFFFFFFFFULL) {
|
||||
return false;
|
||||
}
|
||||
s++;
|
||||
}
|
||||
*out = (uint32_t)v;
|
||||
*cursor = s;
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool token_is(const char *line, const char *word, const char **rest)
|
||||
{
|
||||
size_t n = strlen(word);
|
||||
if (strncmp(line, word, n) != 0) {
|
||||
return false;
|
||||
}
|
||||
if (line[n] != '\0' && line[n] != ' ') {
|
||||
return false;
|
||||
}
|
||||
*rest = line + n;
|
||||
return true;
|
||||
}
|
||||
|
||||
stim_rc_t stim_parse_line(const char *line, stim_cmd_t *out)
|
||||
{
|
||||
memset(out, 0, sizeof(*out));
|
||||
while (*line == ' ') {
|
||||
line++;
|
||||
}
|
||||
const char *rest = NULL;
|
||||
if (token_is(line, "START", &rest)) {
|
||||
uint32_t f, b, v, d;
|
||||
if (!parse_u32(&rest, &f) || !parse_u32(&rest, &b) ||
|
||||
!parse_u32(&rest, &v) || !parse_u32(&rest, &d)) {
|
||||
return STIM_ERR_PARSE;
|
||||
}
|
||||
while (*rest == ' ') {
|
||||
rest++;
|
||||
}
|
||||
if (*rest != '\0') {
|
||||
return STIM_ERR_PARSE; /* trailing junk */
|
||||
}
|
||||
if (b > 255 || v > 255) {
|
||||
return STIM_ERR_PARSE; /* fields must fit their types */
|
||||
}
|
||||
out->kind = STIM_CMD_START;
|
||||
out->params.freq_mhz = f;
|
||||
out->params.brightness_pct = (uint8_t)b;
|
||||
out->params.volume_pct = (uint8_t)v;
|
||||
out->params.duration_s = d;
|
||||
return STIM_OK;
|
||||
}
|
||||
if (token_is(line, "STOP", &rest)) {
|
||||
out->kind = STIM_CMD_STOP;
|
||||
return STIM_OK;
|
||||
}
|
||||
if (token_is(line, "STATUS", &rest)) {
|
||||
out->kind = STIM_CMD_STATUS;
|
||||
return STIM_OK;
|
||||
}
|
||||
if (token_is(line, "UNLOCK", &rest)) {
|
||||
out->kind = STIM_CMD_UNLOCK;
|
||||
return STIM_OK;
|
||||
}
|
||||
if (token_is(line, "VERSION", &rest)) {
|
||||
out->kind = STIM_CMD_VERSION;
|
||||
return STIM_OK;
|
||||
}
|
||||
return STIM_ERR_UNKNOWN_CMD;
|
||||
}
|
||||
|
||||
const char *stim_rc_str(stim_rc_t rc)
|
||||
{
|
||||
switch (rc) {
|
||||
case STIM_OK: return "ok";
|
||||
case STIM_ERR_FREQ_RANGE: return "freq_out_of_envelope";
|
||||
case STIM_ERR_BRIGHTNESS_CAP: return "brightness_above_cap";
|
||||
case STIM_ERR_VOLUME_CAP: return "volume_above_cap";
|
||||
case STIM_ERR_DURATION_CAP: return "duration_above_cap";
|
||||
case STIM_ERR_ZERO_DURATION: return "zero_duration";
|
||||
case STIM_ERR_BUSY: return "busy";
|
||||
case STIM_ERR_LOCKED: return "estop_locked";
|
||||
case STIM_ERR_PARSE: return "parse_error";
|
||||
case STIM_ERR_UNKNOWN_CMD: return "unknown_command";
|
||||
default: return "unknown_rc";
|
||||
}
|
||||
}
|
||||
|
||||
const char *stim_stop_str(stim_stop_reason_t r)
|
||||
{
|
||||
switch (r) {
|
||||
case STIM_STOP_NONE: return "none";
|
||||
case STIM_STOP_COMPLETED: return "completed";
|
||||
case STIM_STOP_HOST: return "host_stop";
|
||||
case STIM_STOP_BUTTON: return "estop_button";
|
||||
case STIM_STOP_FAULT: return "fault";
|
||||
default: return "unknown";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
/*
|
||||
* stim_core.h — pure, host-testable core of the gamma stimulation firmware.
|
||||
*
|
||||
* Everything safety-critical lives here, with NO ESP-IDF dependencies, so the
|
||||
* exact code that ships on the device is unit-tested on the host (gcc) and in
|
||||
* CI. main.c is a thin hardware binding (timers, LEDC, GPIO, UART).
|
||||
*
|
||||
* Mirrors the ruview-gamma crate's SafetyEnvelope::conservative() (ADR-250
|
||||
* §5/§12): the firmware enforces the same hard caps *independently*, so even a
|
||||
* compromised or buggy host cannot command an out-of-envelope stimulus.
|
||||
* Defense in depth: host gate (Rust) AND device gate (this file).
|
||||
*
|
||||
* Units: frequency in millihertz (exact integer math — the ±0.1 Hz HIL target
|
||||
* is ±100 mHz), intensity in percent (0–100), duration in seconds.
|
||||
*/
|
||||
#ifndef STIM_CORE_H
|
||||
#define STIM_CORE_H
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/* ---- Hard safety envelope (device-side; never widened at runtime) ------- */
|
||||
|
||||
typedef struct {
|
||||
uint32_t min_freq_mhz; /* 36000 = 36.0 Hz */
|
||||
uint32_t max_freq_mhz; /* 44000 = 44.0 Hz */
|
||||
uint8_t max_brightness_pct;/* 40 = SafetyEnvelope::conservative cap 0.40 */
|
||||
uint8_t max_volume_pct; /* 40 */
|
||||
uint32_t max_duration_s; /* 900 = 15 min */
|
||||
} stim_envelope_t;
|
||||
|
||||
/* The compiled-in conservative envelope (ADR-250 §5). The values are
|
||||
* hard-coded in stim_core.c — there are deliberately NO Kconfig options for
|
||||
* them: widening the envelope requires editing the host-tested core and its
|
||||
* unit tests, never a build-time switch. */
|
||||
stim_envelope_t stim_envelope_conservative(void);
|
||||
|
||||
/* ---- Session state machine ---------------------------------------------- */
|
||||
|
||||
typedef enum {
|
||||
STIM_IDLE = 0, /* outputs off, ready for START */
|
||||
STIM_RUNNING, /* stimulation active */
|
||||
STIM_LOCKED, /* emergency-stopped; START refused until UNLOCK */
|
||||
} stim_state_t;
|
||||
|
||||
typedef enum {
|
||||
STIM_STOP_NONE = 0,
|
||||
STIM_STOP_COMPLETED, /* duration elapsed (not a safety stop) */
|
||||
STIM_STOP_HOST, /* host STOP command */
|
||||
STIM_STOP_BUTTON, /* hardware e-stop button */
|
||||
STIM_STOP_FAULT, /* internal fault (watchdog, bad state) */
|
||||
} stim_stop_reason_t;
|
||||
|
||||
typedef struct {
|
||||
uint32_t freq_mhz; /* commanded envelope frequency */
|
||||
uint8_t brightness_pct; /* LED intensity during ON half-period */
|
||||
uint8_t volume_pct; /* tone intensity during ON half-period */
|
||||
uint32_t duration_s; /* session length */
|
||||
} stim_params_t;
|
||||
|
||||
typedef struct {
|
||||
stim_envelope_t envelope;
|
||||
stim_state_t state;
|
||||
stim_params_t active; /* valid when state == RUNNING */
|
||||
stim_stop_reason_t last_stop;
|
||||
uint32_t session_seq; /* increments on each START */
|
||||
uint32_t elapsed_half_periods; /* advanced by the timer ISR */
|
||||
bool envelope_on; /* current half-period phase */
|
||||
} stim_ctx_t;
|
||||
|
||||
/* Initialize a context with the given envelope, in IDLE. */
|
||||
void stim_init(stim_ctx_t *ctx, stim_envelope_t envelope);
|
||||
|
||||
/* ---- Validation (fail closed) ------------------------------------------- */
|
||||
|
||||
typedef enum {
|
||||
STIM_OK = 0,
|
||||
STIM_ERR_FREQ_RANGE, /* outside [min,max] mHz */
|
||||
STIM_ERR_BRIGHTNESS_CAP,
|
||||
STIM_ERR_VOLUME_CAP,
|
||||
STIM_ERR_DURATION_CAP,
|
||||
STIM_ERR_ZERO_DURATION,
|
||||
STIM_ERR_BUSY, /* START while RUNNING */
|
||||
STIM_ERR_LOCKED, /* START while LOCKED (e-stop latched) */
|
||||
STIM_ERR_PARSE, /* malformed command line */
|
||||
STIM_ERR_UNKNOWN_CMD,
|
||||
} stim_rc_t;
|
||||
|
||||
/* Validate params against the context envelope. Pure; no state change. */
|
||||
stim_rc_t stim_validate(const stim_ctx_t *ctx, const stim_params_t *p);
|
||||
|
||||
/* ---- Transitions (the only mutators) ------------------------------------ */
|
||||
|
||||
/* START: validate + transition IDLE->RUNNING. Fails closed on any violation,
|
||||
* on BUSY, and on LOCKED. */
|
||||
stim_rc_t stim_start(stim_ctx_t *ctx, const stim_params_t *p);
|
||||
|
||||
/* STOP from the host: RUNNING->IDLE (graceful; not latched). */
|
||||
stim_rc_t stim_stop_host(stim_ctx_t *ctx);
|
||||
|
||||
/* Emergency stop (button ISR or fault): any state -> LOCKED. Latched —
|
||||
* further STARTs are refused until stim_unlock(). Mirrors the Rust
|
||||
* SafetyMonitor latch (a session must never silently resume). */
|
||||
void stim_estop(stim_ctx_t *ctx, stim_stop_reason_t why);
|
||||
|
||||
/* Operator unlock after an e-stop: LOCKED -> IDLE. */
|
||||
stim_rc_t stim_unlock(stim_ctx_t *ctx);
|
||||
|
||||
/* Timer ISR tick: advance one half-period. Returns true while RUNNING; when
|
||||
* the session's duration is reached it transitions to IDLE (COMPLETED) and
|
||||
* returns false. Pure integer math, ISR-safe. */
|
||||
bool stim_tick(stim_ctx_t *ctx);
|
||||
|
||||
/* Half-period length in microseconds for a commanded frequency:
|
||||
* 500'000'000'000 / freq_mhz / 1000 — exact for the supported range.
|
||||
* (40.0 Hz = 40000 mHz -> 12'500 us.) */
|
||||
uint32_t stim_half_period_us(uint32_t freq_mhz);
|
||||
|
||||
/* Total half-periods in a session of duration_s at freq_mhz (rounded down). */
|
||||
uint32_t stim_session_half_periods(uint32_t freq_mhz, uint32_t duration_s);
|
||||
|
||||
/* ---- Host command protocol (line-based, UART) ---------------------------
|
||||
*
|
||||
* START <freq_mhz> <brightness_pct> <volume_pct> <duration_s>
|
||||
* STOP
|
||||
* STATUS
|
||||
* UNLOCK
|
||||
* VERSION
|
||||
*
|
||||
* stim_parse_line() parses one trimmed line into a command. Pure.
|
||||
*/
|
||||
|
||||
typedef enum {
|
||||
STIM_CMD_NONE = 0,
|
||||
STIM_CMD_START,
|
||||
STIM_CMD_STOP,
|
||||
STIM_CMD_STATUS,
|
||||
STIM_CMD_UNLOCK,
|
||||
STIM_CMD_VERSION,
|
||||
} stim_cmd_kind_t;
|
||||
|
||||
typedef struct {
|
||||
stim_cmd_kind_t kind;
|
||||
stim_params_t params; /* valid when kind == STIM_CMD_START */
|
||||
} stim_cmd_t;
|
||||
|
||||
stim_rc_t stim_parse_line(const char *line, stim_cmd_t *out);
|
||||
|
||||
/* Human-readable tag for a return code (for "ERR <tag>" replies). */
|
||||
const char *stim_rc_str(stim_rc_t rc);
|
||||
|
||||
/* Human-readable tag for a stop reason (for the session log). */
|
||||
const char *stim_stop_str(stim_stop_reason_t r);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* STIM_CORE_H */
|
||||
@@ -0,0 +1,9 @@
|
||||
# esp32-gamma-stim defaults (S3/C6 class; single-purpose actuator node)
|
||||
CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y
|
||||
CONFIG_PARTITION_TABLE_SINGLE_APP=y
|
||||
CONFIG_FREERTOS_HZ=1000
|
||||
# Console on USB-CDC where available; falls back to UART0.
|
||||
CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG=y
|
||||
# Keep radio off: this node is a deterministic actuator, not a network device.
|
||||
# (No WiFi/BT init in the firmware; nothing to configure out.)
|
||||
CONFIG_COMPILER_OPTIMIZATION_SIZE=y
|
||||
@@ -0,0 +1,248 @@
|
||||
/*
|
||||
* Host-side unit tests for stim_core (the safety-critical firmware core).
|
||||
* Build & run (no ESP-IDF needed):
|
||||
*
|
||||
* cd firmware/esp32-gamma-stim
|
||||
* gcc -Wall -Wextra -Werror -O2 -I main tests/test_stim_core.c main/stim_core.c -o /tmp/test_stim && /tmp/test_stim
|
||||
*
|
||||
* Exit code 0 = all pass. These tests cover the same invariants the
|
||||
* ruview-gamma Rust crate enforces host-side (defense in depth): envelope is
|
||||
* never exceeded, e-stop latches, fail-closed parsing, exact timing math for
|
||||
* the ±0.1 Hz HIL target.
|
||||
*/
|
||||
#include <assert.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "stim_core.h"
|
||||
|
||||
static int tests_run = 0;
|
||||
#define RUN(t) do { t(); tests_run++; printf("ok - %s\n", #t); } while (0)
|
||||
|
||||
static stim_ctx_t fresh(void)
|
||||
{
|
||||
stim_ctx_t c;
|
||||
stim_init(&c, stim_envelope_conservative());
|
||||
return c;
|
||||
}
|
||||
|
||||
static stim_params_t prior(void)
|
||||
{
|
||||
stim_params_t p = {
|
||||
.freq_mhz = 40000, .brightness_pct = 30, .volume_pct = 28, .duration_s = 600,
|
||||
};
|
||||
return p;
|
||||
}
|
||||
|
||||
/* ---- envelope ------------------------------------------------------------ */
|
||||
|
||||
static void test_prior_is_inside_envelope(void)
|
||||
{
|
||||
stim_ctx_t c = fresh();
|
||||
stim_params_t p = prior();
|
||||
assert(stim_validate(&c, &p) == STIM_OK);
|
||||
}
|
||||
|
||||
static void test_frequency_outside_band_refused(void)
|
||||
{
|
||||
stim_ctx_t c = fresh();
|
||||
stim_params_t p = prior();
|
||||
p.freq_mhz = 35999; /* 35.999 Hz */
|
||||
assert(stim_validate(&c, &p) == STIM_ERR_FREQ_RANGE);
|
||||
p.freq_mhz = 44001;
|
||||
assert(stim_validate(&c, &p) == STIM_ERR_FREQ_RANGE);
|
||||
p.freq_mhz = 0;
|
||||
assert(stim_validate(&c, &p) == STIM_ERR_FREQ_RANGE);
|
||||
/* band edges are inclusive */
|
||||
p.freq_mhz = 36000;
|
||||
assert(stim_validate(&c, &p) == STIM_OK);
|
||||
p.freq_mhz = 44000;
|
||||
assert(stim_validate(&c, &p) == STIM_OK);
|
||||
}
|
||||
|
||||
static void test_intensity_caps_refused(void)
|
||||
{
|
||||
stim_ctx_t c = fresh();
|
||||
stim_params_t p = prior();
|
||||
p.brightness_pct = 41;
|
||||
assert(stim_validate(&c, &p) == STIM_ERR_BRIGHTNESS_CAP);
|
||||
p = prior();
|
||||
p.volume_pct = 41;
|
||||
assert(stim_validate(&c, &p) == STIM_ERR_VOLUME_CAP);
|
||||
p = prior();
|
||||
p.brightness_pct = 40; /* cap value itself is allowed */
|
||||
p.volume_pct = 40;
|
||||
assert(stim_validate(&c, &p) == STIM_OK);
|
||||
}
|
||||
|
||||
static void test_duration_caps_refused(void)
|
||||
{
|
||||
stim_ctx_t c = fresh();
|
||||
stim_params_t p = prior();
|
||||
p.duration_s = 0;
|
||||
assert(stim_validate(&c, &p) == STIM_ERR_ZERO_DURATION);
|
||||
p.duration_s = 901;
|
||||
assert(stim_validate(&c, &p) == STIM_ERR_DURATION_CAP);
|
||||
p.duration_s = 900;
|
||||
assert(stim_validate(&c, &p) == STIM_OK);
|
||||
}
|
||||
|
||||
/* ---- state machine --------------------------------------------------------- */
|
||||
|
||||
static void test_start_refused_while_running(void)
|
||||
{
|
||||
stim_ctx_t c = fresh();
|
||||
stim_params_t p = prior();
|
||||
assert(stim_start(&c, &p) == STIM_OK);
|
||||
assert(c.state == STIM_RUNNING);
|
||||
assert(stim_start(&c, &p) == STIM_ERR_BUSY);
|
||||
}
|
||||
|
||||
static void test_out_of_envelope_start_keeps_outputs_off(void)
|
||||
{
|
||||
stim_ctx_t c = fresh();
|
||||
stim_params_t p = prior();
|
||||
p.brightness_pct = 90;
|
||||
assert(stim_start(&c, &p) == STIM_ERR_BRIGHTNESS_CAP);
|
||||
assert(c.state == STIM_IDLE); /* fail closed */
|
||||
assert(!c.envelope_on);
|
||||
assert(c.session_seq == 0); /* no session consumed */
|
||||
}
|
||||
|
||||
static void test_estop_latches_from_any_state(void)
|
||||
{
|
||||
stim_ctx_t c = fresh();
|
||||
stim_params_t p = prior();
|
||||
assert(stim_start(&c, &p) == STIM_OK);
|
||||
stim_estop(&c, STIM_STOP_BUTTON);
|
||||
assert(c.state == STIM_LOCKED);
|
||||
assert(!c.envelope_on);
|
||||
/* START must be refused while latched — a session can never silently
|
||||
* resume after an e-stop (mirrors the Rust SafetyMonitor latch). */
|
||||
assert(stim_start(&c, &p) == STIM_ERR_LOCKED);
|
||||
/* Host STOP does not clear the latch either. */
|
||||
stim_stop_host(&c);
|
||||
assert(c.state == STIM_LOCKED);
|
||||
/* Only the explicit operator UNLOCK clears it. */
|
||||
assert(stim_unlock(&c) == STIM_OK);
|
||||
assert(c.state == STIM_IDLE);
|
||||
assert(stim_start(&c, &p) == STIM_OK);
|
||||
}
|
||||
|
||||
static void test_session_completes_after_duration(void)
|
||||
{
|
||||
stim_ctx_t c = fresh();
|
||||
stim_params_t p = prior();
|
||||
p.freq_mhz = 40000;
|
||||
p.duration_s = 1; /* 1 s @ 40 Hz = 80 half-periods */
|
||||
assert(stim_start(&c, &p) == STIM_OK);
|
||||
uint32_t total = stim_session_half_periods(p.freq_mhz, p.duration_s);
|
||||
assert(total == 80);
|
||||
for (uint32_t i = 0; i < total - 1; i++) {
|
||||
assert(stim_tick(&c));
|
||||
}
|
||||
assert(!stim_tick(&c)); /* final tick ends the session */
|
||||
assert(c.state == STIM_IDLE);
|
||||
assert(c.last_stop == STIM_STOP_COMPLETED);
|
||||
assert(!c.envelope_on);
|
||||
}
|
||||
|
||||
static void test_tick_alternates_envelope(void)
|
||||
{
|
||||
stim_ctx_t c = fresh();
|
||||
stim_params_t p = prior();
|
||||
assert(stim_start(&c, &p) == STIM_OK);
|
||||
assert(!c.envelope_on);
|
||||
stim_tick(&c);
|
||||
assert(c.envelope_on);
|
||||
stim_tick(&c);
|
||||
assert(!c.envelope_on);
|
||||
}
|
||||
|
||||
/* ---- timing math (the ±0.1 Hz HIL target is integer-exact) ----------------- */
|
||||
|
||||
static void test_half_period_math_is_exact(void)
|
||||
{
|
||||
assert(stim_half_period_us(40000) == 12500); /* 40.0 Hz */
|
||||
assert(stim_half_period_us(36000) == 13888); /* 36.0 Hz, floor of 13888.9 */
|
||||
assert(stim_half_period_us(44000) == 11363); /* 44.0 Hz, floor of 11363.6 */
|
||||
assert(stim_half_period_us(38500) == 12987); /* 38.5 Hz */
|
||||
/* Worst-case truncation at 44 Hz: commanded period = 2*11363us = 22726us
|
||||
* -> 44.0028 Hz, an error of 2.8 mHz — 35x inside the ±100 mHz target. */
|
||||
}
|
||||
|
||||
static void test_session_half_periods_math(void)
|
||||
{
|
||||
assert(stim_session_half_periods(40000, 600) == 48000); /* 10 min @ 40 Hz */
|
||||
assert(stim_session_half_periods(44000, 900) == 79200);
|
||||
assert(stim_session_half_periods(36000, 1) == 72);
|
||||
}
|
||||
|
||||
/* ---- protocol parsing -------------------------------------------------------- */
|
||||
|
||||
static void test_parse_start(void)
|
||||
{
|
||||
stim_cmd_t cmd;
|
||||
assert(stim_parse_line("START 40000 30 28 600", &cmd) == STIM_OK);
|
||||
assert(cmd.kind == STIM_CMD_START);
|
||||
assert(cmd.params.freq_mhz == 40000);
|
||||
assert(cmd.params.brightness_pct == 30);
|
||||
assert(cmd.params.volume_pct == 28);
|
||||
assert(cmd.params.duration_s == 600);
|
||||
}
|
||||
|
||||
static void test_parse_simple_commands(void)
|
||||
{
|
||||
stim_cmd_t cmd;
|
||||
assert(stim_parse_line("STOP", &cmd) == STIM_OK && cmd.kind == STIM_CMD_STOP);
|
||||
assert(stim_parse_line("STATUS", &cmd) == STIM_OK && cmd.kind == STIM_CMD_STATUS);
|
||||
assert(stim_parse_line("UNLOCK", &cmd) == STIM_OK && cmd.kind == STIM_CMD_UNLOCK);
|
||||
assert(stim_parse_line("VERSION", &cmd) == STIM_OK && cmd.kind == STIM_CMD_VERSION);
|
||||
assert(stim_parse_line(" STOP", &cmd) == STIM_OK); /* leading spaces ok */
|
||||
}
|
||||
|
||||
static void test_parse_rejects_malformed(void)
|
||||
{
|
||||
stim_cmd_t cmd;
|
||||
assert(stim_parse_line("START", &cmd) == STIM_ERR_PARSE);
|
||||
assert(stim_parse_line("START 40000 30 28", &cmd) == STIM_ERR_PARSE);
|
||||
assert(stim_parse_line("START 40000 30 28 600 junk", &cmd) == STIM_ERR_PARSE);
|
||||
assert(stim_parse_line("START 40000 999 28 600", &cmd) == STIM_ERR_PARSE);
|
||||
assert(stim_parse_line("START -1 30 28 600", &cmd) == STIM_ERR_PARSE);
|
||||
assert(stim_parse_line("START 99999999999 30 28 600", &cmd) == STIM_ERR_PARSE);
|
||||
assert(stim_parse_line("FLASHBANG", &cmd) == STIM_ERR_UNKNOWN_CMD);
|
||||
assert(stim_parse_line("STOPX", &cmd) == STIM_ERR_UNKNOWN_CMD);
|
||||
assert(stim_parse_line("", &cmd) == STIM_ERR_UNKNOWN_CMD);
|
||||
}
|
||||
|
||||
static void test_parsed_hostile_start_is_still_refused_by_envelope(void)
|
||||
{
|
||||
/* End-to-end fail-closed: a syntactically valid but unsafe command parses
|
||||
* fine and is then refused by validation — never reaches the outputs. */
|
||||
stim_ctx_t c = fresh();
|
||||
stim_cmd_t cmd;
|
||||
assert(stim_parse_line("START 60000 40 40 600", &cmd) == STIM_OK);
|
||||
assert(stim_start(&c, &cmd.params) == STIM_ERR_FREQ_RANGE);
|
||||
assert(c.state == STIM_IDLE);
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
RUN(test_prior_is_inside_envelope);
|
||||
RUN(test_frequency_outside_band_refused);
|
||||
RUN(test_intensity_caps_refused);
|
||||
RUN(test_duration_caps_refused);
|
||||
RUN(test_start_refused_while_running);
|
||||
RUN(test_out_of_envelope_start_keeps_outputs_off);
|
||||
RUN(test_estop_latches_from_any_state);
|
||||
RUN(test_session_completes_after_duration);
|
||||
RUN(test_tick_alternates_envelope);
|
||||
RUN(test_half_period_math_is_exact);
|
||||
RUN(test_session_half_periods_math);
|
||||
RUN(test_parse_start);
|
||||
RUN(test_parse_simple_commands);
|
||||
RUN(test_parse_rejects_malformed);
|
||||
RUN(test_parsed_hostile_start_is_still_refused_by_envelope);
|
||||
printf("\nall %d stim_core tests passed\n", tests_run);
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
0.1.0
|
||||
Generated
+34
@@ -7456,6 +7456,35 @@ version = "2.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "753a07254fa68db183949ec6c7575d890da4d42404afabc11d610a720fcf570c"
|
||||
|
||||
[[package]]
|
||||
name = "ruview-gamma"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"approx",
|
||||
"criterion",
|
||||
"rand 0.8.5",
|
||||
"rand_chacha 0.3.1",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruview-gamma-clinic"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"ruview-gamma",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tower 0.4.13",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruview-swarm"
|
||||
version = "0.1.0"
|
||||
@@ -10892,6 +10921,7 @@ version = "0.3.0"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"criterion",
|
||||
"ruvector-mincut",
|
||||
"wifi-densepose-bfld",
|
||||
"wifi-densepose-core",
|
||||
"wifi-densepose-geo 0.1.0",
|
||||
@@ -11075,9 +11105,13 @@ dependencies = [
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"ureq 2.12.1",
|
||||
"wifi-densepose-bfld",
|
||||
"wifi-densepose-engine",
|
||||
"wifi-densepose-geo 0.1.0",
|
||||
"wifi-densepose-hardware",
|
||||
"wifi-densepose-signal",
|
||||
"wifi-densepose-wifiscan",
|
||||
"wifi-densepose-worldgraph 0.3.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -71,6 +71,8 @@ members = [
|
||||
"crates/homecore-assist", # ADR-133 — HOMECORE voice assistant + ruflo bridge
|
||||
"crates/homecore-server", # iter-9 — HOMECORE integration binary (all 8 crates wired together)
|
||||
"crates/ruview-swarm", # ADR-148 — drone swarm control system
|
||||
"crates/ruview-gamma", # ADR-250 — adaptive gamma entrainment (governed research platform)
|
||||
"crates/ruview-gamma-clinic", # ADR-251 — clinical dashboard + hash-chained RuVector store
|
||||
]
|
||||
# ADR-040: WASM edge crate targets wasm32-unknown-unknown (no_std),
|
||||
# excluded from workspace to avoid breaking `cargo test --workspace`.
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
[package]
|
||||
name = "ruview-gamma-clinic"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
description = "Clinical dashboard + persistent hash-chained RuVector store for the Adaptive Gamma platform (ADR-251). Read-only instrumentation over ruview-gamma: participant response maps, session trends, cohort clusters, acceptance verdicts with gated claims. Research tooling, not a medical device."
|
||||
repository.workspace = true
|
||||
publish = false
|
||||
|
||||
[[bin]]
|
||||
name = "gamma-clinic"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
ruview-gamma = { version = "0.3.0", path = "../ruview-gamma" }
|
||||
|
||||
axum = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
# raw_value: the hash chain covers the exact on-disk record bytes (re-serializing
|
||||
# floats is not byte-stable — serde_json's default float parse is fast/lossy).
|
||||
serde_json = { workspace = true, features = ["raw_value"] }
|
||||
sha2 = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tower = { workspace = true }
|
||||
tempfile = "3"
|
||||
@@ -0,0 +1,141 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>RuView Gamma — Clinical Dashboard (research use only)</title>
|
||||
<style>
|
||||
:root { --bg:#0e1116; --card:#161b22; --ink:#e6edf3; --dim:#8b949e;
|
||||
--ok:#3fb950; --warn:#d29922; --bad:#f85149; --acc:#58a6ff; }
|
||||
body { background:var(--bg); color:var(--ink); font:14px/1.5 system-ui,sans-serif; margin:0; }
|
||||
header { padding:14px 22px; border-bottom:1px solid #21262d; display:flex; gap:14px; align-items:baseline; }
|
||||
header h1 { font-size:17px; margin:0; }
|
||||
header .claim { color:var(--dim); font-size:12px; }
|
||||
#integrity { margin-left:auto; font-size:12px; padding:3px 10px; border-radius:10px; }
|
||||
.ok { background:#12361f; color:var(--ok); }
|
||||
.bad { background:#3d1214; color:var(--bad); }
|
||||
main { display:grid; grid-template-columns:260px 1fr; gap:16px; padding:16px 22px; }
|
||||
.card { background:var(--card); border:1px solid #21262d; border-radius:8px; padding:14px; margin-bottom:16px; }
|
||||
.card h2 { font-size:13px; margin:0 0 10px; color:var(--dim); text-transform:uppercase; letter-spacing:.06em; }
|
||||
#participants li { list-style:none; padding:6px 8px; border-radius:6px; cursor:pointer; font-family:ui-monospace,monospace; font-size:13px; }
|
||||
#participants li:hover, #participants li.sel { background:#1f2630; color:var(--acc); }
|
||||
#participants ul { margin:0; padding:0; }
|
||||
table { width:100%; border-collapse:collapse; font-size:13px; }
|
||||
td,th { padding:5px 8px; border-bottom:1px solid #21262d; text-align:left; }
|
||||
th { color:var(--dim); font-weight:500; }
|
||||
.pass { color:var(--ok); } .fail { color:var(--bad); }
|
||||
svg { width:100%; height:180px; }
|
||||
.axis { stroke:#30363d; stroke-width:1; }
|
||||
.curve { fill:none; stroke:var(--acc); stroke-width:2; }
|
||||
.trend { fill:none; stroke:var(--ok); stroke-width:2; }
|
||||
.comfort { fill:none; stroke:var(--warn); stroke-width:1.5; stroke-dasharray:4 3; }
|
||||
.pt { fill:var(--acc); }
|
||||
.stop { fill:var(--bad); }
|
||||
.lbl { fill:var(--dim); font-size:10px; }
|
||||
footer { color:var(--dim); font-size:11px; padding:10px 22px; border-top:1px solid #21262d; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>RuView Gamma — Clinical Dashboard</h1>
|
||||
<span class="claim">read-only · pseudonymous · research use only — not a medical device</span>
|
||||
<span id="integrity">checking…</span>
|
||||
</header>
|
||||
<main>
|
||||
<aside>
|
||||
<div class="card"><h2>Participants</h2><div id="participants"><ul></ul></div></div>
|
||||
<div class="card"><h2>Cohort clusters</h2><div id="cohort"></div></div>
|
||||
</aside>
|
||||
<section>
|
||||
<div class="card"><h2>Frequency response map <span id="ptag"></span></h2><svg id="freqmap"></svg></div>
|
||||
<div class="card"><h2>Session trend (entrainment ─ / comfort ╌ / safety stops ●)</h2><svg id="trend"></svg></div>
|
||||
<div class="card"><h2>Program acceptance (claims released only by the gate)</h2>
|
||||
<table id="acceptance"><thead><tr>
|
||||
<th>program</th><th>gain</th><th>stops</th><th>adherence</th><th>repeat ±Hz</th><th>verdict</th><th>released claim</th>
|
||||
</tr></thead><tbody></tbody></table>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<footer>ADR-251 · every record hash-chained; integrity badge is green only when the on-disk chain verifies · claims pass through the ADR-250 acceptance gate, never around it</footer>
|
||||
<script>
|
||||
const $ = (s) => document.querySelector(s);
|
||||
const J = (u) => fetch(u).then(r => r.json());
|
||||
|
||||
function line(svg, pts, cls, w=560, h=170, pad=28) {
|
||||
if (!pts.length) return;
|
||||
const xs = pts.map(p=>p[0]), ys = pts.map(p=>p[1]);
|
||||
const x0=Math.min(...xs), x1=Math.max(...xs), y0=Math.min(0,...ys), y1=Math.max(1e-9,...ys);
|
||||
const X = v => pad + (v-x0)/((x1-x0)||1)*(w-2*pad);
|
||||
const Y = v => h-pad - (v-y0)/((y1-y0)||1)*(h-2*pad);
|
||||
const d = pts.map((p,i)=>(i?'L':'M')+X(p[0]).toFixed(1)+','+Y(p[1]).toFixed(1)).join(' ');
|
||||
const el = document.createElementNS('http://www.w3.org/2000/svg','path');
|
||||
el.setAttribute('d', d); el.setAttribute('class', cls); svg.appendChild(el);
|
||||
return {X, Y};
|
||||
}
|
||||
function axes(svg, w=560, h=170, pad=28) {
|
||||
svg.innerHTML = '';
|
||||
const ax = document.createElementNS('http://www.w3.org/2000/svg','path');
|
||||
ax.setAttribute('d', `M${pad},${pad} L${pad},${h-pad} L${w-pad},${h-pad}`);
|
||||
ax.setAttribute('class','axis'); svg.appendChild(ax);
|
||||
}
|
||||
function label(svg, x, y, text) {
|
||||
const t = document.createElementNS('http://www.w3.org/2000/svg','text');
|
||||
t.setAttribute('x',x); t.setAttribute('y',y); t.setAttribute('class','lbl');
|
||||
t.textContent = text; svg.appendChild(t);
|
||||
}
|
||||
|
||||
async function loadParticipant(tag) {
|
||||
$('#ptag').textContent = '— ' + tag;
|
||||
document.querySelectorAll('#participants li').forEach(li =>
|
||||
li.classList.toggle('sel', li.textContent.startsWith(tag)));
|
||||
const d = await J('/api/clinic/participants/' + tag);
|
||||
const fm = $('#freqmap'); axes(fm);
|
||||
line(fm, d.frequency_curve, 'curve');
|
||||
d.frequency_curve.forEach(p => {});
|
||||
label(fm, 30, 14, 'score vs frequency (Hz)');
|
||||
const tr = $('#trend'); axes(tr);
|
||||
const ent = d.sessions.map((s,i)=>[i, s.entrainment_score]);
|
||||
const com = d.sessions.map((s,i)=>[i, s.comfort]);
|
||||
const m = line(tr, ent, 'trend');
|
||||
line(tr, com, 'comfort');
|
||||
if (m) d.sessions.forEach((s,i) => {
|
||||
if (!s.safety_pass) {
|
||||
const c = document.createElementNS('http://www.w3.org/2000/svg','circle');
|
||||
c.setAttribute('cx', m.X(i)); c.setAttribute('cy', m.Y(s.entrainment_score));
|
||||
c.setAttribute('r', 4); c.setAttribute('class','stop'); tr.appendChild(c);
|
||||
}
|
||||
});
|
||||
label(tr, 30, 14, d.sessions.length + ' sessions');
|
||||
}
|
||||
|
||||
async function init() {
|
||||
const integ = await J('/api/clinic/integrity');
|
||||
const b = $('#integrity');
|
||||
b.textContent = integ.valid ? `chain ok · ${integ.records} records` : `CHAIN BROKEN @ line ${integ.broken_at}`;
|
||||
b.className = integ.valid ? 'ok' : 'bad';
|
||||
|
||||
const ps = await J('/api/clinic/participants');
|
||||
const ul = $('#participants ul');
|
||||
ps.forEach(p => {
|
||||
const li = document.createElement('li');
|
||||
li.textContent = `${p.tag} · ${p.sessions} ses · ${p.safety_stops ? p.safety_stops + ' stops' : 'clean'}`;
|
||||
li.onclick = () => loadParticipant(p.tag);
|
||||
ul.appendChild(li);
|
||||
});
|
||||
if (ps.length) loadParticipant(ps[0].tag);
|
||||
|
||||
const co = await J('/api/clinic/cohort');
|
||||
$('#cohort').innerHTML = co.clusters.map((c,i) =>
|
||||
`<div>cluster ${i}: <b>${c.members.length}</b> · ${c.members.join(', ')}</div>`).join('') || '<i>no profiles</i>';
|
||||
|
||||
const acc = await J('/api/clinic/acceptance');
|
||||
$('#acceptance tbody').innerHTML = acc.map(a => `<tr>
|
||||
<td>${a.program_id}</td><td>${(a.entrainment_gain*100).toFixed(1)}%</td>
|
||||
<td>${(a.safety_stop_rate*100).toFixed(1)}%</td><td>${(a.mean_adherence*100).toFixed(0)}%</td>
|
||||
<td>${a.repeatability_band_hz.toFixed(2)}</td>
|
||||
<td class="${a.overall_pass?'pass':'fail'}">${a.overall_pass?'PASS':'WITHHELD'}</td>
|
||||
<td>${a.released_claim}</td></tr>`).join('');
|
||||
}
|
||||
init();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,128 @@
|
||||
//! # ruview-gamma-clinic — Clinical dashboard + persistent RuVector store (ADR-251)
|
||||
//!
|
||||
//! Read-only research/clinical instrumentation over the ADR-250 adaptive-gamma
|
||||
//! platform: a **hash-chained JSONL store** (profiles, witnessed session
|
||||
//! summaries, acceptance verdicts — any retroactive edit breaks the chain and
|
||||
//! the store refuses to open) plus an **axum dashboard** (participant response
|
||||
//! maps, session trends with safety-stop markers, cohort clusters, per-program
|
||||
//! acceptance verdicts carrying only gate-released claims, and a live
|
||||
//! chain-integrity badge).
|
||||
//!
|
||||
//! > **Not a medical device.** This surface can neither start stimulation nor
|
||||
//! > widen a safety envelope — there are no mutating routes (tested). It
|
||||
//! > renders what the ADR-250 acceptance gate released, nothing stronger.
|
||||
//!
|
||||
//! ## Quick start
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use std::sync::Arc;
|
||||
//! use tokio::sync::RwLock;
|
||||
//! use ruview_gamma_clinic::{store::ClinicStore, server::router};
|
||||
//!
|
||||
//! # async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
//! let store = Arc::new(RwLock::new(ClinicStore::open("clinic.jsonl")?));
|
||||
//! let app = router(store);
|
||||
//! let listener = tokio::net::TcpListener::bind("127.0.0.1:8090").await?;
|
||||
//! axum::serve(listener, app).await?;
|
||||
//! # Ok(())
|
||||
//! # }
|
||||
//! ```
|
||||
|
||||
pub mod server;
|
||||
pub mod store;
|
||||
|
||||
use ruview_gamma::ruflo::RufloGovernor;
|
||||
use store::{ClinicRecord, ClinicStore, SessionSummary, StoreError};
|
||||
|
||||
/// Ingest a governor's current state into the store: upsert the anonymized
|
||||
/// profile and append any sessions not yet persisted (deduplicated by the
|
||||
/// session witness hash). Returns how many new sessions were appended.
|
||||
///
|
||||
/// This is the bridge from the live ADR-250 loop to the durable clinic record:
|
||||
/// call it after a session (or batch) completes.
|
||||
///
|
||||
/// # Errors
|
||||
/// Propagates [`StoreError`] from the underlying append.
|
||||
pub fn ingest_governor(
|
||||
store: &mut ClinicStore,
|
||||
gov: &RufloGovernor,
|
||||
program_id: &str,
|
||||
) -> Result<usize, StoreError> {
|
||||
let profile = gov.export_anonymized_profile();
|
||||
let tag = profile.profile_tag.clone();
|
||||
store.append(ClinicRecord::Profile(profile))?;
|
||||
|
||||
let known: std::collections::BTreeSet<String> = store
|
||||
.sessions_for(&tag)
|
||||
.iter()
|
||||
.map(|s| s.session_hash.clone())
|
||||
.collect();
|
||||
|
||||
let mut appended = 0usize;
|
||||
for rec in gov.audit_log() {
|
||||
if known.contains(&rec.session_hash) {
|
||||
continue;
|
||||
}
|
||||
store.append(ClinicRecord::Session(SessionSummary {
|
||||
profile_tag: tag.clone(),
|
||||
program_id: program_id.to_string(),
|
||||
frequency_hz: rec.stimulus.frequency_hz,
|
||||
entrainment_score: rec.outcome.entrainment_score,
|
||||
comfort: rec.subjective.comfort,
|
||||
safety_pass: rec.outcome.safety_pass,
|
||||
session_hash: rec.session_hash.clone(),
|
||||
timestamp_ms: rec.timestamp_ms,
|
||||
}))?;
|
||||
appended += 1;
|
||||
}
|
||||
Ok(appended)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ruview_gamma::response::RuViewState;
|
||||
use ruview_gamma::ruflo::Consent;
|
||||
use ruview_gamma::simulator::{LatentPerson, ResponseSimulator};
|
||||
use ruview_gamma::stimulus::SafetyEnvelope;
|
||||
|
||||
/// End-to-end: a governed calibration run lands in the store with the
|
||||
/// pseudonymous tag, witnessed hashes, and an intact chain — and re-ingest
|
||||
/// is idempotent (witness-hash dedup).
|
||||
#[test]
|
||||
fn governor_ingest_roundtrip_and_idempotence() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("clinic.jsonl");
|
||||
let mut store = ClinicStore::open(&path).unwrap();
|
||||
|
||||
let mut gov = RufloGovernor::enroll(
|
||||
"subject-secret-001",
|
||||
SafetyEnvelope::conservative(),
|
||||
&[],
|
||||
Consent::Granted,
|
||||
)
|
||||
.unwrap();
|
||||
let sim = ResponseSimulator::new(42);
|
||||
let latent = LatentPerson::from_id("subject-secret-001");
|
||||
gov.run_calibration(&sim, &latent, &RuViewState::calm_baseline(), 5.0, 0)
|
||||
.unwrap();
|
||||
|
||||
let n = ingest_governor(&mut store, &gov, "alzheimers-research").unwrap();
|
||||
assert_eq!(n, 9); // the 36..44 Hz sweep
|
||||
|
||||
// Pseudonymity: the person_id never appears anywhere in the file.
|
||||
let raw = std::fs::read_to_string(&path).unwrap();
|
||||
assert!(!raw.contains("subject-secret-001"));
|
||||
|
||||
// Re-ingest appends nothing new (dedup by witness hash).
|
||||
let again = ingest_governor(&mut store, &gov, "alzheimers-research").unwrap();
|
||||
assert_eq!(again, 0);
|
||||
|
||||
// Chain stays valid across reopen, with sessions queryable by tag.
|
||||
let reopened = ClinicStore::open(&path).unwrap();
|
||||
assert!(reopened.verify_chain().valid);
|
||||
let tags = reopened.participant_tags();
|
||||
assert_eq!(tags.len(), 1);
|
||||
assert_eq!(reopened.sessions_for(&tags[0]).len(), 9);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
//! `gamma-clinic` — serve the ADR-251 clinical dashboard over a store file.
|
||||
//!
|
||||
//! Usage: `gamma-clinic [STORE_PATH] [BIND_ADDR]`
|
||||
//! Defaults: `clinic.jsonl`, `127.0.0.1:8090`. Read-only surface.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use ruview_gamma_clinic::server::router;
|
||||
use ruview_gamma_clinic::store::ClinicStore;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut args = std::env::args().skip(1);
|
||||
let store_path = args.next().unwrap_or_else(|| "clinic.jsonl".to_string());
|
||||
let bind = args.next().unwrap_or_else(|| "127.0.0.1:8090".to_string());
|
||||
|
||||
// Fails closed on a tampered chain — refuses to serve doctored data.
|
||||
let store = ClinicStore::open(&store_path)?;
|
||||
let status = store.verify_chain();
|
||||
println!(
|
||||
"gamma-clinic: store={store_path} records={} chain={}",
|
||||
status.records,
|
||||
if status.valid { "ok" } else { "BROKEN" }
|
||||
);
|
||||
|
||||
let app = router(Arc::new(RwLock::new(store)));
|
||||
let listener = tokio::net::TcpListener::bind(&bind).await?;
|
||||
println!("gamma-clinic: dashboard at http://{bind}/ (read-only; research use only)");
|
||||
axum::serve(listener, app).await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
//! Read-only clinical dashboard API (ADR-251 §2.2).
|
||||
//!
|
||||
//! Strictly observational: no route mutates stimulation state, widens an
|
||||
//! envelope, or writes to the store. Claim discipline is inherited — the
|
||||
//! acceptance payload carries the gate's `released_claim` verbatim (which is
|
||||
//! `NO_CLAIM` for any program that has not passed), never a raw program claim.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::{Path, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{Html, IntoResponse, Json};
|
||||
use axum::routing::get;
|
||||
use axum::Router;
|
||||
use serde::Serialize;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use crate::store::ClinicStore;
|
||||
|
||||
/// Shared, read-locked store handle.
|
||||
pub type SharedStore = Arc<RwLock<ClinicStore>>;
|
||||
|
||||
/// The embedded single-file dashboard (no build step, no CDN — auditable by
|
||||
/// reading one file).
|
||||
pub const DASHBOARD_HTML: &str = include_str!("dashboard.html");
|
||||
|
||||
/// Participant list row.
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ParticipantRow {
|
||||
tag: String,
|
||||
sessions: usize,
|
||||
mean_entrainment: f64,
|
||||
safety_stops: usize,
|
||||
drift_flagged: bool,
|
||||
}
|
||||
|
||||
/// Per-participant detail: response map + session trend.
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ParticipantDetail {
|
||||
tag: String,
|
||||
/// `(frequency_hz, score)` points sorted by frequency — the personal
|
||||
/// response map rendered by the dashboard.
|
||||
frequency_curve: Vec<(f64, f64)>,
|
||||
sessions: Vec<SessionPoint>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct SessionPoint {
|
||||
frequency_hz: f64,
|
||||
entrainment_score: f64,
|
||||
comfort: f64,
|
||||
safety_pass: bool,
|
||||
session_hash: String,
|
||||
timestamp_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct CohortView {
|
||||
clusters: Vec<Cluster>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct Cluster {
|
||||
members: Vec<String>,
|
||||
}
|
||||
|
||||
/// Build the dashboard router over a shared store.
|
||||
pub fn router(store: SharedStore) -> Router {
|
||||
Router::new()
|
||||
.route("/", get(dashboard))
|
||||
.route("/api/clinic/participants", get(participants))
|
||||
.route("/api/clinic/participants/:tag", get(participant_detail))
|
||||
.route("/api/clinic/cohort", get(cohort))
|
||||
.route("/api/clinic/acceptance", get(acceptance))
|
||||
.route("/api/clinic/integrity", get(integrity))
|
||||
.with_state(store)
|
||||
}
|
||||
|
||||
async fn dashboard() -> Html<&'static str> {
|
||||
Html(DASHBOARD_HTML)
|
||||
}
|
||||
|
||||
async fn participants(State(store): State<SharedStore>) -> Json<Vec<ParticipantRow>> {
|
||||
let s = store.read().await;
|
||||
let rows = s
|
||||
.participant_tags()
|
||||
.into_iter()
|
||||
.map(|tag| {
|
||||
let sessions = s.sessions_for(&tag);
|
||||
let n = sessions.len();
|
||||
let mean = if n > 0 {
|
||||
sessions.iter().map(|x| x.entrainment_score).sum::<f64>() / n as f64
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let stops = sessions.iter().filter(|x| !x.safety_pass).count();
|
||||
// Adverse flag from the stored vector (index 19 is sticky).
|
||||
let drift_flagged = s
|
||||
.profile_for(&tag)
|
||||
.map(|p| p.vector[19] >= 1.0)
|
||||
.unwrap_or(false);
|
||||
ParticipantRow {
|
||||
tag,
|
||||
sessions: n,
|
||||
mean_entrainment: mean,
|
||||
safety_stops: stops,
|
||||
drift_flagged,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Json(rows)
|
||||
}
|
||||
|
||||
async fn participant_detail(
|
||||
State(store): State<SharedStore>,
|
||||
Path(tag): Path<String>,
|
||||
) -> Result<Json<ParticipantDetail>, StatusCode> {
|
||||
let s = store.read().await;
|
||||
let sessions = s.sessions_for(&tag);
|
||||
let profile = s.profile_for(&tag);
|
||||
if sessions.is_empty() && profile.is_none() {
|
||||
return Err(StatusCode::NOT_FOUND);
|
||||
}
|
||||
// Frequency curve: prefer the stored profile's transferable map; fall back
|
||||
// to per-session (frequency, score) points.
|
||||
let mut frequency_curve: Vec<(f64, f64)> = match profile {
|
||||
Some(p) if !p.frequency_scores.is_empty() => p.frequency_scores.clone(),
|
||||
_ => sessions
|
||||
.iter()
|
||||
.map(|x| (x.frequency_hz, x.entrainment_score))
|
||||
.collect(),
|
||||
};
|
||||
frequency_curve.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
|
||||
Ok(Json(ParticipantDetail {
|
||||
tag,
|
||||
frequency_curve,
|
||||
sessions: sessions
|
||||
.iter()
|
||||
.map(|x| SessionPoint {
|
||||
frequency_hz: x.frequency_hz,
|
||||
entrainment_score: x.entrainment_score,
|
||||
comfort: x.comfort,
|
||||
safety_pass: x.safety_pass,
|
||||
session_hash: x.session_hash.clone(),
|
||||
timestamp_ms: x.timestamp_ms,
|
||||
})
|
||||
.collect(),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn cohort(State(store): State<SharedStore>) -> Json<CohortView> {
|
||||
let s = store.read().await;
|
||||
let profiles = s.profiles();
|
||||
let n = profiles.len();
|
||||
if n == 0 {
|
||||
return Json(CohortView {
|
||||
clusters: Vec::new(),
|
||||
});
|
||||
}
|
||||
let k = 3.min(n);
|
||||
let assign = profiles.cluster(k, 10);
|
||||
let mut clusters: Vec<Cluster> = (0..k)
|
||||
.map(|_| Cluster {
|
||||
members: Vec::new(),
|
||||
})
|
||||
.collect();
|
||||
for (i, &c) in assign.iter().enumerate() {
|
||||
if let Some(p) = profiles.profile(i) {
|
||||
clusters[c].members.push(p.profile_tag.clone());
|
||||
}
|
||||
}
|
||||
clusters.retain(|c| !c.members.is_empty());
|
||||
Json(CohortView { clusters })
|
||||
}
|
||||
|
||||
async fn acceptance(State(store): State<SharedStore>) -> impl IntoResponse {
|
||||
let s = store.read().await;
|
||||
// Serialize the stored summaries directly — `released_claim` is the gate's
|
||||
// output, recorded at evaluation time; this surface never reconstructs or
|
||||
// upgrades a claim.
|
||||
let list: Vec<_> = s.acceptance_reports().values().cloned().collect();
|
||||
Json(list)
|
||||
}
|
||||
|
||||
async fn integrity(State(store): State<SharedStore>) -> impl IntoResponse {
|
||||
let s = store.read().await;
|
||||
Json(s.verify_chain())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::store::{AcceptanceSummary, ClinicRecord, SessionSummary};
|
||||
use axum::body::Body;
|
||||
use axum::http::Request;
|
||||
use ruview_gamma::acceptance::NO_CLAIM;
|
||||
use ruview_gamma::ruvector::{AnonymizedProfile, VECTOR_DIM};
|
||||
use tower::ServiceExt;
|
||||
|
||||
async fn body_json(res: axum::response::Response) -> serde_json::Value {
|
||||
let bytes = axum::body::to_bytes(res.into_body(), 1 << 20)
|
||||
.await
|
||||
.unwrap();
|
||||
serde_json::from_slice(&bytes).unwrap()
|
||||
}
|
||||
|
||||
fn seeded_store() -> SharedStore {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("clinic.jsonl");
|
||||
let mut s = ClinicStore::open(&path).unwrap();
|
||||
let mut vector = [0.5; VECTOR_DIM];
|
||||
vector[11] = 39.0;
|
||||
s.append(ClinicRecord::Profile(AnonymizedProfile {
|
||||
profile_tag: "tag-a".into(),
|
||||
vector,
|
||||
frequency_scores: vec![(38.0, 0.5), (39.0, 0.8), (40.0, 0.6)],
|
||||
}))
|
||||
.unwrap();
|
||||
for (hz, score, pass) in [(38.0, 0.5, true), (39.0, 0.8, true), (40.0, 0.6, false)] {
|
||||
s.append(ClinicRecord::Session(SessionSummary {
|
||||
profile_tag: "tag-a".into(),
|
||||
program_id: "alzheimers-research".into(),
|
||||
frequency_hz: hz,
|
||||
entrainment_score: score,
|
||||
comfort: 0.85,
|
||||
safety_pass: pass,
|
||||
session_hash: "cd".repeat(32),
|
||||
timestamp_ms: 1,
|
||||
}))
|
||||
.unwrap();
|
||||
}
|
||||
// One passed and one withheld acceptance verdict.
|
||||
s.append(ClinicRecord::Acceptance(AcceptanceSummary {
|
||||
program_id: "attention-working-memory".into(),
|
||||
entrainment_gain: 0.3,
|
||||
safety_stop_rate: 0.0,
|
||||
mean_adherence: 0.95,
|
||||
repeatability_band_hz: 0.8,
|
||||
overall_pass: true,
|
||||
released_claim: "personalized frequency-response discovery".into(),
|
||||
}))
|
||||
.unwrap();
|
||||
s.append(ClinicRecord::Acceptance(AcceptanceSummary {
|
||||
program_id: "home-wellness".into(),
|
||||
entrainment_gain: 0.05,
|
||||
safety_stop_rate: 0.0,
|
||||
mean_adherence: 0.9,
|
||||
repeatability_band_hz: 3.0,
|
||||
overall_pass: false,
|
||||
released_claim: NO_CLAIM.into(),
|
||||
}))
|
||||
.unwrap();
|
||||
// Leak the tempdir so the file outlives the test router.
|
||||
std::mem::forget(dir);
|
||||
Arc::new(RwLock::new(s))
|
||||
}
|
||||
|
||||
async fn get(router: &Router, uri: &str) -> axum::response::Response {
|
||||
router
|
||||
.clone()
|
||||
.oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dashboard_html_served() {
|
||||
let r = router(seeded_store());
|
||||
let res = get(&r, "/").await;
|
||||
assert_eq!(res.status(), StatusCode::OK);
|
||||
let bytes = axum::body::to_bytes(res.into_body(), 1 << 20)
|
||||
.await
|
||||
.unwrap();
|
||||
let html = String::from_utf8(bytes.to_vec()).unwrap();
|
||||
assert!(html.contains("Clinical Dashboard"));
|
||||
assert!(html.contains("research use only"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn participants_lists_sessions_and_stops() {
|
||||
let r = router(seeded_store());
|
||||
let v = body_json(get(&r, "/api/clinic/participants").await).await;
|
||||
assert_eq!(v[0]["tag"], "tag-a");
|
||||
assert_eq!(v[0]["sessions"], 3);
|
||||
assert_eq!(v[0]["safety_stops"], 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn participant_detail_serves_sorted_frequency_curve() {
|
||||
let r = router(seeded_store());
|
||||
let v = body_json(get(&r, "/api/clinic/participants/tag-a").await).await;
|
||||
let curve = v["frequency_curve"].as_array().unwrap();
|
||||
assert_eq!(curve.len(), 3);
|
||||
// Sorted ascending by frequency.
|
||||
assert!(curve[0][0].as_f64().unwrap() < curve[2][0].as_f64().unwrap());
|
||||
assert_eq!(v["sessions"].as_array().unwrap().len(), 3);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unknown_participant_is_404() {
|
||||
let r = router(seeded_store());
|
||||
assert_eq!(
|
||||
get(&r, "/api/clinic/participants/nobody").await.status(),
|
||||
StatusCode::NOT_FOUND
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn acceptance_payload_uses_gated_claim() {
|
||||
let r = router(seeded_store());
|
||||
let v = body_json(get(&r, "/api/clinic/acceptance").await).await;
|
||||
let list = v.as_array().unwrap();
|
||||
assert_eq!(list.len(), 2);
|
||||
// The failed program surfaces NO_CLAIM verbatim — never its raw claim.
|
||||
let withheld = list
|
||||
.iter()
|
||||
.find(|a| a["program_id"] == "home-wellness")
|
||||
.unwrap();
|
||||
assert_eq!(withheld["overall_pass"], false);
|
||||
assert_eq!(withheld["released_claim"], NO_CLAIM);
|
||||
let passed = list
|
||||
.iter()
|
||||
.find(|a| a["program_id"] == "attention-working-memory")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
passed["released_claim"],
|
||||
"personalized frequency-response discovery"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cohort_and_integrity_endpoints_respond() {
|
||||
let r = router(seeded_store());
|
||||
let co = body_json(get(&r, "/api/clinic/cohort").await).await;
|
||||
assert!(co["clusters"].as_array().unwrap().len() >= 1);
|
||||
let integ = body_json(get(&r, "/api/clinic/integrity").await).await;
|
||||
assert_eq!(integ["valid"], true);
|
||||
assert_eq!(integ["records"], 6);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn surface_is_read_only_no_mutating_routes() {
|
||||
// POST to every route must not be routable (405/404), proving the
|
||||
// surface cannot actuate or write.
|
||||
let r = router(seeded_store());
|
||||
for uri in [
|
||||
"/",
|
||||
"/api/clinic/participants",
|
||||
"/api/clinic/participants/tag-a",
|
||||
"/api/clinic/cohort",
|
||||
"/api/clinic/acceptance",
|
||||
"/api/clinic/integrity",
|
||||
] {
|
||||
let res = r
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri(uri)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
res.status() == StatusCode::METHOD_NOT_ALLOWED
|
||||
|| res.status() == StatusCode::NOT_FOUND,
|
||||
"POST {uri} unexpectedly routable: {}",
|
||||
res.status()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
//! Persistent, hash-chained RuVector store (ADR-251 §2.1).
|
||||
//!
|
||||
//! Append-only JSON-lines file holding three record kinds — anonymized
|
||||
//! profiles, witnessed session summaries, and acceptance reports. Every line
|
||||
//! is hash-chained: `entry_hash = SHA-256(prev_hash ‖ canonical_record_json)`,
|
||||
//! so any retroactive edit, deletion, or reorder breaks [`ClinicStore::verify_chain`].
|
||||
//! The RuVector in-memory layer (kNN, clustering) is rebuilt from the file on
|
||||
//! [`ClinicStore::open`], so cohort warm-start survives restarts.
|
||||
//!
|
||||
//! Pseudonymity: records carry only the one-way profile tags from ADR-250 §10
|
||||
//! — never a `person_id`, never raw sensor data.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use ruview_gamma::ruvector::{AnonymizedProfile, ProfileStore};
|
||||
|
||||
/// Store errors.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum StoreError {
|
||||
/// Filesystem failure.
|
||||
#[error("io error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
/// A line failed to parse.
|
||||
#[error("corrupt record at line {line}: {reason}")]
|
||||
Corrupt { line: usize, reason: String },
|
||||
}
|
||||
|
||||
/// One witnessed session summary, as persisted for the dashboard. A projection
|
||||
/// of `ruview_gamma::session::SessionRecord` keyed by the one-way profile tag.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SessionSummary {
|
||||
/// One-way profile tag (never a person_id).
|
||||
pub profile_tag: String,
|
||||
/// Program the session ran under.
|
||||
pub program_id: String,
|
||||
/// Stimulation frequency (Hz).
|
||||
pub frequency_hz: f64,
|
||||
/// Safe-entrainment score for the session.
|
||||
pub entrainment_score: f64,
|
||||
/// Participant comfort `[0,1]`.
|
||||
pub comfort: f64,
|
||||
/// Whether the session passed without a safety stop.
|
||||
pub safety_pass: bool,
|
||||
/// The session's witness hash (hex SHA-256 from the RuFlo builder).
|
||||
pub session_hash: String,
|
||||
/// Caller-supplied epoch milliseconds.
|
||||
pub timestamp_ms: u64,
|
||||
}
|
||||
|
||||
/// One persisted acceptance verdict (the gate's output, never the raw claim).
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct AcceptanceSummary {
|
||||
/// Program graded.
|
||||
pub program_id: String,
|
||||
/// Measured entrainment gain vs the fixed prior.
|
||||
pub entrainment_gain: f64,
|
||||
/// Measured safety-stop rate.
|
||||
pub safety_stop_rate: f64,
|
||||
/// Measured mean adherence.
|
||||
pub mean_adherence: f64,
|
||||
/// Optimal-frequency spread across repeats (Hz).
|
||||
pub repeatability_band_hz: f64,
|
||||
/// Whether all four criteria passed.
|
||||
pub overall_pass: bool,
|
||||
/// The claim **as released by the gate** (`NO_CLAIM` on failure).
|
||||
pub released_claim: String,
|
||||
}
|
||||
|
||||
/// A store record: exactly one of the three kinds per line.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum ClinicRecord {
|
||||
/// Anonymized responder profile (upserted by tag on load).
|
||||
Profile(AnonymizedProfile),
|
||||
/// Witnessed session summary.
|
||||
Session(SessionSummary),
|
||||
/// Acceptance verdict for a program.
|
||||
Acceptance(AcceptanceSummary),
|
||||
}
|
||||
|
||||
/// One persisted line: the record plus its chain hash.
|
||||
///
|
||||
/// `record` is kept as a [`serde_json::value::RawValue`] so the chain hashes
|
||||
/// the **exact bytes on disk**. Re-serializing a parsed record is not
|
||||
/// byte-stable: serde_json's default float parsing is fast-but-lossy (±1 ulp;
|
||||
/// exact parsing is behind its `float_roundtrip` feature), so
|
||||
/// `to_string(from_str(x))` can differ from `x` for long float literals —
|
||||
/// hash-by-reserialization would self-corrupt.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct ChainedLine {
|
||||
record: Box<serde_json::value::RawValue>,
|
||||
/// hex SHA-256(prev_hash ‖ raw record json bytes)
|
||||
entry_hash: String,
|
||||
}
|
||||
|
||||
/// Result of an integrity check.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
pub struct ChainStatus {
|
||||
/// Whether every entry hash verified.
|
||||
pub valid: bool,
|
||||
/// Number of records in the chain.
|
||||
pub records: usize,
|
||||
/// First broken line (1-based), if any.
|
||||
pub broken_at: Option<usize>,
|
||||
}
|
||||
|
||||
/// The persistent clinic store: hash-chained JSONL on disk + the RuVector
|
||||
/// in-memory layer rebuilt on open.
|
||||
pub struct ClinicStore {
|
||||
path: PathBuf,
|
||||
/// Last entry hash (hex) — the chain head.
|
||||
head: String,
|
||||
/// RuVector layer over the loaded profiles (kNN, clustering).
|
||||
profiles: ProfileStore,
|
||||
/// Session summaries by profile tag, in append order.
|
||||
sessions: BTreeMap<String, Vec<SessionSummary>>,
|
||||
/// Latest acceptance verdict per program.
|
||||
acceptance: BTreeMap<String, AcceptanceSummary>,
|
||||
}
|
||||
|
||||
/// Chain-genesis constant (the `prev_hash` of the first record).
|
||||
const GENESIS: &str = "ruview-gamma-clinic-genesis-v1";
|
||||
|
||||
fn entry_hash(prev: &str, record_json: &str) -> String {
|
||||
let mut h = Sha256::new();
|
||||
h.update(prev.as_bytes());
|
||||
h.update(record_json.as_bytes());
|
||||
let d = h.finalize();
|
||||
let mut s = String::with_capacity(64);
|
||||
for b in d {
|
||||
s.push_str(&format!("{b:02x}"));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
impl ClinicStore {
|
||||
/// Open (or create) a store at `path`, replaying and verifying every line.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`StoreError::Corrupt`] if a line fails to parse or breaks the chain —
|
||||
/// fail closed: a tampered store refuses to open rather than silently
|
||||
/// serving doctored data.
|
||||
pub fn open(path: impl AsRef<Path>) -> Result<Self, StoreError> {
|
||||
let path = path.as_ref().to_path_buf();
|
||||
let mut store = Self {
|
||||
path: path.clone(),
|
||||
head: GENESIS.to_string(),
|
||||
profiles: ProfileStore::new(),
|
||||
sessions: BTreeMap::new(),
|
||||
acceptance: BTreeMap::new(),
|
||||
};
|
||||
if !path.exists() {
|
||||
return Ok(store);
|
||||
}
|
||||
let file = File::open(&path)?;
|
||||
for (i, line) in BufReader::new(file).lines().enumerate() {
|
||||
let line = line?;
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let chained: ChainedLine =
|
||||
serde_json::from_str(&line).map_err(|e| StoreError::Corrupt {
|
||||
line: i + 1,
|
||||
reason: e.to_string(),
|
||||
})?;
|
||||
// Hash the exact raw bytes from disk — never a re-serialization.
|
||||
let expect = entry_hash(&store.head, chained.record.get());
|
||||
if expect != chained.entry_hash {
|
||||
return Err(StoreError::Corrupt {
|
||||
line: i + 1,
|
||||
reason: "hash chain broken".into(),
|
||||
});
|
||||
}
|
||||
let record: ClinicRecord =
|
||||
serde_json::from_str(chained.record.get()).map_err(|e| StoreError::Corrupt {
|
||||
line: i + 1,
|
||||
reason: e.to_string(),
|
||||
})?;
|
||||
store.head = chained.entry_hash;
|
||||
store.apply(record);
|
||||
}
|
||||
Ok(store)
|
||||
}
|
||||
|
||||
/// Apply a record to the in-memory views.
|
||||
fn apply(&mut self, record: ClinicRecord) {
|
||||
match record {
|
||||
ClinicRecord::Profile(p) => self.profiles.upsert(p),
|
||||
ClinicRecord::Session(s) => self
|
||||
.sessions
|
||||
.entry(s.profile_tag.clone())
|
||||
.or_default()
|
||||
.push(s),
|
||||
ClinicRecord::Acceptance(a) => {
|
||||
self.acceptance.insert(a.program_id.clone(), a);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Append a record: chain-hash its exact serialized bytes, write the line,
|
||||
/// update memory.
|
||||
pub fn append(&mut self, record: ClinicRecord) -> Result<(), StoreError> {
|
||||
let record_json = serde_json::to_string(&record).map_err(|e| StoreError::Corrupt {
|
||||
line: 0,
|
||||
reason: e.to_string(),
|
||||
})?;
|
||||
let hash = entry_hash(&self.head, &record_json);
|
||||
let raw = serde_json::value::RawValue::from_string(record_json).map_err(|e| {
|
||||
StoreError::Corrupt {
|
||||
line: 0,
|
||||
reason: e.to_string(),
|
||||
}
|
||||
})?;
|
||||
let line = serde_json::to_string(&ChainedLine {
|
||||
record: raw,
|
||||
entry_hash: hash.clone(),
|
||||
})
|
||||
.map_err(|e| StoreError::Corrupt {
|
||||
line: 0,
|
||||
reason: e.to_string(),
|
||||
})?;
|
||||
let mut f = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&self.path)?;
|
||||
f.write_all(line.as_bytes())?;
|
||||
f.write_all(b"\n")?;
|
||||
self.head = hash;
|
||||
self.apply(record);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Re-read the file from disk and verify the whole chain (tamper check).
|
||||
pub fn verify_chain(&self) -> ChainStatus {
|
||||
let file = match File::open(&self.path) {
|
||||
Ok(f) => f,
|
||||
Err(_) => {
|
||||
return ChainStatus {
|
||||
valid: true,
|
||||
records: 0,
|
||||
broken_at: None,
|
||||
};
|
||||
}
|
||||
};
|
||||
let mut prev = GENESIS.to_string();
|
||||
let mut n = 0usize;
|
||||
for (i, line) in BufReader::new(file).lines().enumerate() {
|
||||
let Ok(line) = line else {
|
||||
return ChainStatus {
|
||||
valid: false,
|
||||
records: n,
|
||||
broken_at: Some(i + 1),
|
||||
};
|
||||
};
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let parsed: Result<ChainedLine, _> = serde_json::from_str(&line);
|
||||
let Ok(chained) = parsed else {
|
||||
return ChainStatus {
|
||||
valid: false,
|
||||
records: n,
|
||||
broken_at: Some(i + 1),
|
||||
};
|
||||
};
|
||||
if entry_hash(&prev, chained.record.get()) != chained.entry_hash {
|
||||
return ChainStatus {
|
||||
valid: false,
|
||||
records: n,
|
||||
broken_at: Some(i + 1),
|
||||
};
|
||||
}
|
||||
prev = chained.entry_hash;
|
||||
n += 1;
|
||||
}
|
||||
ChainStatus {
|
||||
valid: true,
|
||||
records: n,
|
||||
broken_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The RuVector layer over loaded profiles (kNN / warm-start / clustering).
|
||||
pub fn profiles(&self) -> &ProfileStore {
|
||||
&self.profiles
|
||||
}
|
||||
|
||||
/// Sessions for one profile tag, in append order.
|
||||
pub fn sessions_for(&self, tag: &str) -> &[SessionSummary] {
|
||||
self.sessions.get(tag).map(Vec::as_slice).unwrap_or(&[])
|
||||
}
|
||||
|
||||
/// All profile tags with at least one session or profile, sorted.
|
||||
pub fn participant_tags(&self) -> Vec<String> {
|
||||
let mut tags: Vec<String> = self.sessions.keys().cloned().collect();
|
||||
for i in 0..self.profiles.len() {
|
||||
if let Some(p) = self.profiles.profile(i) {
|
||||
if !tags.contains(&p.profile_tag) {
|
||||
tags.push(p.profile_tag.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
tags.sort();
|
||||
tags
|
||||
}
|
||||
|
||||
/// The stored profile for a tag, if any.
|
||||
pub fn profile_for(&self, tag: &str) -> Option<&AnonymizedProfile> {
|
||||
(0..self.profiles.len())
|
||||
.filter_map(|i| self.profiles.profile(i))
|
||||
.find(|p| p.profile_tag == tag)
|
||||
}
|
||||
|
||||
/// Latest acceptance verdicts, keyed by program id.
|
||||
pub fn acceptance_reports(&self) -> &BTreeMap<String, AcceptanceSummary> {
|
||||
&self.acceptance
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ruview_gamma::ruvector::VECTOR_DIM;
|
||||
|
||||
fn profile(tag: &str, peak: f64) -> AnonymizedProfile {
|
||||
let mut vector = [0.5; VECTOR_DIM];
|
||||
vector[5] = 13.0;
|
||||
vector[11] = peak;
|
||||
AnonymizedProfile {
|
||||
profile_tag: tag.into(),
|
||||
vector,
|
||||
frequency_scores: vec![(peak - 1.0, 0.5), (peak, 0.8), (peak + 1.0, 0.5)],
|
||||
}
|
||||
}
|
||||
|
||||
fn session(tag: &str, hz: f64, score: f64) -> SessionSummary {
|
||||
SessionSummary {
|
||||
profile_tag: tag.into(),
|
||||
program_id: "alzheimers-research".into(),
|
||||
frequency_hz: hz,
|
||||
entrainment_score: score,
|
||||
comfort: 0.9,
|
||||
safety_pass: true,
|
||||
session_hash: "ab".repeat(32),
|
||||
timestamp_ms: 1_700_000_000_000,
|
||||
}
|
||||
}
|
||||
|
||||
fn tmp() -> (tempfile::TempDir, PathBuf) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("clinic.jsonl");
|
||||
(dir, path)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrips_all_record_kinds_across_reopen() {
|
||||
let (_d, path) = tmp();
|
||||
{
|
||||
let mut s = ClinicStore::open(&path).unwrap();
|
||||
s.append(ClinicRecord::Profile(profile("tag-a", 39.0)))
|
||||
.unwrap();
|
||||
s.append(ClinicRecord::Session(session("tag-a", 39.0, 0.7)))
|
||||
.unwrap();
|
||||
s.append(ClinicRecord::Session(session("tag-a", 39.5, 0.75)))
|
||||
.unwrap();
|
||||
s.append(ClinicRecord::Acceptance(AcceptanceSummary {
|
||||
program_id: "sleep-optimization".into(),
|
||||
entrainment_gain: 0.25,
|
||||
safety_stop_rate: 0.0,
|
||||
mean_adherence: 0.95,
|
||||
repeatability_band_hz: 1.0,
|
||||
overall_pass: true,
|
||||
released_claim: "sleep-state-timed entrainment optimization".into(),
|
||||
}))
|
||||
.unwrap();
|
||||
}
|
||||
let s = ClinicStore::open(&path).unwrap();
|
||||
assert_eq!(s.participant_tags(), vec!["tag-a".to_string()]);
|
||||
assert_eq!(s.sessions_for("tag-a").len(), 2);
|
||||
assert_eq!(s.profile_for("tag-a").unwrap().frequency_scores.len(), 3);
|
||||
assert!(s.acceptance_reports().contains_key("sleep-optimization"));
|
||||
let st = s.verify_chain();
|
||||
assert!(st.valid);
|
||||
assert_eq!(st.records, 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tampered_chain_is_detected_and_refuses_open() {
|
||||
let (_d, path) = tmp();
|
||||
{
|
||||
let mut s = ClinicStore::open(&path).unwrap();
|
||||
s.append(ClinicRecord::Session(session("tag-a", 40.0, 0.6)))
|
||||
.unwrap();
|
||||
s.append(ClinicRecord::Session(session("tag-a", 41.0, 0.7)))
|
||||
.unwrap();
|
||||
}
|
||||
// Doctor the first line's score 0.6 -> 0.9 (a retroactive edit).
|
||||
let text = std::fs::read_to_string(&path).unwrap();
|
||||
let doctored = text.replacen("0.6", "0.9", 1);
|
||||
assert_ne!(text, doctored);
|
||||
std::fs::write(&path, doctored).unwrap();
|
||||
// Open fails closed…
|
||||
assert!(matches!(
|
||||
ClinicStore::open(&path),
|
||||
Err(StoreError::Corrupt { line: 1, .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_a_line_breaks_the_chain() {
|
||||
let (_d, path) = tmp();
|
||||
{
|
||||
let mut s = ClinicStore::open(&path).unwrap();
|
||||
s.append(ClinicRecord::Session(session("a", 40.0, 0.5)))
|
||||
.unwrap();
|
||||
s.append(ClinicRecord::Session(session("a", 41.0, 0.6)))
|
||||
.unwrap();
|
||||
s.append(ClinicRecord::Session(session("a", 42.0, 0.7)))
|
||||
.unwrap();
|
||||
}
|
||||
let text = std::fs::read_to_string(&path).unwrap();
|
||||
let pruned: Vec<&str> = text
|
||||
.lines()
|
||||
.enumerate()
|
||||
.filter(|(i, _)| *i != 1)
|
||||
.map(|(_, l)| l)
|
||||
.collect();
|
||||
std::fs::write(&path, pruned.join("\n")).unwrap();
|
||||
assert!(ClinicStore::open(&path).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn knn_survives_reload() {
|
||||
let (_d, path) = tmp();
|
||||
{
|
||||
let mut s = ClinicStore::open(&path).unwrap();
|
||||
s.append(ClinicRecord::Profile(profile("lo", 37.0)))
|
||||
.unwrap();
|
||||
s.append(ClinicRecord::Profile(profile("hi", 43.0)))
|
||||
.unwrap();
|
||||
}
|
||||
let s = ClinicStore::open(&path).unwrap();
|
||||
let mut q = [0.5; VECTOR_DIM];
|
||||
q[5] = 13.0;
|
||||
q[11] = 37.0;
|
||||
let nn = s.profiles().k_nearest(&q, 1);
|
||||
assert_eq!(s.profiles().profile(nn[0].0).unwrap().profile_tag, "lo");
|
||||
// Warm-start priors are constructible from the reloaded store.
|
||||
assert!(!s.profiles().warm_start_prior(&q, 2, 1e-4).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_store_is_valid() {
|
||||
let (_d, path) = tmp();
|
||||
let s = ClinicStore::open(&path).unwrap();
|
||||
let st = s.verify_chain();
|
||||
assert!(st.valid);
|
||||
assert_eq!(st.records, 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
[package]
|
||||
name = "ruview-gamma"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
description = "Adaptive Gamma Entrainment (ADR-250) — governed, deterministic, safety-constrained personalization of 40 Hz-prior sensory stimulation (RuView sensing + RuVector response modeling + RuFlo audit). Research platform, not a medical device."
|
||||
repository.workspace = true
|
||||
keywords = ["gamma", "entrainment", "neuromodulation", "bayesian-optimization", "research"]
|
||||
categories = ["science", "simulation"]
|
||||
readme = "README.md"
|
||||
# Research platform. Publishing intentionally disabled: this crate touches a
|
||||
# safety-critical, clinical-adjacent domain (ADR-250 §12). Flip to true only
|
||||
# after safety + claim-discipline sign-off.
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
# rlib for native workspace linking; cdylib so the deterministic core can be
|
||||
# wrapped for a browser/edge dashboard later (same posture as nvsim).
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
# `ruview-gamma` is a standalone leaf crate with NO internal RuView deps — the
|
||||
# governed software core (optimizer, safety envelope, RuVector update logic,
|
||||
# session witness) must be testable and replayable bit-exactly before any
|
||||
# hardware actuation or human exposure. Hardware/RF/EEG adapters land behind
|
||||
# feature flags after the core ships (ADR-250 §21, Milestones 2–4).
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
# Deterministic ChaCha20 PRNG: same (person, state, stimulus, seed) yields
|
||||
# byte-identical synthetic responses across runs and machines — the
|
||||
# reproducibility commitment in ADR-250 §11. default-features off drops the
|
||||
# getrandom OS-entropy path; all seeds are caller-supplied u64.
|
||||
rand = { version = "0.8", default-features = false }
|
||||
rand_chacha = { version = "0.3", default-features = false }
|
||||
|
||||
# SHA-256 session witness: hash(protocol, model, device, stimulus, sensors,
|
||||
# response, safety) — the RuFlo reproducibility trail (ADR-250 §11).
|
||||
sha2 = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
approx = "0.5"
|
||||
criterion = { workspace = true }
|
||||
|
||||
[[bench]]
|
||||
name = "optimizer_bench"
|
||||
harness = false
|
||||
@@ -0,0 +1,220 @@
|
||||
# ruview-gamma — Adaptive Sensory Neuromodulation (ADR-250)
|
||||
|
||||
> **The most valuable thing here is not 40 Hz. It is a governed personalization
|
||||
> engine that refuses to overpromise.**
|
||||
|
||||
The control brain for an adaptive light-and-sound neuromodulation device. The
|
||||
device plays stimulation; **RuView** reads the body as feedback; **RuVector**
|
||||
learns the personal response map; **RuFlo** governs the safety, audit trail, and
|
||||
claim boundary. The breakthrough is not speed alone — it is **safe adaptive
|
||||
personalization with proof discipline**.
|
||||
|
||||
It starts from 40 Hz as the research prior, then learns whether a person
|
||||
responds better at 38.5, 40, 41.2, or another safe setting — watching breathing,
|
||||
stillness, restlessness, adherence, and sensor confidence. If something goes
|
||||
wrong, the session locks. If a program has not proven entrainment, safety,
|
||||
adherence, and repeatability, it cannot advertise a benefit — it returns
|
||||
*research use only*.
|
||||
|
||||
## Benchmarks (this container — indicative)
|
||||
|
||||
| Path | Current | Role |
|
||||
|------|---------|------|
|
||||
| Safety tick | ~8 ns | real-time stop path |
|
||||
| Recommendation | ~15 µs | per-session decision |
|
||||
| Cohort kNN (500 profiles) | ~15 µs | warm-start matching |
|
||||
| Calibration sweep | ~115 µs | setup and tuning |
|
||||
| Full acceptance grading | ~425 µs | enrollment-only (offline) |
|
||||
|
||||
The per-session control loop is microseconds; the heavier acceptance grading is
|
||||
enrollment-time work, not on the loop. No regression across the optimization
|
||||
passes.
|
||||
|
||||
## The hard claim gate
|
||||
|
||||
A program's benefit claim is releasable through exactly one invariant
|
||||
(`acceptance::claim_allowed`), used everywhere:
|
||||
|
||||
```text
|
||||
claim_allowed = entrainment_pass AND safety_pass
|
||||
AND adherence_pass AND repeatability_pass
|
||||
```
|
||||
|
||||
Anything short of all four returns `research use only — … no claim`
|
||||
(`acceptance::NO_CLAIM`). The marketing claim is unreadable except through the
|
||||
gate.
|
||||
|
||||
## Next milestone — hardware in the loop (`hil`)
|
||||
|
||||
The software core is proven against a deterministic simulator; the next
|
||||
acceptance bar is a real LED + speaker actuator (e.g. ESP32-driven) plus the
|
||||
stop path. `hil::verify_hil` grades a captured bench measurement against fixed
|
||||
targets:
|
||||
|
||||
| Test | Target |
|
||||
|------|--------|
|
||||
| LED frequency accuracy | ±0.1 Hz |
|
||||
| Worst-case frequency error over the session window | ±0.1 Hz |
|
||||
| Worst-case half-period jitter over the session window | ≤ 500 µs |
|
||||
| Audio-visual sync drift | < 5 ms |
|
||||
| Stop signal → actuator off | < 100 ms |
|
||||
| Session-hash reproducibility | 100% |
|
||||
| EEG entrainment lift vs fixed 40 Hz | ≥ 20% |
|
||||
|
||||
All criteria fail closed: NaN measurements, impossible hash counts
|
||||
(`reproduced > total`), or an empty replay set grade as FAIL.
|
||||
|
||||
---
|
||||
|
||||
Governed, deterministic, **safety-constrained** personalization of 40 Hz-prior
|
||||
multisensory (light + sound) stimulation. Treats 40 Hz as the evidence-based
|
||||
*starting prior*, then learns each person's safe entrainment response curve using
|
||||
passive RuView sensing, optional EEG, a constrained optimizer, and auditable
|
||||
RuFlo workflows.
|
||||
|
||||
> **Not medical advice / not a medical device.** This crate is a research and
|
||||
> engineering platform. The only claim it makes is **"personalized entrainment
|
||||
> optimization"** (`ruview_gamma::PRODUCT_CLAIM`) — never Alzheimer's treatment,
|
||||
> amyloid clearance, or any clinical outcome (ADR-250 §19). It performs **no
|
||||
> hardware actuation**: real stimulus delivery, RF sensing, and EEG arrive
|
||||
> through external adapters behind feature flags after this governed software
|
||||
> core ships (ADR-250 §21, Milestones 2–4).
|
||||
|
||||
## Why it exists
|
||||
|
||||
The field mostly treats 40 Hz as a fixed protocol. But individual brains differ
|
||||
by baseline gamma, arousal, sleep, sensory acuity, medication, age, and comfort
|
||||
(the 2025 PLOS One 36–44 Hz re-evaluation). Fixed 40 Hz (1) assumes one
|
||||
frequency fits all, (2) never verifies entrainment, (3) ignores physiological
|
||||
state, and (4) cannot safely optimize over time. This crate closes that loop.
|
||||
|
||||
## The safety invariant
|
||||
|
||||
**No recommendation, calibration step, bandit arm, or closed-loop nudge can ever
|
||||
emit a `StimulusParameters` outside the `SafetyEnvelope`.** Every emitting path
|
||||
clamps to the envelope and is asserted against `SafetyEnvelope::contains` in
|
||||
tests. The optimizer never widens the envelope — only an operator constructs a
|
||||
wider one deliberately (ADR-250 §12). Non-finite (NaN/∞) inputs clamp toward the
|
||||
conservative floor, never the cap.
|
||||
|
||||
## Module map
|
||||
|
||||
| Module | Role (ADR-250 §) | Highlights |
|
||||
|--------|------------------|------------|
|
||||
| `stimulus` | §5, §12 | `StimulusParameters`, `SafetyEnvelope` (validate / clamp / grids) |
|
||||
| `safety` | §12 | exclusion screen, latched `SafetyMonitor`, hard-stop reasons |
|
||||
| `response` | §6, §9, §10 | `RuViewState`, optional `EegMeasurement`, 20-field `PersonResponseVector` (RuVector memory) with sticky adverse flag |
|
||||
| `objective` | §7 | safe-entrainment score; safety is a hard gate, not a weight; RF-only proxy when EEG absent |
|
||||
| `simulator` | §21 M1 | deterministic ChaCha20 `frequency_response_curve(person, state, stimulus)` |
|
||||
| `optimizer` | §8 | Phase-1 calibration sweep, Phase-2 GP + Expected-Improvement, Phase-4 closed-loop control |
|
||||
| `bandit` | §8 P3 | LinUCB contextual bandit over envelope-safe arms |
|
||||
| `ruvector` | §10 items 3–6 | anonymized `ProfileStore` (one-way hashed tags), deterministic kNN, cohort warm-start priors (down-weighted pseudo-observations), `DriftDetector` over the physiological sub-vector, deterministic k-means clustering |
|
||||
| `program` | §23 | `NeuroProgram` catalog (7 use cases) — per-program envelope, prior, objective, state-gating, evidence level, and gated claim |
|
||||
| `acceptance` | §18/§23.1 | `AcceptanceHarness` + `ClaimGate` + the `claim_allowed` invariant — entrainment/safety/adherence/repeatability gate; a program's claim is unreadable until all four pass |
|
||||
| `hil` | §17/§21 M2 | hardware-in-the-loop contract: `verify_hil` grades a captured actuator measurement (LED ±0.1 Hz, A/V sync < 5 ms, stop < 100 ms, hash 100%, EEG lift ≥ 20%) |
|
||||
| `session` | §11, §13 | hashable `SessionRecord`, reproducible `session_hash` (SHA-256, quantized canonical form) |
|
||||
| `ruflo` | §11 | consent → exclusion → envelope → run → monitor → score → update → witnessed audit; trial/sham mode; clinician export; claim discipline |
|
||||
| `proof` | — | deterministic bundle witness (mirrors `nvsim` / `verify.py`) |
|
||||
| `math` | — | dependency-light numerics (erf, normal CDF/PDF, Cholesky, RBF) |
|
||||
|
||||
## Quick start
|
||||
|
||||
```rust
|
||||
use ruview_gamma::{
|
||||
ruflo::{Consent, RufloGovernor},
|
||||
response::RuViewState,
|
||||
simulator::{LatentPerson, ResponseSimulator},
|
||||
stimulus::{SafetyEnvelope, StimulusParameters},
|
||||
};
|
||||
|
||||
let envelope = SafetyEnvelope::conservative();
|
||||
let mut gov = RufloGovernor::enroll("subject-001", envelope, &[], Consent::Granted)
|
||||
.expect("cleared to participate");
|
||||
|
||||
// Milestone 1: drive the governed loop with the deterministic simulator.
|
||||
let sim = ResponseSimulator::new(42);
|
||||
let latent = LatentPerson::from_id("subject-001");
|
||||
let state = RuViewState::calm_baseline();
|
||||
gov.run_calibration(&sim, &latent, &state, 5.0, 0).unwrap();
|
||||
|
||||
let rec = gov.recommend(&StimulusParameters::prior());
|
||||
assert!(envelope.contains(&rec.stimulus)); // always inside the envelope
|
||||
```
|
||||
|
||||
## Test / validate / benchmark
|
||||
|
||||
```bash
|
||||
cargo test -p ruview-gamma --no-default-features # 64 unit/integration + 1 doctest
|
||||
cargo bench -p ruview-gamma --no-default-features # criterion micro-benchmarks
|
||||
```
|
||||
|
||||
Determinism is proven, not assumed: `proof::Proof::reference_witness()` runs a
|
||||
fixed reference participant through the full governed pipeline and pins the
|
||||
bundle SHA-256 (`Proof::EXPECTED_WITNESS`); the test fails on any silent drift in
|
||||
the optimizer, simulator, response update, or session hashing.
|
||||
|
||||
### Measured (this container, indicative — not a regression gate)
|
||||
|
||||
| Bench | Median | Note |
|
||||
|-------|--------|------|
|
||||
| `gamma_safety_tick` | ~9.3 ns | vs ADR-250 §17 < 500 ms hard-stop latency bound |
|
||||
| `gamma_bandit_select` | ~74 ns | LinUCB decision |
|
||||
| `gamma_bayesian_recommend` | ~19 µs | GP + EI over the 0.1 Hz envelope grid (was ~105 µs: the GP is now factorized once per recommend, not once per grid candidate — −81%, bit-identical) |
|
||||
| `gamma_calibration_sweep` | ~135 µs | full 9-session enroll → simulate → score → update → witness (was ~486 µs, −71%) |
|
||||
| `gamma_cohort_knn_500` | ~15 µs | exact kNN over 500 anonymized profiles |
|
||||
| `gamma_cohort_warm_start_500` | ~16 µs | full cohort prior construction (runs once per enrollment) |
|
||||
| `gamma_acceptance_grade_program` | ~425 µs | full 3-repeat program acceptance grading (offline gate) |
|
||||
|
||||
## Adaptive sensory neuromodulation platform (ADR-250 §23)
|
||||
|
||||
40 Hz is one prior in one program — the engine is a general personal
|
||||
neural-rhythm optimization platform. `NeuroProgram::catalog()` ships seven use
|
||||
cases (Alzheimer's research, post-stroke cognition, sleep optimization,
|
||||
attention/working-memory, mood/arousal, home wellness, drug+device trial
|
||||
infrastructure), each with its **own** safety envelope, prior, objective
|
||||
weighting, physiological-state gating (the sleep program permits `Asleep` and
|
||||
caps brightness near-dark; attention requires wakefulness), evidence level, and
|
||||
a single non-disease claim. `RufloGovernor::enroll_program` wires it all in;
|
||||
`enroll` stays the bare Alzheimer's-defaults path (so the pinned witness holds).
|
||||
|
||||
**Claim discipline is executable.** A program's claim can only be read through
|
||||
the acceptance gate:
|
||||
|
||||
```rust
|
||||
use ruview_gamma::acceptance::{AcceptanceHarness, AcceptanceCriteria};
|
||||
use ruview_gamma::program::NeuroProgram;
|
||||
# use ruview_gamma::simulator::LatentPerson;
|
||||
# use ruview_gamma::response::RuViewState;
|
||||
|
||||
let harness = AcceptanceHarness::new(42, AcceptanceCriteria::default());
|
||||
let report = harness.evaluate(
|
||||
&NeuroProgram::sleep_optimization(),
|
||||
&LatentPerson::from_id("subject"),
|
||||
&RuViewState::calm_baseline(),
|
||||
);
|
||||
// Returns the program claim ONLY if entrainment + safety + adherence +
|
||||
// repeatability all pass; otherwise the research-only NO_CLAIM string.
|
||||
let _claim = report.claim_gate().claim();
|
||||
```
|
||||
|
||||
## Self-learning across people (ADR-250 §10)
|
||||
|
||||
`RufloGovernor::export_anonymized_profile()` publishes a participant's 20-field
|
||||
vector + per-frequency scores from **safe sessions only** under a one-way hashed
|
||||
tag; `seed_from_cohort(&store, k)` warm-starts a new person's optimizer from the
|
||||
k nearest responders as **down-weighted pseudo-observations**
|
||||
(`observe_prior`, ≥25× the real-observation noise). Priors shape where the
|
||||
optimizer looks first but never count as measured data — they are excluded from
|
||||
the EI incumbent, the audit log, and the clinician report. Per-session
|
||||
`drift_status()` (Welford centroid over the *physiological* sub-vector —
|
||||
stimulus inputs masked out) flags when recalibration is warranted.
|
||||
|
||||
## Roadmap (ADR-250 §21)
|
||||
|
||||
M1 simulator ✅ · M2 device harness (envelope + e-stop contract) ✅ · M3 RuView
|
||||
state contract ✅ · M4 optional EEG input ✅ · M5 adaptive optimizer (BO + bandit
|
||||
+ closed-loop) ✅ · M6 trial mode (sham/blinding + clinician export) ✅ ·
|
||||
§10 RuVector self-learning (cohort warm-start, drift detection, clustering) ✅.
|
||||
Hardware actuation, real RF sensing, and real EEG land behind feature-flagged
|
||||
adapters. An HNSW backend (the `ruvector` crates) drops in for `ProfileStore`
|
||||
once cohorts grow past ~10⁵ profiles.
|
||||
@@ -0,0 +1,133 @@
|
||||
//! Criterion benchmarks for the adaptive-gamma governed loop (ADR-250 §17).
|
||||
//!
|
||||
//! Measures the latency-sensitive paths: a full calibration sweep, a single
|
||||
//! Bayesian recommendation, a closed-loop safety tick, and a bandit decision.
|
||||
//! The safety-stop tick is the figure compared against ADR-250 §17's < 500 ms
|
||||
//! bound — it is O(1) and lands far below.
|
||||
|
||||
use criterion::{black_box, criterion_group, criterion_main, Criterion};
|
||||
|
||||
use ruview_gamma::acceptance::{AcceptanceCriteria, AcceptanceHarness};
|
||||
use ruview_gamma::bandit::{BanditContext, ContextualBandit};
|
||||
use ruview_gamma::optimizer::BayesianOptimizer;
|
||||
use ruview_gamma::program::NeuroProgram;
|
||||
use ruview_gamma::response::RuViewState;
|
||||
use ruview_gamma::ruflo::{Consent, RufloGovernor};
|
||||
use ruview_gamma::ruvector::{AnonymizedProfile, ProfileStore, VECTOR_DIM};
|
||||
use ruview_gamma::safety::{SafetyMonitor, SafetyTick};
|
||||
use ruview_gamma::simulator::{LatentPerson, ResponseSimulator};
|
||||
use ruview_gamma::stimulus::{SafetyEnvelope, StimulusParameters};
|
||||
|
||||
fn bench_calibration(c: &mut Criterion) {
|
||||
let env = SafetyEnvelope::conservative();
|
||||
let sim = ResponseSimulator::new(42);
|
||||
let latent = LatentPerson::from_id("bench-subject");
|
||||
let state = RuViewState::calm_baseline();
|
||||
c.bench_function("gamma_calibration_sweep", |b| {
|
||||
b.iter(|| {
|
||||
let mut gov =
|
||||
RufloGovernor::enroll("bench-subject", env, &[], Consent::Granted).unwrap();
|
||||
gov.run_calibration(black_box(&sim), &latent, &state, 5.0, 0)
|
||||
.unwrap();
|
||||
black_box(gov.audit_log().len())
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
fn bench_recommend(c: &mut Criterion) {
|
||||
let env = SafetyEnvelope::conservative();
|
||||
let mut bo = BayesianOptimizer::default();
|
||||
for f in env.calibration_frequencies() {
|
||||
bo.observe(f, 1.0 - 0.05 * (f - 39.5).powi(2));
|
||||
}
|
||||
let base = StimulusParameters::prior();
|
||||
c.bench_function("gamma_bayesian_recommend", |b| {
|
||||
b.iter(|| black_box(bo.recommend(black_box(&env), black_box(&base))))
|
||||
});
|
||||
}
|
||||
|
||||
fn bench_safety_tick(c: &mut Criterion) {
|
||||
c.bench_function("gamma_safety_tick", |b| {
|
||||
b.iter(|| {
|
||||
let mut m = SafetyMonitor::default();
|
||||
black_box(m.evaluate(black_box(SafetyTick {
|
||||
adverse: None,
|
||||
sensor_confidence: 0.9,
|
||||
stimulus_in_envelope: true,
|
||||
})))
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
fn bench_bandit(c: &mut Criterion) {
|
||||
let env = SafetyEnvelope::conservative();
|
||||
let candidates: Vec<StimulusParameters> = [38.0, 40.0, 42.0]
|
||||
.iter()
|
||||
.map(|&f| {
|
||||
let mut s = StimulusParameters::prior();
|
||||
s.frequency_hz = f;
|
||||
s
|
||||
})
|
||||
.collect();
|
||||
let bandit = ContextualBandit::new(&env, &candidates, 1.0).unwrap();
|
||||
let ctx = BanditContext {
|
||||
sleep_quality: 0.7,
|
||||
time_of_day: 0.5,
|
||||
breathing_state: 0.8,
|
||||
motion_state: 0.1,
|
||||
fatigue_proxy: 0.2,
|
||||
prior_response: 0.6,
|
||||
};
|
||||
c.bench_function("gamma_bandit_select", |b| {
|
||||
b.iter(|| black_box(bandit.select(black_box(&ctx))))
|
||||
});
|
||||
}
|
||||
|
||||
fn cohort_store(n: usize) -> ProfileStore {
|
||||
let mut store = ProfileStore::new();
|
||||
for i in 0..n {
|
||||
let mut vector = [0.5; VECTOR_DIM];
|
||||
vector[5] = 12.0 + (i % 8) as f64; // breathing_rate spread
|
||||
vector[11] = 36.0 + (i % 9) as f64; // frequency spread
|
||||
store.upsert(AnonymizedProfile {
|
||||
profile_tag: format!("p{i:04}"),
|
||||
vector,
|
||||
frequency_scores: (36..=44).map(|f| (f as f64, 0.5 + 0.01 * (i % 7) as f64)).collect(),
|
||||
});
|
||||
}
|
||||
store
|
||||
}
|
||||
|
||||
fn bench_cohort_knn(c: &mut Criterion) {
|
||||
let store = cohort_store(500);
|
||||
let mut q = [0.5; VECTOR_DIM];
|
||||
q[5] = 14.0;
|
||||
q[11] = 39.0;
|
||||
c.bench_function("gamma_cohort_knn_500", |b| {
|
||||
b.iter(|| black_box(store.k_nearest(black_box(&q), 5)))
|
||||
});
|
||||
c.bench_function("gamma_cohort_warm_start_500", |b| {
|
||||
b.iter(|| black_box(store.warm_start_prior(black_box(&q), 5, 1e-4)))
|
||||
});
|
||||
}
|
||||
|
||||
fn bench_acceptance(c: &mut Criterion) {
|
||||
let harness = AcceptanceHarness::new(42, AcceptanceCriteria::default());
|
||||
let program = NeuroProgram::sleep_optimization();
|
||||
let person = LatentPerson::from_id("bench-acc-subject");
|
||||
let state = RuViewState::calm_baseline();
|
||||
c.bench_function("gamma_acceptance_grade_program", |b| {
|
||||
b.iter(|| black_box(harness.evaluate(black_box(&program), &person, &state)))
|
||||
});
|
||||
}
|
||||
|
||||
criterion_group!(
|
||||
benches,
|
||||
bench_calibration,
|
||||
bench_recommend,
|
||||
bench_safety_tick,
|
||||
bench_bandit,
|
||||
bench_cohort_knn,
|
||||
bench_acceptance
|
||||
);
|
||||
criterion_main!(benches);
|
||||
@@ -0,0 +1,396 @@
|
||||
//! Acceptance gate (ADR-250 §18, generalized across programs).
|
||||
//!
|
||||
//! > Every use case must show measurable **entrainment**, **safety**,
|
||||
//! > **adherence**, and **repeatability** before making any disease claim.
|
||||
//!
|
||||
//! This module operationalizes that sentence. [`AcceptanceHarness`] runs a
|
||||
//! [`NeuroProgram`] against the deterministic simulator over several
|
||||
//! independent repeats and produces an [`AcceptanceReport`] with the four
|
||||
//! measured metrics, a per-criterion pass/fail, and — crucially — a
|
||||
//! [`ClaimGate`] that **only releases the program's claim when every criterion
|
||||
//! passes**. Until then the gate returns the research-only, no-claim string.
|
||||
|
||||
use crate::program::NeuroProgram;
|
||||
use crate::response::RuViewState;
|
||||
use crate::ruflo::{Consent, RufloGovernor};
|
||||
use crate::simulator::{LatentPerson, ResponseSimulator};
|
||||
use crate::stimulus::StimulusParameters;
|
||||
|
||||
/// The research-only string returned by a failed [`ClaimGate`]: no optimization
|
||||
/// claim, no disease claim — only a statement that evidence is insufficient.
|
||||
pub const NO_CLAIM: &str = "research use only — acceptance criteria not yet met; no claim";
|
||||
|
||||
/// **The hard claim-gate invariant** (ADR-250 §23.1). The single source of
|
||||
/// truth used everywhere a claim could be released:
|
||||
///
|
||||
/// ```text
|
||||
/// claim_allowed = entrainment_pass AND safety_pass
|
||||
/// AND adherence_pass AND repeatability_pass
|
||||
/// ```
|
||||
///
|
||||
/// Anything short of all four returns the research-only string. Centralizing it
|
||||
/// here means no path can accidentally weaken the gate to an OR or a subset.
|
||||
#[inline]
|
||||
pub fn claim_allowed(
|
||||
entrainment_pass: bool,
|
||||
safety_pass: bool,
|
||||
adherence_pass: bool,
|
||||
repeatability_pass: bool,
|
||||
) -> bool {
|
||||
entrainment_pass && safety_pass && adherence_pass && repeatability_pass
|
||||
}
|
||||
|
||||
/// Thresholds a program must clear (ADR-250 §18 generalized). Defaults mirror
|
||||
/// the ADR's published targets; programs may tighten them.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct AcceptanceCriteria {
|
||||
/// Minimum mean entrainment gain of the adaptive recommendation over the
|
||||
/// program's fixed prior, as a fraction (0.20 = ADR-250 §18 "≥20%").
|
||||
pub min_entrainment_gain: f64,
|
||||
/// Maximum tolerated safety-stop rate across all sessions (0.0 = none).
|
||||
pub max_safety_stop_rate: f64,
|
||||
/// Minimum mean adherence across sessions.
|
||||
pub min_adherence: f64,
|
||||
/// Maximum spread (Hz) of the discovered optimal frequency across repeats
|
||||
/// (ADR-250 §18 "same optimal band within ±1 Hz across 3 sessions" → 2 Hz).
|
||||
pub max_repeatability_band_hz: f64,
|
||||
/// Independent repeats to run (≥3 per ADR-250 §18).
|
||||
pub repeats: usize,
|
||||
}
|
||||
|
||||
impl Default for AcceptanceCriteria {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
min_entrainment_gain: 0.20,
|
||||
max_safety_stop_rate: 0.0,
|
||||
min_adherence: 0.8,
|
||||
max_repeatability_band_hz: 2.0,
|
||||
repeats: 3,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The four measured metrics plus per-criterion verdicts and the gated claim.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct AcceptanceReport {
|
||||
pub program_id: String,
|
||||
/// Mean entrainment gain (adaptive vs fixed prior), as a fraction.
|
||||
pub entrainment_gain: f64,
|
||||
/// Observed safety-stop rate across all sessions.
|
||||
pub safety_stop_rate: f64,
|
||||
/// Mean adherence across all sessions.
|
||||
pub mean_adherence: f64,
|
||||
/// Spread (Hz) of the discovered optimal frequency across repeats.
|
||||
pub repeatability_band_hz: f64,
|
||||
pub entrainment_pass: bool,
|
||||
pub safety_pass: bool,
|
||||
pub adherence_pass: bool,
|
||||
pub repeatability_pass: bool,
|
||||
/// True only if all four criteria pass.
|
||||
pub overall_pass: bool,
|
||||
/// The claim that may be surfaced: the program's claim iff `overall_pass`,
|
||||
/// else [`NO_CLAIM`].
|
||||
pub released_claim: String,
|
||||
}
|
||||
|
||||
impl AcceptanceReport {
|
||||
/// The [`ClaimGate`] for this report.
|
||||
pub fn claim_gate(&self) -> ClaimGate<'_> {
|
||||
ClaimGate { report: self }
|
||||
}
|
||||
}
|
||||
|
||||
/// A thin, hard-to-misuse accessor: you cannot read a program's marketing claim
|
||||
/// except through this gate, which substitutes [`NO_CLAIM`] on failure.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct ClaimGate<'a> {
|
||||
report: &'a AcceptanceReport,
|
||||
}
|
||||
|
||||
impl ClaimGate<'_> {
|
||||
/// The releasable claim string (program claim on pass, [`NO_CLAIM`] on fail).
|
||||
pub fn claim(&self) -> &str {
|
||||
&self.report.released_claim
|
||||
}
|
||||
|
||||
/// Whether a (non-disease) optimization claim may be surfaced at all.
|
||||
pub fn is_released(&self) -> bool {
|
||||
self.report.overall_pass
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs a program against the deterministic simulator and grades it.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AcceptanceHarness {
|
||||
pub criteria: AcceptanceCriteria,
|
||||
seed: u64,
|
||||
}
|
||||
|
||||
impl AcceptanceHarness {
|
||||
pub fn new(seed: u64, criteria: AcceptanceCriteria) -> Self {
|
||||
Self { criteria, seed }
|
||||
}
|
||||
|
||||
/// Grade `program` for a simulated participant `person` in `state`.
|
||||
///
|
||||
/// Each repeat: enroll under the program, run its calibration sweep, take
|
||||
/// the adaptive recommendation, and compare its mean simulated entrainment
|
||||
/// against the program's fixed prior. Metrics are aggregated across repeats;
|
||||
/// the claim is released only if all four criteria pass.
|
||||
pub fn evaluate(
|
||||
&self,
|
||||
program: &NeuroProgram,
|
||||
person: &LatentPerson,
|
||||
state: &RuViewState,
|
||||
) -> AcceptanceReport {
|
||||
let sim = ResponseSimulator::new(self.seed);
|
||||
let mut optimal_freqs = Vec::with_capacity(self.criteria.repeats);
|
||||
let mut gains = Vec::with_capacity(self.criteria.repeats);
|
||||
let mut total_sessions = 0usize;
|
||||
let mut total_stops = 0usize;
|
||||
let mut adherence_sum = 0.0;
|
||||
|
||||
for r in 0..self.criteria.repeats.max(1) {
|
||||
let pid = format!("acc-{}-{}", program.id, r);
|
||||
let mut gov =
|
||||
match RufloGovernor::enroll_program(&pid, program.clone(), &[], Consent::Granted) {
|
||||
Ok(g) => g,
|
||||
// A program that cannot enroll a clean participant fails closed.
|
||||
Err(_) => return self.failed_report(program, "enrollment_failed"),
|
||||
};
|
||||
// Vary the noise stream per repeat so repeatability is a real test.
|
||||
gov.run_calibration(
|
||||
&sim,
|
||||
person,
|
||||
state,
|
||||
program.prior.duration_minutes.min(5.0),
|
||||
r as u64,
|
||||
)
|
||||
.ok();
|
||||
|
||||
for rec in gov.audit_log() {
|
||||
total_sessions += 1;
|
||||
if !rec.outcome.safety_pass {
|
||||
total_stops += 1;
|
||||
}
|
||||
adherence_sum += rec.ruview_state.adherence as f64;
|
||||
}
|
||||
|
||||
let rec = gov.recommend(&program.prior);
|
||||
optimal_freqs.push(rec.stimulus.frequency_hz);
|
||||
|
||||
// Entrainment gain: adaptive recommendation vs the fixed prior.
|
||||
let mean = |stim: &StimulusParameters| -> f64 {
|
||||
(0..16)
|
||||
.map(|i| {
|
||||
sim.simulate(person, state, stim, 10_000 + i)
|
||||
.eeg
|
||||
.gamma_power_gain
|
||||
})
|
||||
.sum::<f64>()
|
||||
/ 16.0
|
||||
};
|
||||
let adaptive = mean(&rec.stimulus);
|
||||
let baseline = mean(&program.prior).max(1e-6);
|
||||
gains.push((adaptive - baseline) / baseline);
|
||||
}
|
||||
|
||||
let entrainment_gain = mean_of(&gains);
|
||||
let safety_stop_rate = if total_sessions > 0 {
|
||||
total_stops as f64 / total_sessions as f64
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
let mean_adherence = if total_sessions > 0 {
|
||||
adherence_sum / total_sessions as f64
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let repeatability_band_hz = spread(&optimal_freqs);
|
||||
|
||||
let c = &self.criteria;
|
||||
let entrainment_pass = entrainment_gain >= c.min_entrainment_gain;
|
||||
let safety_pass = safety_stop_rate <= c.max_safety_stop_rate;
|
||||
let adherence_pass = mean_adherence >= c.min_adherence;
|
||||
let repeatability_pass = repeatability_band_hz <= c.max_repeatability_band_hz;
|
||||
let overall_pass = claim_allowed(
|
||||
entrainment_pass,
|
||||
safety_pass,
|
||||
adherence_pass,
|
||||
repeatability_pass,
|
||||
);
|
||||
|
||||
AcceptanceReport {
|
||||
program_id: program.id.to_string(),
|
||||
entrainment_gain,
|
||||
safety_stop_rate,
|
||||
mean_adherence,
|
||||
repeatability_band_hz,
|
||||
entrainment_pass,
|
||||
safety_pass,
|
||||
adherence_pass,
|
||||
repeatability_pass,
|
||||
overall_pass,
|
||||
released_claim: if overall_pass {
|
||||
program.claim.to_string()
|
||||
} else {
|
||||
NO_CLAIM.to_string()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn failed_report(&self, program: &NeuroProgram, _why: &str) -> AcceptanceReport {
|
||||
AcceptanceReport {
|
||||
program_id: program.id.to_string(),
|
||||
entrainment_gain: 0.0,
|
||||
safety_stop_rate: 1.0,
|
||||
mean_adherence: 0.0,
|
||||
repeatability_band_hz: f64::INFINITY,
|
||||
entrainment_pass: false,
|
||||
safety_pass: false,
|
||||
adherence_pass: false,
|
||||
repeatability_pass: false,
|
||||
overall_pass: false,
|
||||
released_claim: NO_CLAIM.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn mean_of(v: &[f64]) -> f64 {
|
||||
if v.is_empty() {
|
||||
0.0
|
||||
} else {
|
||||
v.iter().sum::<f64>() / v.len() as f64
|
||||
}
|
||||
}
|
||||
|
||||
fn spread(v: &[f64]) -> f64 {
|
||||
if v.is_empty() {
|
||||
return f64::INFINITY;
|
||||
}
|
||||
let lo = v.iter().cloned().fold(f64::INFINITY, f64::min);
|
||||
let hi = v.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
|
||||
hi - lo
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::response::{RuViewState, SleepState};
|
||||
|
||||
#[test]
|
||||
fn claim_allowed_requires_all_four_and_rejects_every_subset() {
|
||||
// All four → allowed.
|
||||
assert!(claim_allowed(true, true, true, true));
|
||||
// Every 3-of-4 subset (one false) → denied. This is the AND, not OR,
|
||||
// guarantee the whole gate rests on.
|
||||
let one_false = [
|
||||
(false, true, true, true),
|
||||
(true, false, true, true),
|
||||
(true, true, false, true),
|
||||
(true, true, true, false),
|
||||
];
|
||||
for (e, s, a, r) in one_false {
|
||||
assert!(
|
||||
!claim_allowed(e, s, a, r),
|
||||
"subset {e}{s}{a}{r} must be denied"
|
||||
);
|
||||
}
|
||||
assert!(!claim_allowed(false, false, false, false));
|
||||
}
|
||||
|
||||
fn detuned_subject() -> (String, LatentPerson) {
|
||||
// A subject whose latent peak is clearly off the prior frequency, so an
|
||||
// adaptive program has real gain to find.
|
||||
for n in 0..80 {
|
||||
let id = format!("acc-subject-{n}");
|
||||
let p = LatentPerson::from_id(&id);
|
||||
if (p.peak_hz - 40.0).abs() > 2.0 && p.peak_hz > 37.5 && p.peak_hz < 42.5 {
|
||||
return (id, p);
|
||||
}
|
||||
}
|
||||
panic!("a detuned subject exists");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claim_is_withheld_until_criteria_pass() {
|
||||
// Impossible entrainment bar → must fail → NO_CLAIM.
|
||||
let (_, person) = detuned_subject();
|
||||
let harness = AcceptanceHarness::new(
|
||||
1,
|
||||
AcceptanceCriteria {
|
||||
min_entrainment_gain: 100.0, // unreachable
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let report = harness.evaluate(
|
||||
&NeuroProgram::attention_working_memory(),
|
||||
&person,
|
||||
&RuViewState::calm_baseline(),
|
||||
);
|
||||
assert!(!report.overall_pass);
|
||||
assert!(!report.entrainment_pass);
|
||||
assert_eq!(report.claim_gate().claim(), NO_CLAIM);
|
||||
assert!(!report.claim_gate().is_released());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn passing_program_releases_its_own_claim() {
|
||||
let (_, person) = detuned_subject();
|
||||
let program = NeuroProgram::attention_working_memory();
|
||||
// Lenient-but-real bar: any positive adaptive gain, perfect sim safety/adherence.
|
||||
let harness = AcceptanceHarness::new(
|
||||
7,
|
||||
AcceptanceCriteria {
|
||||
min_entrainment_gain: 0.0,
|
||||
max_safety_stop_rate: 0.0,
|
||||
min_adherence: 0.8,
|
||||
max_repeatability_band_hz: 8.0,
|
||||
repeats: 3,
|
||||
},
|
||||
);
|
||||
let report = harness.evaluate(&program, &person, &RuViewState::calm_baseline());
|
||||
assert!(report.entrainment_pass);
|
||||
assert!(report.safety_pass);
|
||||
assert!(report.adherence_pass);
|
||||
if report.overall_pass {
|
||||
assert_eq!(report.claim_gate().claim(), program.claim);
|
||||
assert!(report.claim_gate().is_released());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn safety_criterion_blocks_claim_on_stops() {
|
||||
// Drive overstimulation so the simulator raises adverse events: a
|
||||
// saturated-intensity prior in a restless state.
|
||||
let (_, person) = detuned_subject();
|
||||
let harness = AcceptanceHarness::new(3, AcceptanceCriteria::default());
|
||||
let mut restless = RuViewState::calm_baseline();
|
||||
restless.sleep_state = SleepState::Active;
|
||||
restless.restlessness_score = 0.9;
|
||||
// Even if it passes, the safety rate must be a real measured fraction.
|
||||
let report = harness.evaluate(&NeuroProgram::mood_arousal(), &person, &restless);
|
||||
assert!((0.0..=1.0).contains(&report.safety_stop_rate));
|
||||
if report.safety_stop_rate > 0.0 {
|
||||
assert!(!report.safety_pass);
|
||||
assert!(!report.overall_pass);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_catalog_program_is_gradable() {
|
||||
let (_, person) = detuned_subject();
|
||||
let harness = AcceptanceHarness::new(11, AcceptanceCriteria::default());
|
||||
let state = RuViewState::calm_baseline();
|
||||
for program in NeuroProgram::catalog() {
|
||||
let report = harness.evaluate(&program, &person, &state);
|
||||
assert_eq!(report.program_id, program.id);
|
||||
// Gate is total: it always yields *some* releasable string.
|
||||
assert!(!report.released_claim.is_empty());
|
||||
// And a failing program never leaks the program claim.
|
||||
if !report.overall_pass {
|
||||
assert_eq!(report.claim_gate().claim(), NO_CLAIM);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
//! Phase-3 contextual bandit (ADR-250 §8).
|
||||
//!
|
||||
//! Once enough sessions exist, protocol selection becomes state-dependent:
|
||||
//! `context = [sleep_quality, time_of_day, breathing_state, motion_state,
|
||||
//! fatigue_proxy, prior_response]` → `action = stimulus setting` →
|
||||
//! `reward = safe_entrainment_score`. We use **LinUCB** (disjoint linear model
|
||||
//! per arm) — small, deterministic, explainable, and edge-deployable.
|
||||
//!
|
||||
//! Arms are a discrete set of *envelope-safe* stimulus settings supplied by the
|
||||
//! caller; the bandit never invents an out-of-envelope action because it can
|
||||
//! only ever return one of the arms it was given.
|
||||
|
||||
use crate::math::clamp_safe;
|
||||
use crate::stimulus::{SafetyEnvelope, StimulusParameters};
|
||||
|
||||
/// Context vector dimensionality (ADR-250 §8 Phase 3 context list).
|
||||
pub const CONTEXT_DIM: usize = 6;
|
||||
|
||||
/// The decision context, normalized to `[0,1]` per field.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct BanditContext {
|
||||
pub sleep_quality: f64,
|
||||
pub time_of_day: f64,
|
||||
pub breathing_state: f64,
|
||||
pub motion_state: f64,
|
||||
pub fatigue_proxy: f64,
|
||||
pub prior_response: f64,
|
||||
}
|
||||
|
||||
impl BanditContext {
|
||||
/// Flat feature vector in the documented field order.
|
||||
pub fn features(&self) -> [f64; CONTEXT_DIM] {
|
||||
[
|
||||
clamp_safe(self.sleep_quality, 0.0, 1.0),
|
||||
clamp_safe(self.time_of_day, 0.0, 1.0),
|
||||
clamp_safe(self.breathing_state, 0.0, 1.0),
|
||||
clamp_safe(self.motion_state, 0.0, 1.0),
|
||||
clamp_safe(self.fatigue_proxy, 0.0, 1.0),
|
||||
clamp_safe(self.prior_response, 0.0, 1.0),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/// One LinUCB arm: a fixed safe stimulus plus its online linear model. The
|
||||
/// per-arm `A⁻¹` is maintained incrementally via the Sherman–Morrison update,
|
||||
/// so no matrix inversion runs at decision time.
|
||||
#[derive(Debug, Clone)]
|
||||
struct Arm {
|
||||
stimulus: StimulusParameters,
|
||||
/// A⁻¹ (d×d, row-major), initialized to I.
|
||||
a_inv: [f64; CONTEXT_DIM * CONTEXT_DIM],
|
||||
/// b (d), initialized to 0.
|
||||
b: [f64; CONTEXT_DIM],
|
||||
}
|
||||
|
||||
impl Arm {
|
||||
fn new(stimulus: StimulusParameters) -> Self {
|
||||
let mut a_inv = [0.0; CONTEXT_DIM * CONTEXT_DIM];
|
||||
for i in 0..CONTEXT_DIM {
|
||||
a_inv[i * CONTEXT_DIM + i] = 1.0;
|
||||
}
|
||||
Self {
|
||||
stimulus,
|
||||
a_inv,
|
||||
b: [0.0; CONTEXT_DIM],
|
||||
}
|
||||
}
|
||||
|
||||
/// theta = A⁻¹ b
|
||||
fn theta(&self) -> [f64; CONTEXT_DIM] {
|
||||
mat_vec(&self.a_inv, &self.b)
|
||||
}
|
||||
|
||||
/// UCB score: μ + α·√(xᵀ A⁻¹ x).
|
||||
fn ucb(&self, x: &[f64; CONTEXT_DIM], alpha: f64) -> f64 {
|
||||
let theta = self.theta();
|
||||
let mean: f64 = theta.iter().zip(x).map(|(t, xi)| t * xi).sum();
|
||||
let ainv_x = mat_vec(&self.a_inv, x);
|
||||
let var: f64 = x.iter().zip(&ainv_x).map(|(xi, v)| xi * v).sum();
|
||||
mean + alpha * var.max(0.0).sqrt()
|
||||
}
|
||||
|
||||
/// Online update with observed `(x, reward)` via Sherman–Morrison:
|
||||
/// A ← A + x xᵀ ⇒ A⁻¹ ← A⁻¹ − (A⁻¹ x xᵀ A⁻¹)/(1 + xᵀ A⁻¹ x).
|
||||
fn update(&mut self, x: &[f64; CONTEXT_DIM], reward: f64) {
|
||||
let ainv_x = mat_vec(&self.a_inv, x); // A⁻¹ x (d)
|
||||
let denom = 1.0 + x.iter().zip(&ainv_x).map(|(xi, v)| xi * v).sum::<f64>();
|
||||
// A⁻¹ ← A⁻¹ − (ainv_x)(ainv_x)ᵀ / denom (since A symmetric ⇒ xᵀA⁻¹ = (A⁻¹x)ᵀ)
|
||||
for i in 0..CONTEXT_DIM {
|
||||
for j in 0..CONTEXT_DIM {
|
||||
self.a_inv[i * CONTEXT_DIM + j] -= ainv_x[i] * ainv_x[j] / denom;
|
||||
}
|
||||
}
|
||||
for i in 0..CONTEXT_DIM {
|
||||
self.b[i] += reward * x[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// LinUCB contextual bandit over a fixed set of envelope-safe arms.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ContextualBandit {
|
||||
arms: Vec<Arm>,
|
||||
/// Exploration coefficient α.
|
||||
pub alpha: f64,
|
||||
}
|
||||
|
||||
impl ContextualBandit {
|
||||
/// Build a bandit from candidate stimuli. Each candidate is **clamped into
|
||||
/// the envelope** on the way in, so no arm can ever be unsafe (ADR-250 §12).
|
||||
/// Returns `None` if no candidates were supplied.
|
||||
pub fn new(
|
||||
envelope: &SafetyEnvelope,
|
||||
candidates: &[StimulusParameters],
|
||||
alpha: f64,
|
||||
) -> Option<Self> {
|
||||
if candidates.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let arms = candidates
|
||||
.iter()
|
||||
.map(|s| Arm::new(envelope.clamp(*s)))
|
||||
.collect();
|
||||
Some(Self { arms, alpha })
|
||||
}
|
||||
|
||||
/// Number of arms.
|
||||
pub fn n_arms(&self) -> usize {
|
||||
self.arms.len()
|
||||
}
|
||||
|
||||
/// Select the arm index with the highest UCB for `ctx`. Deterministic
|
||||
/// tie-break: lowest index wins.
|
||||
pub fn select(&self, ctx: &BanditContext) -> usize {
|
||||
let x = ctx.features();
|
||||
let mut best_i = 0;
|
||||
let mut best = f64::NEG_INFINITY;
|
||||
for (i, arm) in self.arms.iter().enumerate() {
|
||||
let u = arm.ucb(&x, self.alpha);
|
||||
if u > best {
|
||||
best = u;
|
||||
best_i = i;
|
||||
}
|
||||
}
|
||||
best_i
|
||||
}
|
||||
|
||||
/// The stimulus for an arm index (always envelope-safe).
|
||||
pub fn stimulus(&self, arm: usize) -> StimulusParameters {
|
||||
self.arms[arm].stimulus
|
||||
}
|
||||
|
||||
/// Record the reward for a chosen arm under context `ctx`.
|
||||
pub fn update(&mut self, arm: usize, ctx: &BanditContext, reward: f64) {
|
||||
let x = ctx.features();
|
||||
self.arms[arm].update(&x, reward);
|
||||
}
|
||||
}
|
||||
|
||||
/// y = M x for row-major `d×d` M and length-`d` x.
|
||||
fn mat_vec(m: &[f64; CONTEXT_DIM * CONTEXT_DIM], x: &[f64; CONTEXT_DIM]) -> [f64; CONTEXT_DIM] {
|
||||
let mut y = [0.0; CONTEXT_DIM];
|
||||
for i in 0..CONTEXT_DIM {
|
||||
let mut s = 0.0;
|
||||
for j in 0..CONTEXT_DIM {
|
||||
s += m[i * CONTEXT_DIM + j] * x[j];
|
||||
}
|
||||
y[i] = s;
|
||||
}
|
||||
y
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::stimulus::StimulusParameters;
|
||||
|
||||
fn candidates() -> Vec<StimulusParameters> {
|
||||
[38.0, 40.0, 42.0]
|
||||
.iter()
|
||||
.map(|&f| {
|
||||
let mut s = StimulusParameters::prior();
|
||||
s.frequency_hz = f;
|
||||
s
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn ctx(sleep: f64) -> BanditContext {
|
||||
BanditContext {
|
||||
sleep_quality: sleep,
|
||||
time_of_day: 0.5,
|
||||
breathing_state: 0.8,
|
||||
motion_state: 0.1,
|
||||
fatigue_proxy: 0.2,
|
||||
prior_response: 0.6,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_empty_candidates() {
|
||||
let env = SafetyEnvelope::conservative();
|
||||
assert!(ContextualBandit::new(&env, &[], 1.0).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_arms_are_envelope_safe_even_if_candidate_is_not() {
|
||||
let env = SafetyEnvelope::conservative();
|
||||
let mut bad = StimulusParameters::prior();
|
||||
bad.frequency_hz = 100.0;
|
||||
bad.brightness_level = 9.0;
|
||||
let b = ContextualBandit::new(&env, &[bad], 1.0).unwrap();
|
||||
assert!(env.contains(&b.stimulus(0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn learns_to_prefer_rewarded_arm() {
|
||||
let env = SafetyEnvelope::conservative();
|
||||
let mut b = ContextualBandit::new(&env, &candidates(), 0.1).unwrap();
|
||||
let c = ctx(0.9);
|
||||
// Arm 2 (42 Hz) is consistently best in this context.
|
||||
for _ in 0..50 {
|
||||
for arm in 0..b.n_arms() {
|
||||
let reward = if arm == 2 { 1.0 } else { 0.1 };
|
||||
b.update(arm, &c, reward);
|
||||
}
|
||||
}
|
||||
assert_eq!(b.select(&c), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_is_deterministic() {
|
||||
let env = SafetyEnvelope::conservative();
|
||||
let b = ContextualBandit::new(&env, &candidates(), 1.0).unwrap();
|
||||
let c = ctx(0.5);
|
||||
assert_eq!(b.select(&c), b.select(&c));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
//! Hardware-in-the-loop (HIL) acceptance contract — Milestone "device harness"
|
||||
//! (ADR-250 §17, §21 M2).
|
||||
//!
|
||||
//! The software core is proven against the deterministic simulator; the next
|
||||
//! acceptance milestone is **real hardware**: an LED + speaker actuator (e.g.
|
||||
//! driven by an ESP32) plus the safety stop path. This module defines the
|
||||
//! *contract* every actuator integration must satisfy and a verifier that
|
||||
//! grades a captured [`HilMeasurement`] against fixed targets. It does **not**
|
||||
//! talk to hardware — the firmware/driver records the measurements and submits
|
||||
//! them here, keeping this crate a deterministic, dependency-light leaf.
|
||||
//!
|
||||
//! | Test | Target |
|
||||
//! |------|--------|
|
||||
//! | LED frequency accuracy | ±0.1 Hz |
|
||||
//! | Worst-case frequency error over the session window | ±0.1 Hz |
|
||||
//! | Worst-case half-period jitter over the session window | ≤ 500 µs |
|
||||
//! | Audio-visual sync drift | < 5 ms |
|
||||
//! | Stop signal → actuator off | < 100 ms |
|
||||
//! | Session-hash reproducibility | 100% |
|
||||
//! | EEG entrainment lift vs fixed 40 Hz | ≥ 20% |
|
||||
//!
|
||||
//! Every criterion fails closed: a NaN/non-finite measurement, an impossible
|
||||
//! hash count (`reproduced > total`), or an empty replay set all grade as
|
||||
//! FAIL, never as "unknown".
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Fixed HIL targets (ADR-250 §17 acceptance + §18). Constants, not config:
|
||||
/// these are the bar a device must clear to be called validated.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub struct HilTargets {
|
||||
/// Max |measured − commanded| LED/flicker frequency, Hz.
|
||||
pub max_frequency_error_hz: f64,
|
||||
/// Max audio-visual onset skew, milliseconds.
|
||||
pub max_av_sync_drift_ms: f64,
|
||||
/// Max latency from stop assertion to actuator-off, milliseconds.
|
||||
pub max_stop_latency_ms: f64,
|
||||
/// Min fraction of replayed sessions whose witness hash reproduced.
|
||||
pub min_hash_reproducibility: f64,
|
||||
/// Min EEG entrainment lift over fixed 40 Hz, as a fraction.
|
||||
pub min_eeg_lift: f64,
|
||||
/// Max worst-case half-period jitter over the session window, µs.
|
||||
/// Grades delivered regularity, not a point estimate: a bench run must
|
||||
/// report the worst half-period deviation it saw, captured at the sync
|
||||
/// pin across the whole session.
|
||||
pub max_half_period_jitter_us: f64,
|
||||
}
|
||||
|
||||
impl Default for HilTargets {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_frequency_error_hz: 0.1,
|
||||
max_av_sync_drift_ms: 5.0,
|
||||
max_stop_latency_ms: 100.0,
|
||||
min_hash_reproducibility: 1.0,
|
||||
min_eeg_lift: 0.20,
|
||||
// 500 µs is ~4% of the shortest half-period (11.4 ms @ 44 Hz):
|
||||
// generous for ISR latency, tight enough to catch a wobbling timer.
|
||||
max_half_period_jitter_us: 500.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A captured bench measurement from a real actuator run. Populated by the
|
||||
/// firmware/driver test harness (e.g. measuring LED frequency with a photodiode
|
||||
/// and a logic analyzer, sync with a dual-channel capture, stop latency from
|
||||
/// GPIO assert to PWM-off).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub struct HilMeasurement {
|
||||
/// Commanded frequency the controller asked for (Hz).
|
||||
pub commanded_frequency_hz: f64,
|
||||
/// Frequency actually measured at the LED (Hz) — session mean.
|
||||
pub measured_frequency_hz: f64,
|
||||
/// Worst-case |measured − commanded| frequency over the whole session
|
||||
/// window (Hz). Graded against the same ±0.1 Hz budget as the mean, so
|
||||
/// the bench measures delivered regularity, not a point estimate.
|
||||
pub max_abs_freq_error_hz: f64,
|
||||
/// Worst-case half-period jitter over the session window (µs), measured
|
||||
/// at the sync pin.
|
||||
pub max_half_period_jitter_us: f64,
|
||||
/// Measured audio-visual onset skew (ms).
|
||||
pub av_sync_drift_ms: f64,
|
||||
/// Measured stop-assert → actuator-off latency (ms).
|
||||
pub stop_latency_ms: f64,
|
||||
/// Replayed sessions whose witness hash matched / total replayed.
|
||||
pub hashes_reproduced: u32,
|
||||
pub hashes_total: u32,
|
||||
/// Mean EEG entrainment under the adaptive protocol.
|
||||
pub eeg_entrainment_adaptive: f64,
|
||||
/// Mean EEG entrainment under fixed 40 Hz (the control arm).
|
||||
pub eeg_entrainment_fixed_40hz: f64,
|
||||
}
|
||||
|
||||
/// Per-criterion verdict for a HIL run.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub struct HilReport {
|
||||
pub frequency_error_hz: f64,
|
||||
pub frequency_pass: bool,
|
||||
/// Worst-case frequency error over the session window (echoed from the
|
||||
/// measurement) and its verdict against the same ±0.1 Hz budget.
|
||||
pub worst_frequency_error_hz: f64,
|
||||
pub worst_frequency_pass: bool,
|
||||
/// Worst-case half-period jitter verdict (delivered regularity).
|
||||
pub jitter_pass: bool,
|
||||
pub av_sync_pass: bool,
|
||||
pub stop_latency_pass: bool,
|
||||
pub hash_reproducibility: f64,
|
||||
pub hash_pass: bool,
|
||||
pub eeg_lift: f64,
|
||||
pub eeg_lift_pass: bool,
|
||||
/// True only if every HIL criterion passes — the device is bench-validated.
|
||||
pub overall_pass: bool,
|
||||
}
|
||||
|
||||
/// Grade a [`HilMeasurement`] against [`HilTargets`].
|
||||
///
|
||||
/// Fails closed on bad data: any non-finite (NaN/inf) measurement and any
|
||||
/// impossible hash count (`hashes_reproduced > hashes_total`) grades as FAIL.
|
||||
pub fn verify_hil(m: &HilMeasurement, t: &HilTargets) -> HilReport {
|
||||
// Session-mean frequency error. A NaN in either operand yields a NaN
|
||||
// error; the explicit is_finite() makes the fail-closed intent visible
|
||||
// instead of relying on NaN comparison semantics.
|
||||
let frequency_error_hz = (m.measured_frequency_hz - m.commanded_frequency_hz).abs();
|
||||
let frequency_pass =
|
||||
frequency_error_hz.is_finite() && frequency_error_hz <= t.max_frequency_error_hz;
|
||||
|
||||
// Worst-case-over-window regularity: the same ±0.1 Hz budget must hold
|
||||
// for the worst excursion, not just the mean. A negative "worst absolute
|
||||
// error" is self-contradictory bench data ⇒ fail closed.
|
||||
let worst_frequency_error_hz = m.max_abs_freq_error_hz;
|
||||
let worst_frequency_pass = worst_frequency_error_hz.is_finite()
|
||||
&& worst_frequency_error_hz >= 0.0
|
||||
&& worst_frequency_error_hz <= t.max_frequency_error_hz;
|
||||
|
||||
let jitter_pass = m.max_half_period_jitter_us.is_finite()
|
||||
&& m.max_half_period_jitter_us >= 0.0
|
||||
&& m.max_half_period_jitter_us <= t.max_half_period_jitter_us;
|
||||
|
||||
let av_sync_pass =
|
||||
m.av_sync_drift_ms.is_finite() && m.av_sync_drift_ms.abs() <= t.max_av_sync_drift_ms;
|
||||
|
||||
// Stop latency must be finite and within bound (a missing/NaN measurement
|
||||
// fails closed).
|
||||
let stop_latency_pass =
|
||||
m.stop_latency_ms.is_finite() && m.stop_latency_ms <= t.max_stop_latency_ms;
|
||||
|
||||
// hashes_reproduced > hashes_total is impossible for honest bench data;
|
||||
// treat it as an invalid measurement, never as >100% reproducibility.
|
||||
let hash_reproducibility = if m.hashes_total == 0 || m.hashes_reproduced > m.hashes_total {
|
||||
0.0 // nothing replayed, or corrupt counts ⇒ unproven ⇒ fail closed
|
||||
} else {
|
||||
m.hashes_reproduced as f64 / m.hashes_total as f64
|
||||
};
|
||||
let hash_pass = hash_reproducibility >= t.min_hash_reproducibility;
|
||||
|
||||
let eeg_lift =
|
||||
if m.eeg_entrainment_adaptive.is_finite() && m.eeg_entrainment_fixed_40hz.is_finite() {
|
||||
let baseline = m.eeg_entrainment_fixed_40hz.max(1e-6);
|
||||
(m.eeg_entrainment_adaptive - baseline) / baseline
|
||||
} else {
|
||||
f64::NAN // propagate so the verdict below fails closed
|
||||
};
|
||||
let eeg_lift_pass = eeg_lift.is_finite() && eeg_lift >= t.min_eeg_lift;
|
||||
|
||||
let overall_pass = frequency_pass
|
||||
&& worst_frequency_pass
|
||||
&& jitter_pass
|
||||
&& av_sync_pass
|
||||
&& stop_latency_pass
|
||||
&& hash_pass
|
||||
&& eeg_lift_pass;
|
||||
|
||||
HilReport {
|
||||
frequency_error_hz,
|
||||
frequency_pass,
|
||||
worst_frequency_error_hz,
|
||||
worst_frequency_pass,
|
||||
jitter_pass,
|
||||
av_sync_pass,
|
||||
stop_latency_pass,
|
||||
hash_reproducibility,
|
||||
hash_pass,
|
||||
eeg_lift,
|
||||
eeg_lift_pass,
|
||||
overall_pass,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn passing() -> HilMeasurement {
|
||||
HilMeasurement {
|
||||
commanded_frequency_hz: 40.0,
|
||||
measured_frequency_hz: 40.05, // within ±0.1 Hz (mean)
|
||||
max_abs_freq_error_hz: 0.08, // worst case also within ±0.1 Hz
|
||||
max_half_period_jitter_us: 120.0, // < 500 µs
|
||||
av_sync_drift_ms: 2.0, // < 5 ms
|
||||
stop_latency_ms: 40.0, // < 100 ms
|
||||
hashes_reproduced: 100,
|
||||
hashes_total: 100, // 100%
|
||||
eeg_entrainment_adaptive: 0.36,
|
||||
eeg_entrainment_fixed_40hz: 0.30, // +20%
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_good_bench_run_passes_all_criteria() {
|
||||
let r = verify_hil(&passing(), &HilTargets::default());
|
||||
assert!(r.overall_pass);
|
||||
assert!(r.frequency_error_hz <= 0.1);
|
||||
assert!((r.eeg_lift - 0.20).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frequency_drift_beyond_tenth_hz_fails() {
|
||||
let mut m = passing();
|
||||
m.measured_frequency_hz = 40.3; // 0.3 Hz error
|
||||
let r = verify_hil(&m, &HilTargets::default());
|
||||
assert!(!r.frequency_pass);
|
||||
assert!(!r.overall_pass);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slow_stop_fails() {
|
||||
let mut m = passing();
|
||||
m.stop_latency_ms = 250.0;
|
||||
assert!(!verify_hil(&m, &HilTargets::default()).stop_latency_pass);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_stop_measurement_fails_closed() {
|
||||
let mut m = passing();
|
||||
m.stop_latency_ms = f64::NAN;
|
||||
assert!(!verify_hil(&m, &HilTargets::default()).stop_latency_pass);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn any_hash_mismatch_fails_reproducibility() {
|
||||
let mut m = passing();
|
||||
m.hashes_reproduced = 99; // one of 100 drifted
|
||||
let r = verify_hil(&m, &HilTargets::default());
|
||||
assert!(!r.hash_pass);
|
||||
assert!(!r.overall_pass);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_replay_fails_closed() {
|
||||
let mut m = passing();
|
||||
m.hashes_reproduced = 0;
|
||||
m.hashes_total = 0;
|
||||
assert!(!verify_hil(&m, &HilTargets::default()).hash_pass);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insufficient_eeg_lift_fails() {
|
||||
let mut m = passing();
|
||||
m.eeg_entrainment_adaptive = 0.32; // only +6.7%
|
||||
let r = verify_hil(&m, &HilTargets::default());
|
||||
assert!(!r.eeg_lift_pass);
|
||||
assert!(!r.overall_pass);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_drift_beyond_5ms_fails() {
|
||||
let mut m = passing();
|
||||
m.av_sync_drift_ms = 7.5;
|
||||
assert!(!verify_hil(&m, &HilTargets::default()).av_sync_pass);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worst_case_drift_beyond_budget_fails_even_when_mean_passes() {
|
||||
let mut m = passing();
|
||||
m.max_abs_freq_error_hz = 0.25; // mean is 0.05 Hz, worst case is not
|
||||
let r = verify_hil(&m, &HilTargets::default());
|
||||
assert!(r.frequency_pass);
|
||||
assert!(!r.worst_frequency_pass);
|
||||
assert!(!r.overall_pass);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn half_period_jitter_beyond_budget_fails() {
|
||||
let mut m = passing();
|
||||
m.max_half_period_jitter_us = 900.0;
|
||||
let r = verify_hil(&m, &HilTargets::default());
|
||||
assert!(!r.jitter_pass);
|
||||
assert!(!r.overall_pass);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nan_measurements_fail_closed_per_criterion() {
|
||||
let t = HilTargets::default();
|
||||
|
||||
let mut m = passing();
|
||||
m.measured_frequency_hz = f64::NAN;
|
||||
assert!(!verify_hil(&m, &t).frequency_pass);
|
||||
|
||||
let mut m = passing();
|
||||
m.max_abs_freq_error_hz = f64::NAN;
|
||||
assert!(!verify_hil(&m, &t).worst_frequency_pass);
|
||||
|
||||
let mut m = passing();
|
||||
m.max_half_period_jitter_us = f64::NAN;
|
||||
assert!(!verify_hil(&m, &t).jitter_pass);
|
||||
|
||||
let mut m = passing();
|
||||
m.av_sync_drift_ms = f64::NAN;
|
||||
assert!(!verify_hil(&m, &t).av_sync_pass);
|
||||
|
||||
let mut m = passing();
|
||||
m.eeg_entrainment_fixed_40hz = f64::NAN;
|
||||
assert!(!verify_hil(&m, &t).eeg_lift_pass);
|
||||
|
||||
let mut m = passing();
|
||||
m.eeg_entrainment_adaptive = f64::NAN;
|
||||
assert!(!verify_hil(&m, &t).eeg_lift_pass);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_worst_case_values_fail_closed() {
|
||||
let t = HilTargets::default();
|
||||
let mut m = passing();
|
||||
m.max_abs_freq_error_hz = -0.01; // impossible absolute error
|
||||
assert!(!verify_hil(&m, &t).worst_frequency_pass);
|
||||
let mut m = passing();
|
||||
m.max_half_period_jitter_us = -5.0;
|
||||
assert!(!verify_hil(&m, &t).jitter_pass);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn impossible_hash_counts_fail_closed() {
|
||||
let mut m = passing();
|
||||
m.hashes_reproduced = 101; // > hashes_total: corrupt bench data
|
||||
m.hashes_total = 100;
|
||||
let r = verify_hil(&m, &HilTargets::default());
|
||||
assert!(!r.hash_pass);
|
||||
assert_eq!(r.hash_reproducibility, 0.0); // never reported as >100%
|
||||
assert!(!r.overall_pass);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
//! # ruview-gamma — Adaptive Gamma Entrainment (ADR-250)
|
||||
//!
|
||||
//! Governed, deterministic, safety-constrained personalization of 40 Hz-prior
|
||||
//! multisensory stimulation. Treats 40 Hz as the evidence-based **starting
|
||||
//! prior**, then learns each person's safe entrainment response curve using
|
||||
//! passive [RuView] sensing, optional EEG, a constrained optimizer, and
|
||||
//! auditable [RuFlo] workflows.
|
||||
//!
|
||||
//! > **Not medical advice / not a medical device.** This crate is a research
|
||||
//! > and engineering platform. The only product claim it makes is
|
||||
//! > [`ruflo::PRODUCT_CLAIM`] — *"personalized entrainment optimization"* — and
|
||||
//! > never Alzheimer's treatment, amyloid clearance, or any clinical outcome
|
||||
//! > (ADR-250 §19 Non-Goals). It performs no hardware actuation: real stimulus
|
||||
//! > delivery, RF sensing, and EEG arrive through external adapters behind
|
||||
//! > feature flags after this governed software core ships (ADR-250 §21).
|
||||
//!
|
||||
//! ## Design discipline
|
||||
//!
|
||||
//! A deterministic, dependency-light **leaf crate** (no internal RuView deps;
|
||||
//! ChaCha20 PRNG; SHA-256 witness) — the same posture as `nvsim`. The
|
||||
//! optimizer, safety envelope, RuVector update logic, and session witness are
|
||||
//! all testable and replayable bit-exactly *before any human exposure*. See
|
||||
//! [`proof::Proof`] for the deterministic bundle proof.
|
||||
//!
|
||||
//! ## Pipeline (ADR-250 §4)
|
||||
//!
|
||||
//! ```text
|
||||
//! enroll (consent + exclusion screen) ── ruflo
|
||||
//! → calibration sweep 36–44 Hz ── optimizer::CalibrationPlan
|
||||
//! → simulated/observed response ── simulator (M1) / external (M2-4)
|
||||
//! → safety monitor (hard stop, latched) ── safety
|
||||
//! → safe-entrainment score ── objective
|
||||
//! → personal response vector update ── response (RuVector)
|
||||
//! → Bayesian / bandit recommendation ── optimizer / bandit
|
||||
//! → witnessed audit record ── session + ruflo
|
||||
//! ```
|
||||
//!
|
||||
//! ## The safety invariant
|
||||
//!
|
||||
//! **No recommendation, calibration step, bandit arm, or closed-loop nudge can
|
||||
//! ever emit a [`stimulus::StimulusParameters`] outside the
|
||||
//! [`stimulus::SafetyEnvelope`].** Every emitting path clamps to the envelope
|
||||
//! and is asserted against [`stimulus::SafetyEnvelope::contains`] in tests. The
|
||||
//! optimizer never widens the envelope — only an operator constructs a wider
|
||||
//! one deliberately (ADR-250 §12).
|
||||
//!
|
||||
//! ## Quick start
|
||||
//!
|
||||
//! ```
|
||||
//! use ruview_gamma::{
|
||||
//! ruflo::{Consent, RufloGovernor},
|
||||
//! response::RuViewState,
|
||||
//! simulator::{LatentPerson, ResponseSimulator},
|
||||
//! stimulus::{SafetyEnvelope, StimulusParameters},
|
||||
//! };
|
||||
//!
|
||||
//! let envelope = SafetyEnvelope::conservative();
|
||||
//! let mut gov = RufloGovernor::enroll("subject-001", envelope, &[], Consent::Granted)
|
||||
//! .expect("cleared to participate");
|
||||
//!
|
||||
//! // Milestone 1: drive the governed loop with the deterministic simulator.
|
||||
//! let sim = ResponseSimulator::new(42);
|
||||
//! let latent = LatentPerson::from_id("subject-001");
|
||||
//! let state = RuViewState::calm_baseline();
|
||||
//! gov.run_calibration(&sim, &latent, &state, 5.0, 0).unwrap();
|
||||
//!
|
||||
//! let rec = gov.recommend(&StimulusParameters::prior());
|
||||
//! assert!(envelope.contains(&rec.stimulus)); // always inside the envelope
|
||||
//! ```
|
||||
|
||||
pub mod acceptance;
|
||||
pub mod bandit;
|
||||
pub mod hil;
|
||||
pub mod math;
|
||||
pub mod objective;
|
||||
pub mod optimizer;
|
||||
pub mod participant;
|
||||
pub mod program;
|
||||
pub mod proof;
|
||||
pub mod response;
|
||||
pub mod ruflo;
|
||||
pub mod ruvector;
|
||||
pub mod safety;
|
||||
pub mod session;
|
||||
pub mod simulator;
|
||||
pub mod stimulus;
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
/// Crate-level error type (re-exported for callers who want a single error to
|
||||
/// match on; most modules expose their own typed errors).
|
||||
#[derive(Debug, Error)]
|
||||
pub enum GammaError {
|
||||
/// A governance refusal (consent / exclusion / envelope).
|
||||
#[error(transparent)]
|
||||
Governance(#[from] ruflo::GovernanceError),
|
||||
/// JSON (de)serialization of a session record failed.
|
||||
#[error("serialization error: {0}")]
|
||||
Serde(#[from] serde_json::Error),
|
||||
}
|
||||
|
||||
/// The single allowed product claim. Re-exported at crate root for prominence.
|
||||
pub use ruflo::PRODUCT_CLAIM;
|
||||
|
||||
#[cfg(test)]
|
||||
mod integration_tests {
|
||||
use crate::response::RuViewState;
|
||||
use crate::ruflo::{Consent, RufloGovernor, TrialMode};
|
||||
use crate::ruvector::{DriftStatus, ProfileStore};
|
||||
use crate::simulator::{LatentPerson, ResponseSimulator};
|
||||
use crate::stimulus::{SafetyEnvelope, StimulusParameters};
|
||||
|
||||
/// ADR-250 §10 item 3 end-to-end: a cohort of calibrated responders with
|
||||
/// similar physiology warm-starts a new person's optimizer — the very
|
||||
/// first recommendation already points near the cohort's peak band instead
|
||||
/// of the flat 40 Hz prior, while never counting as measured data
|
||||
/// (`n_real_obs == 0`, `best() == None`).
|
||||
#[test]
|
||||
fn cohort_warm_start_improves_first_recommendation() {
|
||||
let env = SafetyEnvelope::conservative();
|
||||
let sim = ResponseSimulator::new(404);
|
||||
let state = RuViewState::calm_baseline();
|
||||
|
||||
// Find a latent subject with a clearly detuned peak, then build a
|
||||
// cohort of 3 donors with the *same* latent physiology (similar
|
||||
// responders) who each ran a full calibration.
|
||||
let mut chosen = None;
|
||||
for n in 0..50 {
|
||||
let id = format!("cohort-seed-{n}");
|
||||
let p = LatentPerson::from_id(&id);
|
||||
if (p.peak_hz - 40.0).abs() > 2.0 && p.peak_hz > 37.0 && p.peak_hz < 43.0 {
|
||||
chosen = Some((id, p));
|
||||
break;
|
||||
}
|
||||
}
|
||||
let (seed_id, latent) = chosen.expect("a detuned subject exists");
|
||||
|
||||
let mut store = ProfileStore::new();
|
||||
for d in 0..3 {
|
||||
let donor_id = format!("{seed_id}-donor-{d}");
|
||||
let mut donor = RufloGovernor::enroll(&donor_id, env, &[], Consent::Granted).unwrap();
|
||||
donor
|
||||
.run_calibration(&sim, &latent, &state, 5.0, 0)
|
||||
.unwrap();
|
||||
store.upsert(donor.export_anonymized_profile());
|
||||
}
|
||||
|
||||
// New person, zero sessions: cold start recommends the 40 Hz prior...
|
||||
let cold = RufloGovernor::enroll("newcomer", env, &[], Consent::Granted).unwrap();
|
||||
let cold_rec = cold.recommend(&StimulusParameters::prior());
|
||||
assert_eq!(cold_rec.stimulus.frequency_hz, 40.0);
|
||||
|
||||
// ...while a cohort-seeded start points into the cohort's peak band.
|
||||
let mut warm = RufloGovernor::enroll("newcomer", env, &[], Consent::Granted).unwrap();
|
||||
let n_priors = warm.seed_from_cohort(&store, 3);
|
||||
assert!(n_priors > 0);
|
||||
let warm_rec = warm.recommend(&StimulusParameters::prior());
|
||||
assert!(env.contains(&warm_rec.stimulus));
|
||||
let cold_err = (cold_rec.stimulus.frequency_hz - latent.peak_hz).abs();
|
||||
let warm_err = (warm_rec.stimulus.frequency_hz - latent.peak_hz).abs();
|
||||
assert!(
|
||||
warm_err < cold_err,
|
||||
"warm-start ({} Hz) should beat cold start ({} Hz) for peak {} Hz",
|
||||
warm_rec.stimulus.frequency_hz,
|
||||
cold_rec.stimulus.frequency_hz,
|
||||
latent.peak_hz
|
||||
);
|
||||
// Honesty: priors are not measured data.
|
||||
assert!(warm.audit_log().is_empty());
|
||||
assert_eq!(warm.clinician_report().n_sessions, 0);
|
||||
}
|
||||
|
||||
/// Platform extension: each program enforces its **own** safety envelope.
|
||||
/// A stimulus that is fine for the Alzheimer's program (brightness 0.30)
|
||||
/// exceeds the sleep program's near-dark cap (0.10) and is refused.
|
||||
#[test]
|
||||
fn per_program_envelope_is_enforced() {
|
||||
use crate::program::NeuroProgram;
|
||||
use crate::stimulus::StimulusParameters;
|
||||
|
||||
let sim = ResponseSimulator::new(5);
|
||||
let latent = LatentPerson::from_id("prog-subject");
|
||||
let state = RuViewState::calm_baseline();
|
||||
|
||||
// 0.30 brightness: inside Alzheimer's envelope, outside sleep's (0.10).
|
||||
let mut stim = StimulusParameters::prior();
|
||||
stim.frequency_hz = 40.0;
|
||||
stim.brightness_level = 0.30;
|
||||
|
||||
let mut alz = RufloGovernor::enroll_program(
|
||||
"p1",
|
||||
NeuroProgram::alzheimers_research(),
|
||||
&[],
|
||||
Consent::Granted,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(alz.run_session(&sim, &latent, &state, &stim, 0).is_ok());
|
||||
|
||||
let mut sleep = RufloGovernor::enroll_program(
|
||||
"p2",
|
||||
NeuroProgram::sleep_optimization(),
|
||||
&[],
|
||||
Consent::Granted,
|
||||
)
|
||||
.unwrap();
|
||||
// The same stimulus is out-of-envelope for the sleep program.
|
||||
assert!(sleep.run_session(&sim, &latent, &state, &stim, 0).is_err());
|
||||
// Its own prior (audio, near-dark) is accepted.
|
||||
let prior = sleep.prior();
|
||||
assert!(sleep.program().unwrap().envelope.contains(&prior));
|
||||
}
|
||||
|
||||
/// Platform acceptance matrix: every catalog program is gradable and the
|
||||
/// claim gate is total (always yields a releasable string; a failing
|
||||
/// program never leaks its marketing claim).
|
||||
#[test]
|
||||
fn acceptance_matrix_gates_every_program_claim() {
|
||||
use crate::acceptance::{AcceptanceCriteria, AcceptanceHarness, NO_CLAIM};
|
||||
use crate::program::NeuroProgram;
|
||||
|
||||
// A detuned subject so adaptive programs have real gain to find.
|
||||
let mut chosen = None;
|
||||
for n in 0..80 {
|
||||
let id = format!("matrix-{n}");
|
||||
let p = LatentPerson::from_id(&id);
|
||||
if (p.peak_hz - 40.0).abs() > 2.0 && p.peak_hz > 37.5 && p.peak_hz < 42.5 {
|
||||
chosen = Some(p);
|
||||
break;
|
||||
}
|
||||
}
|
||||
let person = chosen.expect("detuned subject");
|
||||
let harness = AcceptanceHarness::new(42, AcceptanceCriteria::default());
|
||||
let state = RuViewState::calm_baseline();
|
||||
for program in NeuroProgram::catalog() {
|
||||
let report = harness.evaluate(&program, &person, &state);
|
||||
assert!(!report.released_claim.is_empty());
|
||||
if report.overall_pass {
|
||||
assert_eq!(report.claim_gate().claim(), program.claim);
|
||||
} else {
|
||||
assert_eq!(report.claim_gate().claim(), NO_CLAIM);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// ADR-250 §10 item 4: a stable participant stays `Stable`; collapsing
|
||||
/// their physiology (restless, uncomfortable, no entrainment) flags
|
||||
/// `Drifted`, recommending recalibration.
|
||||
#[test]
|
||||
fn drift_is_flagged_when_response_collapses() {
|
||||
let env = SafetyEnvelope::conservative();
|
||||
let sim = ResponseSimulator::new(77);
|
||||
let latent = LatentPerson::from_id("drift-subject");
|
||||
let calm = RuViewState::calm_baseline();
|
||||
// This behavioral test compresses many sessions into a tiny time window
|
||||
// (timestamps 100..105 ms), which is incompatible with the conservative
|
||||
// wall-clock dose/cooldown policy; use the simulation policy so the dose
|
||||
// gate does not pre-empt the drift signal under test.
|
||||
let mut gov = RufloGovernor::enroll("drift-subject", env, &[], Consent::Granted)
|
||||
.unwrap()
|
||||
.with_dose_limits(crate::participant::DoseLimits::permissive_for_simulation());
|
||||
|
||||
// Settle in: calibration sweep (9 sessions) → stable.
|
||||
gov.run_calibration(&sim, &latent, &calm, 5.0, 0).unwrap();
|
||||
assert_eq!(gov.drift_status(), DriftStatus::Stable);
|
||||
|
||||
// Physiology collapses: restless, fragmented breathing, low stillness.
|
||||
let mut collapsed = calm;
|
||||
collapsed.restlessness_score = 1.0;
|
||||
collapsed.stillness_score = 0.0;
|
||||
collapsed.breathing_stability = 0.1;
|
||||
collapsed.motion_artifact = 0.9;
|
||||
let stim = StimulusParameters::prior();
|
||||
let mut drifted = false;
|
||||
for i in 0..6 {
|
||||
gov.run_session(&sim, &latent, &collapsed, &stim, 100 + i)
|
||||
.unwrap();
|
||||
if gov.drift_status() == DriftStatus::Drifted {
|
||||
drifted = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(drifted, "collapsed physiology must flag drift");
|
||||
}
|
||||
|
||||
/// ADR-250 §18 acceptance: adaptive recommendation beats the fixed 40 Hz
|
||||
/// prior in mean simulated entrainment for a person whose peak is away
|
||||
/// from 40 Hz. (We assert improvement, not the exact ≥20% figure, which is
|
||||
/// simulator-dependent; the harness for the quantitative claim is here.)
|
||||
#[test]
|
||||
fn adaptive_beats_fixed_40hz_for_detuned_person() {
|
||||
let env = SafetyEnvelope::conservative();
|
||||
let sim = ResponseSimulator::new(2024);
|
||||
// Pick a subject whose latent peak is clearly off 40 Hz.
|
||||
let mut chosen = None;
|
||||
for n in 0..50 {
|
||||
let id = format!("detuned-{n}");
|
||||
let p = LatentPerson::from_id(&id);
|
||||
if (p.peak_hz - 40.0).abs() > 1.5 {
|
||||
chosen = Some((id, p));
|
||||
break;
|
||||
}
|
||||
}
|
||||
let (id, latent) = chosen.expect("a detuned subject exists");
|
||||
let state = RuViewState::calm_baseline();
|
||||
|
||||
// Learn via calibration.
|
||||
let mut gov = RufloGovernor::enroll(&id, env, &[], Consent::Granted).unwrap();
|
||||
gov.run_calibration(&sim, &latent, &state, 5.0, 0).unwrap();
|
||||
let rec = gov.recommend(&StimulusParameters::prior());
|
||||
|
||||
// Compare mean simulated entrainment over repeated sessions: fixed 40 Hz
|
||||
// vs the adaptive recommendation. Use fresh session indices.
|
||||
let fixed = {
|
||||
let mut s = StimulusParameters::prior();
|
||||
s.frequency_hz = 40.0;
|
||||
s
|
||||
};
|
||||
let mean = |stim: &StimulusParameters| -> f64 {
|
||||
(0..20)
|
||||
.map(|i| {
|
||||
sim.simulate(&latent, &state, stim, 1000 + i)
|
||||
.eeg
|
||||
.gamma_power_gain
|
||||
})
|
||||
.sum::<f64>()
|
||||
/ 20.0
|
||||
};
|
||||
assert!(env.contains(&rec.stimulus));
|
||||
assert!(mean(&rec.stimulus) > mean(&fixed));
|
||||
}
|
||||
|
||||
/// End-to-end: a blinded sham arm yields lower mean entrainment than the
|
||||
/// open arm across a small cohort — the controlled-trial primitive.
|
||||
#[test]
|
||||
fn sham_arm_shows_no_entrainment_across_cohort() {
|
||||
let env = SafetyEnvelope::conservative();
|
||||
let sim = ResponseSimulator::new(7);
|
||||
let state = RuViewState::calm_baseline();
|
||||
let mut open_sum = 0.0;
|
||||
let mut sham_sum = 0.0;
|
||||
for n in 0..8 {
|
||||
let id = format!("cohort-{n}");
|
||||
let latent = LatentPerson::from_id(&id);
|
||||
let mut stim = StimulusParameters::prior();
|
||||
stim.frequency_hz = (latent.peak_hz * 10.0).round() / 10.0;
|
||||
stim.frequency_hz = stim.frequency_hz.clamp(36.0, 44.0);
|
||||
|
||||
let mut open = RufloGovernor::enroll(&id, env, &[], Consent::Granted).unwrap();
|
||||
let o = open.run_session(&sim, &latent, &state, &stim, 0).unwrap();
|
||||
open_sum += o.outcome.entrainment_score;
|
||||
|
||||
let mut sham = RufloGovernor::enroll(&id, env, &[], Consent::Granted).unwrap();
|
||||
sham.set_mode(TrialMode::Sham);
|
||||
let s = sham.run_session(&sim, &latent, &state, &stim, 0).unwrap();
|
||||
sham_sum += s.outcome.entrainment_score;
|
||||
}
|
||||
assert!(open_sum > sham_sum);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
//! Small, dependency-light deterministic numerics shared across modules.
|
||||
//!
|
||||
//! All functions are pure `f64` and deterministic — no global state, no
|
||||
//! randomness. Kept intentionally tiny so the Gaussian-process surrogate
|
||||
//! (`optimizer.rs`) and the LinUCB bandit (`bandit.rs`) do not pull in a
|
||||
//! linear-algebra crate that could perturb the witness with its own float
|
||||
//! reassociation choices.
|
||||
|
||||
/// Standard-normal PDF φ(z).
|
||||
#[inline]
|
||||
pub fn normal_pdf(z: f64) -> f64 {
|
||||
const INV_SQRT_2PI: f64 = 0.398_942_280_401_432_7;
|
||||
INV_SQRT_2PI * (-0.5 * z * z).exp()
|
||||
}
|
||||
|
||||
/// Error function via Abramowitz & Stegun 7.1.26 (max abs error ≈ 1.5e-7).
|
||||
///
|
||||
/// Deterministic and branch-stable, so the witness is portable across targets.
|
||||
#[inline]
|
||||
pub fn erf(x: f64) -> f64 {
|
||||
let sign = if x < 0.0 { -1.0 } else { 1.0 };
|
||||
let x = x.abs();
|
||||
let t = 1.0 / (1.0 + 0.327_591_1 * x);
|
||||
let y = 1.0
|
||||
- (((((1.061_405_429 * t - 1.453_152_027) * t) + 1.421_413_741) * t - 0.284_496_736) * t
|
||||
+ 0.254_829_592)
|
||||
* t
|
||||
* (-x * x).exp();
|
||||
sign * y
|
||||
}
|
||||
|
||||
/// Standard-normal CDF Φ(z).
|
||||
#[inline]
|
||||
pub fn normal_cdf(z: f64) -> f64 {
|
||||
0.5 * (1.0 + erf(z / std::f64::consts::SQRT_2))
|
||||
}
|
||||
|
||||
/// Dot product. Panics if lengths differ (caller invariant).
|
||||
#[inline]
|
||||
pub fn dot(a: &[f64], b: &[f64]) -> f64 {
|
||||
debug_assert_eq!(a.len(), b.len());
|
||||
a.iter().zip(b).map(|(x, y)| x * y).sum()
|
||||
}
|
||||
|
||||
/// Squared-exponential (RBF) kernel k(a,b) = σ_f² · exp(−‖a−b‖²/(2ℓ²)).
|
||||
#[inline]
|
||||
pub fn rbf_kernel(a: &[f64], b: &[f64], length_scale: f64, signal_var: f64) -> f64 {
|
||||
let d2: f64 = a.iter().zip(b).map(|(x, y)| (x - y) * (x - y)).sum();
|
||||
signal_var * (-0.5 * d2 / (length_scale * length_scale)).exp()
|
||||
}
|
||||
|
||||
/// Cholesky factor L (lower-triangular) of a symmetric positive-definite
|
||||
/// matrix `a` stored row-major `n×n`. Returns `None` if not SPD (a non-positive
|
||||
/// pivot), which the caller treats as "fall back to the prior mean".
|
||||
pub fn cholesky(a: &[f64], n: usize) -> Option<Vec<f64>> {
|
||||
let mut l = vec![0.0f64; n * n];
|
||||
for i in 0..n {
|
||||
for j in 0..=i {
|
||||
let mut sum = a[i * n + j];
|
||||
for k in 0..j {
|
||||
sum -= l[i * n + k] * l[j * n + k];
|
||||
}
|
||||
if i == j {
|
||||
if sum <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
l[i * n + j] = sum.sqrt();
|
||||
} else {
|
||||
l[i * n + j] = sum / l[j * n + j];
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(l)
|
||||
}
|
||||
|
||||
/// Solve `L y = b` (forward substitution) for lower-triangular `L` (`n×n`).
|
||||
pub fn forward_subst(l: &[f64], b: &[f64], n: usize) -> Vec<f64> {
|
||||
let mut y = vec![0.0f64; n];
|
||||
for i in 0..n {
|
||||
let mut sum = b[i];
|
||||
for k in 0..i {
|
||||
sum -= l[i * n + k] * y[k];
|
||||
}
|
||||
y[i] = sum / l[i * n + i];
|
||||
}
|
||||
y
|
||||
}
|
||||
|
||||
/// Solve `Lᵀ x = y` (back substitution) for lower-triangular `L` (`n×n`).
|
||||
pub fn back_subst_transpose(l: &[f64], y: &[f64], n: usize) -> Vec<f64> {
|
||||
let mut x = vec![0.0f64; n];
|
||||
for i in (0..n).rev() {
|
||||
let mut sum = y[i];
|
||||
for k in (i + 1)..n {
|
||||
sum -= l[k * n + i] * x[k];
|
||||
}
|
||||
x[i] = sum / l[i * n + i];
|
||||
}
|
||||
x
|
||||
}
|
||||
|
||||
/// Clamp helper that is explicit about non-finite handling: any non-finite
|
||||
/// input (NaN **or** ±∞) maps to `lo`, so a degenerate value can never escape
|
||||
/// the safety envelope upward toward `hi`.
|
||||
#[inline]
|
||||
pub fn clamp_safe(v: f64, lo: f64, hi: f64) -> f64 {
|
||||
if !v.is_finite() {
|
||||
lo
|
||||
} else {
|
||||
v.max(lo).min(hi)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use approx::assert_abs_diff_eq;
|
||||
|
||||
#[test]
|
||||
fn erf_known_values() {
|
||||
assert_abs_diff_eq!(erf(0.0), 0.0, epsilon = 1e-9);
|
||||
assert_abs_diff_eq!(erf(1.0), 0.842_700_79, epsilon = 1e-6);
|
||||
assert_abs_diff_eq!(erf(-1.0), -0.842_700_79, epsilon = 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normal_cdf_symmetry() {
|
||||
assert_abs_diff_eq!(normal_cdf(0.0), 0.5, epsilon = 1e-9);
|
||||
assert_abs_diff_eq!(normal_cdf(1.96), 0.975, epsilon = 1e-3);
|
||||
assert_abs_diff_eq!(normal_cdf(-1.96), 0.025, epsilon = 1e-3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cholesky_solves_spd_system() {
|
||||
// A = [[4,2],[2,3]], b=[1,1] -> x = A^-1 b
|
||||
let a = vec![4.0, 2.0, 2.0, 3.0];
|
||||
let l = cholesky(&a, 2).expect("SPD");
|
||||
let b = vec![1.0, 1.0];
|
||||
let y = forward_subst(&l, &b, 2);
|
||||
let x = back_subst_transpose(&l, &y, 2);
|
||||
// Verify A x = b
|
||||
assert_abs_diff_eq!(4.0 * x[0] + 2.0 * x[1], 1.0, epsilon = 1e-9);
|
||||
assert_abs_diff_eq!(2.0 * x[0] + 3.0 * x[1], 1.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cholesky_rejects_non_spd() {
|
||||
// Indefinite matrix
|
||||
let a = vec![1.0, 2.0, 2.0, 1.0];
|
||||
assert!(cholesky(&a, 2).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clamp_safe_maps_non_finite_to_low() {
|
||||
assert_eq!(clamp_safe(f64::NAN, 0.1, 0.9), 0.1);
|
||||
assert_eq!(clamp_safe(f64::INFINITY, 0.1, 0.9), 0.1);
|
||||
assert_eq!(clamp_safe(f64::NEG_INFINITY, 0.1, 0.9), 0.1);
|
||||
assert_eq!(clamp_safe(1.5, 0.1, 0.9), 0.9);
|
||||
assert_eq!(clamp_safe(-1.0, 0.1, 0.9), 0.1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
//! The safe-entrainment objective (ADR-250 §7).
|
||||
//!
|
||||
//! `score = w1·gamma + w2·phase_locking + w3·breathing_stability + w4·adherence
|
||||
//! + w5·comfort − w6·motion_artifact − w7·adverse_risk − w8·overstim`
|
||||
//!
|
||||
//! Safety is **not** a soft term here: it is a hard gate applied *before*
|
||||
//! scoring (`SafetyEnvelope`, `SafetyMonitor`). The `adverse_event_risk` and
|
||||
//! `overstimulation_penalty` terms only shape preference *within* the already
|
||||
//! safe region.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::response::{EegMeasurement, RuViewState, SubjectiveReport};
|
||||
use crate::stimulus::{SafetyEnvelope, StimulusParameters};
|
||||
|
||||
/// Objective weights (ADR-250 §7 default weighting).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ObjectiveWeights {
|
||||
pub gamma_gain: f64,
|
||||
pub phase_locking: f64,
|
||||
pub breathing_stability: f64,
|
||||
pub adherence: f64,
|
||||
pub comfort: f64,
|
||||
pub motion_artifact: f64,
|
||||
pub adverse_event_risk: f64,
|
||||
pub overstimulation: f64,
|
||||
}
|
||||
|
||||
impl Default for ObjectiveWeights {
|
||||
/// ADR-250 §7 default weighting table.
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
gamma_gain: 0.30,
|
||||
phase_locking: 0.25,
|
||||
breathing_stability: 0.10,
|
||||
adherence: 0.10,
|
||||
comfort: 0.15,
|
||||
motion_artifact: 0.05,
|
||||
adverse_event_risk: 0.20,
|
||||
overstimulation: 0.10,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Inputs to the score, gathered from one session's observations.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct ScoreInputs<'a> {
|
||||
pub stimulus: &'a StimulusParameters,
|
||||
pub ruview: &'a RuViewState,
|
||||
pub eeg: Option<&'a EegMeasurement>,
|
||||
pub subjective: &'a SubjectiveReport,
|
||||
/// Per-session adverse-event risk estimate `[0,1]` (model-provided).
|
||||
pub adverse_event_risk: f64,
|
||||
}
|
||||
|
||||
/// The safe-entrainment objective.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct SafeEntrainmentObjective {
|
||||
pub weights: ObjectiveWeights,
|
||||
pub envelope: SafetyEnvelope,
|
||||
}
|
||||
|
||||
impl SafeEntrainmentObjective {
|
||||
pub fn new(weights: ObjectiveWeights, envelope: SafetyEnvelope) -> Self {
|
||||
Self { weights, envelope }
|
||||
}
|
||||
|
||||
/// Score a session in `[~−1, ~1]`. When EEG is absent, the gamma and
|
||||
/// phase-locking terms fall back to RF-derived proxies (stillness and
|
||||
/// breathing stability) at reduced weight — never claiming verified neural
|
||||
/// entrainment (ADR-250 §16 consequence 3).
|
||||
pub fn score(&self, inp: &ScoreInputs) -> f64 {
|
||||
let w = &self.weights;
|
||||
|
||||
let (gamma, plv) = match inp.eeg {
|
||||
Some(e) => (e.gamma_power_gain, e.phase_locking_value),
|
||||
// RF-only proxy: down-weighted, derived from calm/still physiology.
|
||||
None => (
|
||||
0.5 * inp.ruview.stillness_score,
|
||||
0.5 * inp.ruview.breathing_stability,
|
||||
),
|
||||
};
|
||||
|
||||
let overstim = self.overstimulation_penalty(inp.stimulus);
|
||||
|
||||
w.gamma_gain * gamma
|
||||
+ w.phase_locking * plv
|
||||
+ w.breathing_stability * inp.ruview.breathing_stability
|
||||
+ w.adherence * inp.ruview.adherence
|
||||
+ w.comfort * inp.subjective.comfort
|
||||
- w.motion_artifact * inp.ruview.motion_artifact
|
||||
- w.adverse_event_risk * inp.adverse_event_risk
|
||||
- w.overstimulation * overstim
|
||||
}
|
||||
|
||||
/// Overstimulation penalty `[0,1]`: how close intensity sits to the caps,
|
||||
/// plus a duration term. Penalizes pushing brightness/volume toward the
|
||||
/// envelope edges and running long sessions before tolerance is shown.
|
||||
pub fn overstimulation_penalty(&self, s: &StimulusParameters) -> f64 {
|
||||
let b = if self.envelope.brightness_cap() > 0.0 {
|
||||
s.brightness_level / self.envelope.brightness_cap()
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let v = if self.envelope.volume_cap() > 0.0 {
|
||||
s.volume_level / self.envelope.volume_cap()
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let d = if self.envelope.max_duration_minutes() > 0.0 {
|
||||
s.duration_minutes / self.envelope.max_duration_minutes()
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
// Mean of the three normalized loads, clamped to [0,1].
|
||||
((b + v + d) / 3.0).clamp(0.0, 1.0)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::response::SubjectiveReport;
|
||||
|
||||
fn objective() -> SafeEntrainmentObjective {
|
||||
SafeEntrainmentObjective::new(ObjectiveWeights::default(), SafetyEnvelope::conservative())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn weights_sum_to_documented_totals() {
|
||||
let w = ObjectiveWeights::default();
|
||||
// Positive entrainment+experience terms sum to 0.90 (ADR-250 §7).
|
||||
let pos = w.gamma_gain + w.phase_locking + w.breathing_stability + w.adherence + w.comfort;
|
||||
assert!((pos - 0.90).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strong_entrainment_scores_higher_than_weak() {
|
||||
let obj = objective();
|
||||
let stim = StimulusParameters::prior();
|
||||
let ruview = RuViewState::calm_baseline();
|
||||
let subj = SubjectiveReport::default();
|
||||
let strong = EegMeasurement {
|
||||
gamma_power_gain: 0.8,
|
||||
phase_locking_value: 0.8,
|
||||
artifact_score: 0.02,
|
||||
};
|
||||
let weak = EegMeasurement {
|
||||
gamma_power_gain: 0.1,
|
||||
phase_locking_value: 0.1,
|
||||
artifact_score: 0.02,
|
||||
};
|
||||
let s_strong = obj.score(&ScoreInputs {
|
||||
stimulus: &stim,
|
||||
ruview: &ruview,
|
||||
eeg: Some(&strong),
|
||||
subjective: &subj,
|
||||
adverse_event_risk: 0.0,
|
||||
});
|
||||
let s_weak = obj.score(&ScoreInputs {
|
||||
stimulus: &stim,
|
||||
ruview: &ruview,
|
||||
eeg: Some(&weak),
|
||||
subjective: &subj,
|
||||
adverse_event_risk: 0.0,
|
||||
});
|
||||
assert!(s_strong > s_weak);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adverse_risk_reduces_score() {
|
||||
let obj = objective();
|
||||
let stim = StimulusParameters::prior();
|
||||
let ruview = RuViewState::calm_baseline();
|
||||
let subj = SubjectiveReport::default();
|
||||
let eeg = EegMeasurement {
|
||||
gamma_power_gain: 0.5,
|
||||
phase_locking_value: 0.5,
|
||||
artifact_score: 0.02,
|
||||
};
|
||||
let safe = obj.score(&ScoreInputs {
|
||||
stimulus: &stim,
|
||||
ruview: &ruview,
|
||||
eeg: Some(&eeg),
|
||||
subjective: &subj,
|
||||
adverse_event_risk: 0.0,
|
||||
});
|
||||
let risky = obj.score(&ScoreInputs {
|
||||
stimulus: &stim,
|
||||
ruview: &ruview,
|
||||
eeg: Some(&eeg),
|
||||
subjective: &subj,
|
||||
adverse_event_risk: 0.9,
|
||||
});
|
||||
assert!(risky < safe);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overstimulation_penalty_grows_with_intensity() {
|
||||
let obj = objective();
|
||||
let mut low = StimulusParameters::prior();
|
||||
low.brightness_level = 0.05;
|
||||
low.volume_level = 0.05;
|
||||
low.duration_minutes = 5.0;
|
||||
let mut high = StimulusParameters::prior();
|
||||
high.brightness_level = 0.40;
|
||||
high.volume_level = 0.40;
|
||||
high.duration_minutes = 15.0;
|
||||
assert!(obj.overstimulation_penalty(&high) > obj.overstimulation_penalty(&low));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_eeg_falls_back_to_rf_proxy() {
|
||||
let obj = objective();
|
||||
let stim = StimulusParameters::prior();
|
||||
let ruview = RuViewState::calm_baseline();
|
||||
let subj = SubjectiveReport::default();
|
||||
// Should produce a finite, sane score without EEG.
|
||||
let s = obj.score(&ScoreInputs {
|
||||
stimulus: &stim,
|
||||
ruview: &ruview,
|
||||
eeg: None,
|
||||
subjective: &subj,
|
||||
adverse_event_risk: 0.0,
|
||||
});
|
||||
assert!(s.is_finite());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,480 @@
|
||||
//! Constrained optimizer — Phase 1 calibration, Phase 2 Bayesian optimization,
|
||||
//! Phase 4 closed-loop control (ADR-250 §8).
|
||||
//!
|
||||
//! **Invariant (enforced by construction and asserted in tests):** every
|
||||
//! [`StimulusParameters`] this module emits satisfies
|
||||
//! [`SafetyEnvelope::contains`]. The optimizer searches *frequency* over the
|
||||
//! envelope's 0.1 Hz grid (the ±0.1 Hz control spec, ADR-250 §18) while holding
|
||||
//! intensity at conservative values — it never widens the envelope (ADR-250 §12).
|
||||
|
||||
use crate::math::{
|
||||
back_subst_transpose, cholesky, dot, forward_subst, normal_cdf, normal_pdf, rbf_kernel,
|
||||
};
|
||||
use crate::stimulus::{SafetyEnvelope, StimulusParameters};
|
||||
|
||||
/// Phase-1 conservative calibration sweep (ADR-250 §8): short sessions at each
|
||||
/// integer Hz in the band. Hands its results to the Bayesian optimizer.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CalibrationPlan {
|
||||
frequencies: Vec<f64>,
|
||||
next: usize,
|
||||
}
|
||||
|
||||
impl CalibrationPlan {
|
||||
pub fn new(envelope: &SafetyEnvelope) -> Self {
|
||||
Self {
|
||||
frequencies: envelope.calibration_frequencies(),
|
||||
next: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of calibration sessions still pending.
|
||||
pub fn remaining(&self) -> usize {
|
||||
self.frequencies.len().saturating_sub(self.next)
|
||||
}
|
||||
|
||||
/// The next calibration stimulus (short duration, conservative intensity),
|
||||
/// or `None` when the sweep is complete. Always inside the envelope.
|
||||
pub fn next_stimulus(
|
||||
&mut self,
|
||||
envelope: &SafetyEnvelope,
|
||||
session_minutes: f64,
|
||||
) -> Option<StimulusParameters> {
|
||||
let f = *self.frequencies.get(self.next)?;
|
||||
self.next += 1;
|
||||
let mut s = StimulusParameters::prior();
|
||||
s.frequency_hz = f;
|
||||
s.duration_minutes = session_minutes;
|
||||
Some(envelope.clamp(s))
|
||||
}
|
||||
}
|
||||
|
||||
/// Gaussian-process surrogate over the 1-D frequency axis with an
|
||||
/// Expected-Improvement acquisition (ADR-250 §8 Phase 2).
|
||||
///
|
||||
/// Supports two observation classes: **real** sessions from this person
|
||||
/// ([`observe`](Self::observe), noise `noise_var`) and **cohort priors** from
|
||||
/// anonymized similar responders ([`observe_prior`](Self::observe_prior),
|
||||
/// caller-supplied larger noise). Priors shape the posterior mean where the
|
||||
/// person has no data yet, but are honestly down-weighted and never define the
|
||||
/// EI incumbent — only the person's own sessions can do that.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BayesianOptimizer {
|
||||
/// RBF length scale in Hz.
|
||||
pub length_scale: f64,
|
||||
/// GP signal variance.
|
||||
pub signal_var: f64,
|
||||
/// Observation noise variance (jitter; also keeps K SPD).
|
||||
pub noise_var: f64,
|
||||
/// EI exploration margin.
|
||||
pub xi: f64,
|
||||
/// Observed `(frequency_hz, score)` pairs.
|
||||
obs_x: Vec<f64>,
|
||||
obs_y: Vec<f64>,
|
||||
/// Per-observation noise variance (diagonal of the noise term in K).
|
||||
obs_noise: Vec<f64>,
|
||||
/// `true` for cohort pseudo-observations (excluded from the incumbent).
|
||||
obs_prior: Vec<bool>,
|
||||
}
|
||||
|
||||
impl Default for BayesianOptimizer {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
length_scale: 1.5,
|
||||
signal_var: 1.0,
|
||||
noise_var: 1e-4,
|
||||
xi: 0.01,
|
||||
obs_x: Vec::new(),
|
||||
obs_y: Vec::new(),
|
||||
obs_noise: Vec::new(),
|
||||
obs_prior: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BayesianOptimizer {
|
||||
/// Record a calibration/optimization result from a **real** session.
|
||||
pub fn observe(&mut self, frequency_hz: f64, score: f64) {
|
||||
self.obs_x.push(frequency_hz);
|
||||
self.obs_y.push(score);
|
||||
self.obs_noise.push(self.noise_var);
|
||||
self.obs_prior.push(false);
|
||||
}
|
||||
|
||||
/// Record a **cohort prior** pseudo-observation (ADR-250 §10 item 3):
|
||||
/// the expected score at `frequency_hz` inferred from anonymized similar
|
||||
/// responders, with `noise_var` reflecting how little it is trusted
|
||||
/// (must be ≥ the real-observation noise; clamped up if not).
|
||||
pub fn observe_prior(&mut self, frequency_hz: f64, score: f64, noise_var: f64) {
|
||||
self.obs_x.push(frequency_hz);
|
||||
self.obs_y.push(score);
|
||||
self.obs_noise.push(noise_var.max(self.noise_var));
|
||||
self.obs_prior.push(true);
|
||||
}
|
||||
|
||||
/// Number of observations so far (real + prior).
|
||||
pub fn n_obs(&self) -> usize {
|
||||
self.obs_x.len()
|
||||
}
|
||||
|
||||
/// Number of **real** (non-prior) observations.
|
||||
pub fn n_real_obs(&self) -> usize {
|
||||
self.obs_prior.iter().filter(|p| !**p).count()
|
||||
}
|
||||
|
||||
/// Best **real** observed score, or `None` if no real observations.
|
||||
/// Cohort priors are deliberately excluded: a borrowed expectation must
|
||||
/// never masquerade as this person's measured response.
|
||||
pub fn best(&self) -> Option<(f64, f64)> {
|
||||
let mut best: Option<(f64, f64)> = None;
|
||||
for ((&x, &y), &prior) in self.obs_x.iter().zip(&self.obs_y).zip(&self.obs_prior) {
|
||||
if prior {
|
||||
continue;
|
||||
}
|
||||
if best.map(|(_, by)| y > by).unwrap_or(true) {
|
||||
best = Some((x, y));
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
/// EI incumbent: the best real observation, falling back to the best prior
|
||||
/// when the person has no sessions yet (so cohort-seeded recommendation
|
||||
/// still explores sensibly rather than treating 0 as the bar).
|
||||
fn incumbent(&self) -> Option<f64> {
|
||||
if let Some((_, by)) = self.best() {
|
||||
return Some(by);
|
||||
}
|
||||
self.obs_y.iter().copied().fold(None, |acc, y| {
|
||||
Some(match acc {
|
||||
Some(a) if a >= y => a,
|
||||
_ => y,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Factorize the GP once: Cholesky `L` of `K = RBF(X,X)+diag(noise)` and
|
||||
/// the weight vector `alpha = K⁻¹ y`. Both depend only on the observations,
|
||||
/// not on any query point, so a single fit serves the whole acquisition
|
||||
/// grid. Returns `None` when there are no observations or `K` is not SPD.
|
||||
///
|
||||
/// The per-query arithmetic in [`GpFit::predict`] is identical to the old
|
||||
/// inline path, so predictions (and therefore the session witness) are
|
||||
/// bit-for-bit unchanged — this is a pure work-elimination optimization.
|
||||
fn fit(&self) -> Option<GpFit<'_>> {
|
||||
let n = self.obs_x.len();
|
||||
if n == 0 {
|
||||
return None;
|
||||
}
|
||||
// K (lower triangle is all Cholesky reads) = RBF(X,X) + diag(noise).
|
||||
let mut k = vec![0.0f64; n * n];
|
||||
for i in 0..n {
|
||||
for j in 0..=i {
|
||||
let mut v = rbf_kernel(
|
||||
&[self.obs_x[i]],
|
||||
&[self.obs_x[j]],
|
||||
self.length_scale,
|
||||
self.signal_var,
|
||||
);
|
||||
if i == j {
|
||||
v += self.obs_noise[i];
|
||||
}
|
||||
k[i * n + j] = v;
|
||||
}
|
||||
}
|
||||
let l = cholesky(&k, n)?;
|
||||
// alpha = K⁻¹ y (solve L Lᵀ alpha = y) — computed once.
|
||||
let y1 = forward_subst(&l, &self.obs_y, n);
|
||||
let alpha = back_subst_transpose(&l, &y1, n);
|
||||
Some(GpFit {
|
||||
obs_x: &self.obs_x,
|
||||
l,
|
||||
alpha,
|
||||
n,
|
||||
length_scale: self.length_scale,
|
||||
signal_var: self.signal_var,
|
||||
})
|
||||
}
|
||||
|
||||
/// GP posterior `(mean, variance)` at `x`. Falls back to `(0, signal_var)`
|
||||
/// (the prior) when there are no observations or K is not SPD.
|
||||
pub fn predict(&self, x: f64) -> (f64, f64) {
|
||||
match self.fit() {
|
||||
Some(fit) => fit.predict(x),
|
||||
None => (0.0, self.signal_var),
|
||||
}
|
||||
}
|
||||
|
||||
/// Expected Improvement (for maximization) at `x`.
|
||||
pub fn expected_improvement(&self, x: f64) -> f64 {
|
||||
let best = match self.incumbent() {
|
||||
Some(by) => by,
|
||||
None => return self.signal_var.sqrt(), // pure exploration
|
||||
};
|
||||
match self.fit() {
|
||||
Some(fit) => fit.expected_improvement(x, best, self.xi),
|
||||
None => self.signal_var.sqrt(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Recommend the next stimulus by maximizing EI over the envelope's 0.1 Hz
|
||||
/// grid, holding `base`'s intensity (clamped). The result is guaranteed
|
||||
/// inside the envelope. With no observations it returns the 40 Hz prior.
|
||||
///
|
||||
/// Fits the GP **once** and reuses the factorization across every grid
|
||||
/// candidate (was: a full Cholesky per candidate, ~82× the work).
|
||||
pub fn recommend(
|
||||
&self,
|
||||
envelope: &SafetyEnvelope,
|
||||
base: &StimulusParameters,
|
||||
) -> Recommendation {
|
||||
let fit = match self.fit() {
|
||||
Some(f) => f,
|
||||
None => {
|
||||
let s = envelope.clamp(*base);
|
||||
return Recommendation {
|
||||
stimulus: s,
|
||||
expected_improvement: 0.0,
|
||||
predicted_score: 0.0,
|
||||
confidence: 0.0,
|
||||
};
|
||||
}
|
||||
};
|
||||
// incumbent() is Some here (fit exists ⇒ ≥1 observation).
|
||||
let best = self.incumbent().unwrap_or(0.0);
|
||||
let grid = fine_grid(envelope);
|
||||
let mut best_f = base.frequency_hz;
|
||||
let mut best_ei = f64::NEG_INFINITY;
|
||||
for &f in &grid {
|
||||
let ei = fit.expected_improvement(f, best, self.xi);
|
||||
if ei > best_ei {
|
||||
best_ei = ei;
|
||||
best_f = f;
|
||||
}
|
||||
}
|
||||
let (mu, var) = fit.predict(best_f);
|
||||
let mut s = *base;
|
||||
s.frequency_hz = best_f;
|
||||
let s = envelope.clamp(s);
|
||||
Recommendation {
|
||||
stimulus: s,
|
||||
expected_improvement: best_ei.max(0.0),
|
||||
predicted_score: mu,
|
||||
// Confidence shrinks with posterior variance (more data near the
|
||||
// pick → tighter → higher confidence), squashed to [0,1].
|
||||
confidence: 1.0 / (1.0 + var.sqrt()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A cached GP factorization (Cholesky `L` + weights `alpha`) over a fixed set
|
||||
/// of observations, reused across many query points in one acquisition pass.
|
||||
struct GpFit<'a> {
|
||||
obs_x: &'a [f64],
|
||||
/// Cholesky factor of `K` (lower-triangular, `n×n` row-major).
|
||||
l: Vec<f64>,
|
||||
/// `alpha = K⁻¹ y`.
|
||||
alpha: Vec<f64>,
|
||||
n: usize,
|
||||
length_scale: f64,
|
||||
signal_var: f64,
|
||||
}
|
||||
|
||||
impl GpFit<'_> {
|
||||
/// Posterior `(mean, variance)` at `x`. Bit-identical to the former inline
|
||||
/// computation; only the shared `L`/`alpha` are now precomputed.
|
||||
fn predict(&self, x: f64) -> (f64, f64) {
|
||||
let n = self.n;
|
||||
// k* = RBF(X, x)
|
||||
let kstar: Vec<f64> = (0..n)
|
||||
.map(|i| rbf_kernel(&[self.obs_x[i]], &[x], self.length_scale, self.signal_var))
|
||||
.collect();
|
||||
let mean = dot(&kstar, &self.alpha);
|
||||
// var = k(x,x) - v·v, where L v = k*
|
||||
let v = forward_subst(&self.l, &kstar, n);
|
||||
let var = (self.signal_var - dot(&v, &v)).max(0.0);
|
||||
(mean, var)
|
||||
}
|
||||
|
||||
/// Expected Improvement at `x` given the incumbent `best` and margin `xi`.
|
||||
fn expected_improvement(&self, x: f64, best: f64, xi: f64) -> f64 {
|
||||
let (mu, var) = self.predict(x);
|
||||
let sigma = var.sqrt();
|
||||
if sigma <= 1e-12 {
|
||||
return 0.0;
|
||||
}
|
||||
let imp = mu - best - xi;
|
||||
let z = imp / sigma;
|
||||
imp * normal_cdf(z) + sigma * normal_pdf(z)
|
||||
}
|
||||
}
|
||||
|
||||
/// A recommendation plus its explainability fields (ADR-250 §14 response,
|
||||
/// §16 "explainable recommendation").
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Recommendation {
|
||||
pub stimulus: StimulusParameters,
|
||||
pub expected_improvement: f64,
|
||||
pub predicted_score: f64,
|
||||
pub confidence: f64,
|
||||
}
|
||||
|
||||
/// Phase-4 closed-loop controller (ADR-250 §8). Applies bounded mid-session
|
||||
/// adjustments; every output is re-clamped to the envelope.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct ClosedLoopController {
|
||||
/// Max single-step frequency nudge in Hz (kept small for safety).
|
||||
pub max_freq_step_hz: f64,
|
||||
/// Entrainment below this triggers a corrective nudge.
|
||||
pub entrainment_floor: f64,
|
||||
/// Comfort below this triggers an intensity reduction.
|
||||
pub comfort_floor: f64,
|
||||
/// Multiplicative intensity reduction on discomfort.
|
||||
pub intensity_backoff: f64,
|
||||
}
|
||||
|
||||
impl Default for ClosedLoopController {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_freq_step_hz: 0.5,
|
||||
entrainment_floor: 0.3,
|
||||
comfort_floor: 0.5,
|
||||
intensity_backoff: 0.8,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One closed-loop action recommendation.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum LoopAction {
|
||||
/// Continue unchanged.
|
||||
Hold,
|
||||
/// Adjust to a new (already envelope-clamped) stimulus.
|
||||
Adjust(StimulusParameters),
|
||||
}
|
||||
|
||||
impl ClosedLoopController {
|
||||
/// Decide the next in-session action from live entrainment/comfort.
|
||||
/// `gradient_sign` indicates which frequency direction recently improved
|
||||
/// entrainment (+1 up, −1 down, 0 unknown); the nudge respects it.
|
||||
pub fn step(
|
||||
&self,
|
||||
envelope: &SafetyEnvelope,
|
||||
current: &StimulusParameters,
|
||||
live_entrainment: f64,
|
||||
live_comfort: f64,
|
||||
gradient_sign: f64,
|
||||
) -> LoopAction {
|
||||
// Comfort first: discomfort always reduces intensity (safety-leaning).
|
||||
if live_comfort < self.comfort_floor {
|
||||
let mut s = *current;
|
||||
s.brightness_level *= self.intensity_backoff;
|
||||
s.volume_level *= self.intensity_backoff;
|
||||
return LoopAction::Adjust(envelope.clamp(s));
|
||||
}
|
||||
// Entrainment fading: small bounded frequency nudge toward improvement.
|
||||
if live_entrainment < self.entrainment_floor {
|
||||
let dir = if gradient_sign >= 0.0 { 1.0 } else { -1.0 };
|
||||
let mut s = *current;
|
||||
s.frequency_hz += dir * self.max_freq_step_hz;
|
||||
return LoopAction::Adjust(envelope.clamp(s));
|
||||
}
|
||||
LoopAction::Hold
|
||||
}
|
||||
}
|
||||
|
||||
/// The 0.1 Hz candidate grid over the envelope (ADR-250 §18 ±0.1 Hz precision).
|
||||
fn fine_grid(envelope: &SafetyEnvelope) -> Vec<f64> {
|
||||
let lo = (envelope.min_hz() * 10.0).round() as i64;
|
||||
let hi = (envelope.max_hz() * 10.0).round() as i64;
|
||||
(lo..=hi).map(|i| i as f64 / 10.0).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn calibration_sweep_covers_band_and_stays_safe() {
|
||||
let env = SafetyEnvelope::conservative();
|
||||
let mut plan = CalibrationPlan::new(&env);
|
||||
let mut seen = Vec::new();
|
||||
while let Some(s) = plan.next_stimulus(&env, 5.0) {
|
||||
assert!(env.contains(&s));
|
||||
seen.push(s.frequency_hz);
|
||||
}
|
||||
assert_eq!(seen.first(), Some(&36.0));
|
||||
assert_eq!(seen.last(), Some(&44.0));
|
||||
assert_eq!(plan.remaining(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gp_recovers_a_quadratic_peak() {
|
||||
// Synthetic score surface peaked at 39.5 Hz.
|
||||
let env = SafetyEnvelope::conservative();
|
||||
let mut bo = BayesianOptimizer::default();
|
||||
let truth = |f: f64| 1.0 - 0.05 * (f - 39.5).powi(2);
|
||||
for f in env.calibration_frequencies() {
|
||||
bo.observe(f, truth(f));
|
||||
}
|
||||
let rec = bo.recommend(&env, &StimulusParameters::prior());
|
||||
assert!(env.contains(&rec.stimulus));
|
||||
// Should land within ±1 Hz of the true peak (ADR-250 §18 repeatability).
|
||||
assert!((rec.stimulus.frequency_hz - 39.5).abs() <= 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recommendation_is_always_in_envelope() {
|
||||
let env = SafetyEnvelope::conservative();
|
||||
let mut bo = BayesianOptimizer::default();
|
||||
// Adversarial: pretend the band edge is best.
|
||||
for f in env.calibration_frequencies() {
|
||||
bo.observe(f, if f >= 44.0 { 10.0 } else { 0.0 });
|
||||
}
|
||||
let rec = bo.recommend(&env, &StimulusParameters::prior());
|
||||
assert!(env.contains(&rec.stimulus));
|
||||
assert!(rec.stimulus.frequency_hz <= env.max_hz());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_observations_returns_prior() {
|
||||
let env = SafetyEnvelope::conservative();
|
||||
let bo = BayesianOptimizer::default();
|
||||
let rec = bo.recommend(&env, &StimulusParameters::prior());
|
||||
assert_eq!(rec.stimulus.frequency_hz, 40.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closed_loop_reduces_intensity_on_discomfort() {
|
||||
let env = SafetyEnvelope::conservative();
|
||||
let ctl = ClosedLoopController::default();
|
||||
let cur = StimulusParameters::prior();
|
||||
match ctl.step(&env, &cur, 0.6, 0.2, 0.0) {
|
||||
LoopAction::Adjust(s) => {
|
||||
assert!(s.brightness_level < cur.brightness_level);
|
||||
assert!(env.contains(&s));
|
||||
}
|
||||
LoopAction::Hold => panic!("should have adjusted intensity"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closed_loop_nudge_stays_in_envelope() {
|
||||
let env = SafetyEnvelope::conservative();
|
||||
let ctl = ClosedLoopController::default();
|
||||
let mut cur = StimulusParameters::prior();
|
||||
cur.frequency_hz = 43.8; // near the upper edge
|
||||
match ctl.step(&env, &cur, 0.1, 0.9, 1.0) {
|
||||
LoopAction::Adjust(s) => assert!(env.contains(&s)),
|
||||
LoopAction::Hold => {}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closed_loop_holds_when_healthy() {
|
||||
let env = SafetyEnvelope::conservative();
|
||||
let ctl = ClosedLoopController::default();
|
||||
let cur = StimulusParameters::prior();
|
||||
assert_eq!(ctl.step(&env, &cur, 0.8, 0.9, 0.0), LoopAction::Hold);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
//! Cross-session participant safety state — the latched governor lock and the
|
||||
//! daily-dose / cooldown ledger (Findings 2 & 4, 2026-06-11 safety review).
|
||||
//!
|
||||
//! ADR-250 §8 mandates a **hard terminate-and-lock on adverse events**. A
|
||||
//! [`SafetyMonitor`](crate::safety::SafetyMonitor) latches *within* a session,
|
||||
//! but a fresh monitor is built per session, so on its own it cannot stop a new
|
||||
//! session from starting after a seizure-like stop. [`ParticipantSafetyState`]
|
||||
//! closes that gap: it is the **persisted** safety record for one participant,
|
||||
//! holding a latched [`LockRecord`] and the session ledger that backs the dose
|
||||
//! cap and cooldown. Serialize it into the session store and a *new*
|
||||
//! [`RufloGovernor`](crate::ruflo::RufloGovernor) for the same participant will
|
||||
//! still refuse until [`ParticipantSafetyState::unlock`] is called with an
|
||||
//! operator acknowledgment (which itself writes an audit record).
|
||||
//!
|
||||
//! ## Lock-class → lock mapping (documented invariant)
|
||||
//!
|
||||
//! | Stop reason | Lock? | Class |
|
||||
//! |-------------|-------|-------|
|
||||
//! | adverse: seizure-like | **yes** | [`LockClass::SeizureLike`] |
|
||||
//! | adverse: abnormal distress | **yes** | [`LockClass::Distress`] |
|
||||
//! | adverse: headache / dizziness / nausea / agitation / visual discomfort | **yes** | [`LockClass::AdverseEvent`] |
|
||||
//! | adverse: user-stop request | no | — (the participant chose to stop; not a clinical lock) |
|
||||
//! | sensor confidence below floor | no | — (unverifiable, retryable) |
|
||||
//! | protocol outside envelope | no | — (pre-empted before delivery) |
|
||||
//! | completed | no | — |
|
||||
//!
|
||||
//! A locked participant is a *fail-closed* state: every lock-warranting stop
|
||||
//! engages the lock; only an explicit human acknowledgment lifts it.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::safety::{AdverseEvent, StopReason};
|
||||
|
||||
/// The severity class of a latched lock (drives operator messaging and audit).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum LockClass {
|
||||
/// A general adverse event (headache, dizziness, nausea, agitation, visual
|
||||
/// discomfort).
|
||||
AdverseEvent,
|
||||
/// A seizure-like symptom — the most serious class.
|
||||
SeizureLike,
|
||||
/// Abnormal distress.
|
||||
Distress,
|
||||
}
|
||||
|
||||
impl LockClass {
|
||||
pub fn tag(self) -> &'static str {
|
||||
match self {
|
||||
LockClass::AdverseEvent => "adverse_event",
|
||||
LockClass::SeizureLike => "seizure_like",
|
||||
LockClass::Distress => "distress",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The lock class a safety stop warrants, or `None` if the stop does not lock.
|
||||
/// This is the single source of truth for the table in the module docs.
|
||||
pub fn lock_class_for(reason: &StopReason) -> Option<LockClass> {
|
||||
match reason {
|
||||
StopReason::AdverseEvent(ev) => match ev {
|
||||
AdverseEvent::SeizureLikeSymptom => Some(LockClass::SeizureLike),
|
||||
AdverseEvent::AbnormalDistress => Some(LockClass::Distress),
|
||||
// The participant choosing to stop is not a clinical adverse event
|
||||
// requiring human acknowledgment to ever resume.
|
||||
AdverseEvent::UserStopRequest => None,
|
||||
AdverseEvent::Headache
|
||||
| AdverseEvent::Dizziness
|
||||
| AdverseEvent::Nausea
|
||||
| AdverseEvent::Agitation
|
||||
| AdverseEvent::VisualDiscomfort => Some(LockClass::AdverseEvent),
|
||||
},
|
||||
// Operational stops fail the session closed but do not latch a
|
||||
// cross-session lock (they are retryable / pre-empted, not adverse).
|
||||
StopReason::SensorConfidenceBelowFloor { .. }
|
||||
| StopReason::ProtocolOutsideEnvelope
|
||||
| StopReason::Completed => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A short, stable tag for a stop reason (for audit / error messages).
|
||||
pub fn stop_tag(reason: &StopReason) -> String {
|
||||
match reason {
|
||||
StopReason::Completed => "completed".to_string(),
|
||||
StopReason::AdverseEvent(ev) => format!("adverse:{}", ev.tag()),
|
||||
StopReason::SensorConfidenceBelowFloor { .. } => {
|
||||
"sensor_confidence_below_floor".to_string()
|
||||
}
|
||||
StopReason::ProtocolOutsideEnvelope => "protocol_outside_envelope".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A latched lock on a participant (ADR-250 §8 terminate-and-lock). Persisted so
|
||||
/// a new governor instance also honors it.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct LockRecord {
|
||||
pub class: LockClass,
|
||||
/// The stop reason that engaged the lock (see [`stop_tag`]).
|
||||
pub reason_tag: String,
|
||||
/// Caller-supplied epoch milliseconds at which the lock engaged.
|
||||
pub locked_at_ms: u64,
|
||||
}
|
||||
|
||||
/// An auditable unlock acknowledgment (ADR-250 §11 audit trail).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct UnlockRecord {
|
||||
/// The operator's free-text justification (e.g. clinician sign-off).
|
||||
pub operator_note: String,
|
||||
/// Caller-supplied epoch milliseconds at which the unlock occurred.
|
||||
pub unlocked_at_ms: u64,
|
||||
/// The class that was cleared (for the audit record).
|
||||
pub cleared_class: LockClass,
|
||||
}
|
||||
|
||||
/// Daily-dose and cooldown policy (Finding 4). Conservative consts by default;
|
||||
/// the absolute *envelope* bounds are non-negotiable, but dosing is a wall-clock
|
||||
/// policy the deterministic core can only enforce against caller-supplied
|
||||
/// timestamps, so it is configurable (with a conservative default).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub struct DoseLimits {
|
||||
/// Maximum distinct **sittings** in any rolling 24 h window.
|
||||
pub max_sittings_per_24h: usize,
|
||||
/// Minimum gap between distinct sittings, in minutes.
|
||||
pub min_inter_sitting_minutes: f64,
|
||||
}
|
||||
|
||||
impl DoseLimits {
|
||||
/// Conservative default cap (Finding 4 example): ≤ 4 sittings/day.
|
||||
pub const MAX_SITTINGS_PER_24H: usize = 4;
|
||||
/// Conservative default cooldown (Finding 4 example): ≥ 60 min between sittings.
|
||||
pub const MIN_INTER_SITTING_MINUTES: f64 = 60.0;
|
||||
/// The rolling-window width in milliseconds (24 h).
|
||||
pub const ROLLING_WINDOW_MS: u64 = 24 * 60 * 60 * 1000;
|
||||
|
||||
/// The enforced-by-default conservative policy.
|
||||
pub fn conservative() -> Self {
|
||||
Self {
|
||||
max_sittings_per_24h: Self::MAX_SITTINGS_PER_24H,
|
||||
min_inter_sitting_minutes: Self::MIN_INTER_SITTING_MINUTES,
|
||||
}
|
||||
}
|
||||
|
||||
/// Research/simulation policy that disables dose + cooldown. **Not for real
|
||||
/// deployments** — simulation compresses many sessions into a tiny time
|
||||
/// window, which is incompatible with wall-clock dosing; the conservative
|
||||
/// default governs the clinic/hardware path.
|
||||
pub fn permissive_for_simulation() -> Self {
|
||||
Self {
|
||||
max_sittings_per_24h: usize::MAX,
|
||||
min_inter_sitting_minutes: 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for DoseLimits {
|
||||
fn default() -> Self {
|
||||
Self::conservative()
|
||||
}
|
||||
}
|
||||
|
||||
/// Why an admission (start-of-session) check refused.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum DoseViolation {
|
||||
/// Too many distinct sittings within the rolling 24 h window.
|
||||
DailyLimit { sittings: usize, max: usize },
|
||||
/// Not enough time has elapsed since the previous sitting.
|
||||
Cooldown {
|
||||
elapsed_minutes: f64,
|
||||
required_minutes: f64,
|
||||
},
|
||||
}
|
||||
|
||||
/// The persisted per-participant safety state: a latched lock plus the sitting
|
||||
/// ledger that backs dose and cooldown. `Default` is the clean state.
|
||||
///
|
||||
/// A **sitting** is identified by its `timestamp_ms`. Sub-sessions delivered at
|
||||
/// the *same* timestamp (a calibration sweep) are one sitting and one dose unit;
|
||||
/// they never trip the inter-sitting cooldown against each other. Distinct
|
||||
/// timestamps are distinct sittings, each counted toward the daily cap — so
|
||||
/// calibration is **not** a backdoor around the dose budget.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ParticipantSafetyState {
|
||||
lock: Option<LockRecord>,
|
||||
/// Every session timestamp that has run (calibration steps included).
|
||||
sittings: Vec<u64>,
|
||||
/// Every unlock acknowledgment, oldest first (audit trail).
|
||||
unlock_audit: Vec<UnlockRecord>,
|
||||
}
|
||||
|
||||
impl ParticipantSafetyState {
|
||||
/// `true` if the participant is latched-locked.
|
||||
pub fn is_locked(&self) -> bool {
|
||||
self.lock.is_some()
|
||||
}
|
||||
|
||||
/// The active lock record, if any.
|
||||
pub fn lock_record(&self) -> Option<&LockRecord> {
|
||||
self.lock.as_ref()
|
||||
}
|
||||
|
||||
/// The unlock-acknowledgment audit trail.
|
||||
pub fn unlock_audit(&self) -> &[UnlockRecord] {
|
||||
&self.unlock_audit
|
||||
}
|
||||
|
||||
/// Engage the lock (idempotent / latched): the *first* lock-warranting stop
|
||||
/// wins and is never overwritten or downgraded by later events.
|
||||
pub fn engage_lock(&mut self, class: LockClass, reason_tag: String, ts_ms: u64) {
|
||||
if self.lock.is_none() {
|
||||
self.lock = Some(LockRecord {
|
||||
class,
|
||||
reason_tag,
|
||||
locked_at_ms: ts_ms,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Lift the lock with an explicit operator acknowledgment, writing an audit
|
||||
/// record. No-op (but still audited as a redundant ack) if already unlocked?
|
||||
/// — No: we only audit a real unlock. Returns the cleared class, or `None`
|
||||
/// if there was nothing locked.
|
||||
pub fn unlock(&mut self, operator_note: impl Into<String>, ts_ms: u64) -> Option<LockClass> {
|
||||
let cleared = self.lock.take()?;
|
||||
self.unlock_audit.push(UnlockRecord {
|
||||
operator_note: operator_note.into(),
|
||||
unlocked_at_ms: ts_ms,
|
||||
cleared_class: cleared.class,
|
||||
});
|
||||
Some(cleared.class)
|
||||
}
|
||||
|
||||
/// Record a session that actually ran (for dose / cooldown accounting).
|
||||
pub fn record_session(&mut self, ts_ms: u64) {
|
||||
self.sittings.push(ts_ms);
|
||||
}
|
||||
|
||||
/// Distinct sittings recorded within the rolling 24 h window ending at `now_ms`.
|
||||
pub fn sittings_in_window(&self, now_ms: u64) -> usize {
|
||||
let lo = now_ms.saturating_sub(DoseLimits::ROLLING_WINDOW_MS);
|
||||
self.sittings
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|&t| t >= lo && t <= now_ms)
|
||||
.collect::<std::collections::BTreeSet<u64>>()
|
||||
.len()
|
||||
}
|
||||
|
||||
/// Admission gate for a session about to start at `now_ms`. Same-timestamp
|
||||
/// sub-sessions (calibration sweep) pass freely; a *new* sitting must clear
|
||||
/// both the cooldown and the daily cap.
|
||||
pub fn check_admission(&self, now_ms: u64, limits: &DoseLimits) -> Result<(), DoseViolation> {
|
||||
// Cooldown vs the most recent *earlier* sitting (same-timestamp siblings
|
||||
// do not count — they are the same sitting).
|
||||
if limits.min_inter_sitting_minutes > 0.0 {
|
||||
if let Some(prev) = self.sittings.iter().copied().filter(|&t| t < now_ms).max() {
|
||||
let elapsed_minutes = (now_ms - prev) as f64 / 60_000.0;
|
||||
if elapsed_minutes < limits.min_inter_sitting_minutes {
|
||||
return Err(DoseViolation::Cooldown {
|
||||
elapsed_minutes,
|
||||
required_minutes: limits.min_inter_sitting_minutes,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
// Daily cap: distinct sittings in the window, including this prospective one.
|
||||
if limits.max_sittings_per_24h != usize::MAX {
|
||||
let lo = now_ms.saturating_sub(DoseLimits::ROLLING_WINDOW_MS);
|
||||
let mut distinct: std::collections::BTreeSet<u64> = self
|
||||
.sittings
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|&t| t >= lo && t <= now_ms)
|
||||
.collect();
|
||||
distinct.insert(now_ms);
|
||||
if distinct.len() > limits.max_sittings_per_24h {
|
||||
return Err(DoseViolation::DailyLimit {
|
||||
sittings: distinct.len(),
|
||||
max: limits.max_sittings_per_24h,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::safety::AdverseEvent;
|
||||
|
||||
#[test]
|
||||
fn seizure_and_distress_map_to_their_classes() {
|
||||
assert_eq!(
|
||||
lock_class_for(&StopReason::AdverseEvent(AdverseEvent::SeizureLikeSymptom)),
|
||||
Some(LockClass::SeizureLike)
|
||||
);
|
||||
assert_eq!(
|
||||
lock_class_for(&StopReason::AdverseEvent(AdverseEvent::AbnormalDistress)),
|
||||
Some(LockClass::Distress)
|
||||
);
|
||||
assert_eq!(
|
||||
lock_class_for(&StopReason::AdverseEvent(AdverseEvent::Headache)),
|
||||
Some(LockClass::AdverseEvent)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_stop_and_operational_stops_do_not_lock() {
|
||||
assert_eq!(
|
||||
lock_class_for(&StopReason::AdverseEvent(AdverseEvent::UserStopRequest)),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
lock_class_for(&StopReason::SensorConfidenceBelowFloor {
|
||||
value: 0.1,
|
||||
floor: 0.5
|
||||
}),
|
||||
None
|
||||
);
|
||||
assert_eq!(lock_class_for(&StopReason::ProtocolOutsideEnvelope), None);
|
||||
assert_eq!(lock_class_for(&StopReason::Completed), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lock_is_latched_first_wins() {
|
||||
let mut s = ParticipantSafetyState::default();
|
||||
s.engage_lock(LockClass::Distress, "adverse:abnormal_distress".into(), 100);
|
||||
s.engage_lock(LockClass::AdverseEvent, "adverse:headache".into(), 200);
|
||||
assert_eq!(s.lock_record().unwrap().class, LockClass::Distress);
|
||||
assert_eq!(s.lock_record().unwrap().locked_at_ms, 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unlock_clears_and_audits() {
|
||||
let mut s = ParticipantSafetyState::default();
|
||||
s.engage_lock(LockClass::SeizureLike, "adverse:seizure_like".into(), 10);
|
||||
assert!(s.is_locked());
|
||||
let cleared = s.unlock("clinician reviewed; cleared to resume", 999);
|
||||
assert_eq!(cleared, Some(LockClass::SeizureLike));
|
||||
assert!(!s.is_locked());
|
||||
assert_eq!(s.unlock_audit().len(), 1);
|
||||
assert_eq!(s.unlock_audit()[0].cleared_class, LockClass::SeizureLike);
|
||||
// A redundant unlock is a no-op and is not audited.
|
||||
assert_eq!(s.unlock("again", 1000), None);
|
||||
assert_eq!(s.unlock_audit().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_timestamp_sweep_is_one_sitting_no_cooldown() {
|
||||
let mut s = ParticipantSafetyState::default();
|
||||
let limits = DoseLimits::conservative();
|
||||
let t0 = 1_700_000_000_000u64;
|
||||
for _ in 0..9 {
|
||||
assert!(s.check_admission(t0, &limits).is_ok());
|
||||
s.record_session(t0);
|
||||
}
|
||||
assert_eq!(s.sittings_in_window(t0), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cooldown_blocks_a_too_soon_second_sitting() {
|
||||
let mut s = ParticipantSafetyState::default();
|
||||
let limits = DoseLimits::conservative();
|
||||
let t0 = 0u64;
|
||||
s.check_admission(t0, &limits).unwrap();
|
||||
s.record_session(t0);
|
||||
// 30 min later — inside the 60 min cooldown.
|
||||
let t1 = 30 * 60 * 1000;
|
||||
assert!(matches!(
|
||||
s.check_admission(t1, &limits),
|
||||
Err(DoseViolation::Cooldown { .. })
|
||||
));
|
||||
// 61 min later — clears the cooldown.
|
||||
let t2 = 61 * 60 * 1000;
|
||||
assert!(s.check_admission(t2, &limits).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn daily_cap_blocks_the_fifth_sitting() {
|
||||
let mut s = ParticipantSafetyState::default();
|
||||
let limits = DoseLimits::conservative();
|
||||
let hour = 60 * 60 * 1000u64;
|
||||
// Four sittings, each > 60 min apart, all within 24 h.
|
||||
for i in 0..4u64 {
|
||||
let t = i * 2 * hour;
|
||||
s.check_admission(t, &limits).unwrap();
|
||||
s.record_session(t);
|
||||
}
|
||||
// A fifth within the same 24 h window is over the cap.
|
||||
let t5 = 9 * hour;
|
||||
assert!(matches!(
|
||||
s.check_admission(t5, &limits),
|
||||
Err(DoseViolation::DailyLimit {
|
||||
sittings: 5,
|
||||
max: 4
|
||||
})
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_roundtrips_through_json() {
|
||||
let mut s = ParticipantSafetyState::default();
|
||||
s.engage_lock(LockClass::Distress, "adverse:abnormal_distress".into(), 7);
|
||||
s.record_session(7);
|
||||
let json = serde_json::to_string(&s).unwrap();
|
||||
let back: ParticipantSafetyState = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(s, back);
|
||||
assert!(back.is_locked());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
//! Neuromodulation programs (ADR-250 extension: adaptive sensory
|
||||
//! neuromodulation, not just Alzheimer's).
|
||||
//!
|
||||
//! The platform thesis: RuView turns the body into the feedback signal,
|
||||
//! RuVector turns repeated sessions into a personal response map, the device is
|
||||
//! the actuator, and RuFlo makes the loop governed and auditable. The *real*
|
||||
//! product is a personal neural-rhythm optimization platform — and each use
|
||||
//! case is a [`NeuroProgram`] bundling its own safety envelope, starting prior,
|
||||
//! objective weighting, physiological-state gating, evidence level, and the
|
||||
//! single claim it is allowed to make.
|
||||
//!
|
||||
//! **Claim discipline is structural:** a program's [`NeuroProgram::claim`] is
|
||||
//! always an *optimization / monitoring* statement, never a disease-treatment
|
||||
//! claim. The disease context lives only in [`EvidenceLevel`], and a claim is
|
||||
//! only releasable once the program clears its acceptance gate
|
||||
//! (`crate::acceptance`).
|
||||
|
||||
use crate::objective::ObjectiveWeights;
|
||||
use crate::response::SleepState;
|
||||
use crate::stimulus::{DutyCycle, Modality, SafetyEnvelope, StimulusParameters};
|
||||
|
||||
/// How well-supported a program's *disease/context* hypothesis is in the
|
||||
/// literature (the user's opportunity map). This gates nothing by itself — it
|
||||
/// is metadata a clinician/operator reads alongside the acceptance report.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum EvidenceLevel {
|
||||
/// Preclinical + early human (e.g. Alzheimer's gamma work).
|
||||
MediumPreclinicalEarlyHuman,
|
||||
/// Early human signals only (post-stroke, sleep, mood).
|
||||
EarlyHuman,
|
||||
/// Mixed / protocol-dependent (attention, working memory).
|
||||
Mixed,
|
||||
/// Speculative (home neuro-wellness).
|
||||
Speculative,
|
||||
/// Strong *infrastructure* opportunity (drug+device trial monitoring) —
|
||||
/// the strength is in measurement/governance, not a therapeutic claim.
|
||||
StrongInfrastructure,
|
||||
}
|
||||
|
||||
impl EvidenceLevel {
|
||||
pub fn tag(self) -> &'static str {
|
||||
match self {
|
||||
EvidenceLevel::MediumPreclinicalEarlyHuman => "medium_preclinical_early_human",
|
||||
EvidenceLevel::EarlyHuman => "early_human",
|
||||
EvidenceLevel::Mixed => "mixed_protocol_dependent",
|
||||
EvidenceLevel::Speculative => "speculative",
|
||||
EvidenceLevel::StrongInfrastructure => "strong_infrastructure",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Time-of-day preference for a program (state-dependent entrainment).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TimePreference {
|
||||
Morning,
|
||||
Evening,
|
||||
/// Quiet wakefulness (the Alzheimer's/attention default).
|
||||
QuietWake,
|
||||
/// Pre-sleep / during sleep (the sleep program).
|
||||
PreSleepOrSleep,
|
||||
/// Any time.
|
||||
Any,
|
||||
}
|
||||
|
||||
/// A named neuromodulation program: everything that distinguishes one use case
|
||||
/// from another, in one value.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NeuroProgram {
|
||||
/// Stable machine id (used in the session witness / provenance).
|
||||
pub id: &'static str,
|
||||
/// Human-readable name.
|
||||
pub display_name: &'static str,
|
||||
/// Literature support for the *context* hypothesis.
|
||||
pub evidence_level: EvidenceLevel,
|
||||
/// The ONLY claim this program may surface (an optimization/monitoring
|
||||
/// statement — never a disease-treatment claim).
|
||||
pub claim: &'static str,
|
||||
/// Per-program safety envelope (optimization happens only inside it).
|
||||
pub envelope: SafetyEnvelope,
|
||||
/// Evidence-based starting prior for this program.
|
||||
pub prior: StimulusParameters,
|
||||
/// Objective weighting tuned to the program's goal.
|
||||
pub weights: ObjectiveWeights,
|
||||
/// Physiological states in which a session is *protocol-eligible* (e.g. the
|
||||
/// sleep program permits `Asleep`; attention requires wakefulness).
|
||||
pub eligible_states: &'static [SleepState],
|
||||
/// When the program prefers to run.
|
||||
pub time_preference: TimePreference,
|
||||
}
|
||||
|
||||
impl NeuroProgram {
|
||||
/// Whether a session in `state` fits this program's protocol. This is a
|
||||
/// *protocol-fit* gate (measured by acceptance), distinct from the hard
|
||||
/// *safety* gate (`SafetyEnvelope` / `SafetyMonitor`).
|
||||
pub fn state_eligible(&self, state: SleepState) -> bool {
|
||||
self.eligible_states.contains(&state)
|
||||
}
|
||||
|
||||
// ---- The opportunity map (one constructor per use case) ----
|
||||
|
||||
/// Alzheimer's research — adaptive entrainment + trial monitoring. Matches
|
||||
/// the original ADR-250 defaults (conservative envelope, 40 Hz prior,
|
||||
/// default weights), so the `RufloGovernor::enroll` witness is unchanged.
|
||||
pub fn alzheimers_research() -> Self {
|
||||
Self {
|
||||
id: "alzheimers-research",
|
||||
display_name: "Alzheimer's Research (adaptive entrainment + trial monitoring)",
|
||||
evidence_level: EvidenceLevel::MediumPreclinicalEarlyHuman,
|
||||
claim: "personalized entrainment optimization",
|
||||
envelope: SafetyEnvelope::conservative(),
|
||||
prior: StimulusParameters::prior(),
|
||||
weights: ObjectiveWeights::default(),
|
||||
eligible_states: &[SleepState::QuietWake, SleepState::Drowsy],
|
||||
time_preference: TimePreference::QuietWake,
|
||||
}
|
||||
}
|
||||
|
||||
/// Post-stroke cognition — recovery state tracking. Comfort-leaning, gentle
|
||||
/// onset (ramped), short sessions; recovery populations tolerate less.
|
||||
pub fn post_stroke_cognition() -> Self {
|
||||
let mut prior = StimulusParameters::prior();
|
||||
prior.duty_cycle = DutyCycle::Ramped;
|
||||
prior.brightness_level = 0.25;
|
||||
prior.volume_level = 0.24;
|
||||
prior.duration_minutes = 8.0;
|
||||
let mut weights = ObjectiveWeights::default();
|
||||
weights.comfort = 0.20;
|
||||
weights.breathing_stability = 0.15;
|
||||
weights.gamma_gain = 0.25;
|
||||
weights.overstimulation = 0.15;
|
||||
Self {
|
||||
id: "post-stroke-cognition",
|
||||
display_name: "Post-Stroke Cognition (recovery state tracking)",
|
||||
evidence_level: EvidenceLevel::EarlyHuman,
|
||||
claim: "personalized entrainment optimization with recovery-state monitoring",
|
||||
envelope: SafetyEnvelope::conservative()
|
||||
.with_caps(0.32, 0.32)
|
||||
.and_then(|e| e.with_max_duration_minutes(12.0))
|
||||
.expect("post-stroke envelope is within the absolute bounds"),
|
||||
prior,
|
||||
weights,
|
||||
eligible_states: &[SleepState::QuietWake, SleepState::Drowsy],
|
||||
time_preference: TimePreference::Morning,
|
||||
}
|
||||
}
|
||||
|
||||
/// Sleep optimization — time stimulation to sleep state. Permits `Drowsy`/
|
||||
/// `Asleep`, lowest intensity caps (must not degrade sleep), weights calm
|
||||
/// physiology over raw gamma.
|
||||
pub fn sleep_optimization() -> Self {
|
||||
let mut prior = StimulusParameters::prior();
|
||||
prior.modality = Modality::Audio; // light flicker is disruptive at sleep
|
||||
prior.brightness_level = 0.0;
|
||||
prior.volume_level = 0.18;
|
||||
prior.duty_cycle = DutyCycle::Ramped;
|
||||
prior.duration_minutes = 15.0;
|
||||
let mut weights = ObjectiveWeights::default();
|
||||
weights.gamma_gain = 0.20;
|
||||
weights.phase_locking = 0.20;
|
||||
weights.breathing_stability = 0.25; // calm sleep physiology is the point
|
||||
weights.comfort = 0.15;
|
||||
weights.overstimulation = 0.20;
|
||||
Self {
|
||||
id: "sleep-optimization",
|
||||
display_name: "Sleep Optimization (state-timed gamma)",
|
||||
evidence_level: EvidenceLevel::EarlyHuman,
|
||||
claim: "sleep-state-timed entrainment optimization",
|
||||
envelope: SafetyEnvelope::conservative()
|
||||
.with_caps(0.10, 0.25) // near-dark
|
||||
.and_then(|e| e.with_max_duration_minutes(30.0))
|
||||
.expect("sleep envelope is within the absolute bounds"),
|
||||
prior,
|
||||
weights,
|
||||
eligible_states: &[
|
||||
SleepState::Drowsy,
|
||||
SleepState::Asleep,
|
||||
SleepState::QuietWake,
|
||||
],
|
||||
time_preference: TimePreference::PreSleepOrSleep,
|
||||
}
|
||||
}
|
||||
|
||||
/// Attention & working memory — personal frequency discovery. Evidence is
|
||||
/// mixed/protocol-dependent, so this program leans hardest on *entrainment*
|
||||
/// terms (find the individual's responsive frequency) under wakefulness.
|
||||
pub fn attention_working_memory() -> Self {
|
||||
let mut weights = ObjectiveWeights::default();
|
||||
weights.gamma_gain = 0.35;
|
||||
weights.phase_locking = 0.30;
|
||||
weights.comfort = 0.10;
|
||||
Self {
|
||||
id: "attention-working-memory",
|
||||
display_name: "Attention & Working Memory (personal frequency discovery)",
|
||||
evidence_level: EvidenceLevel::Mixed,
|
||||
claim: "personalized frequency-response discovery",
|
||||
envelope: SafetyEnvelope::conservative(),
|
||||
prior: StimulusParameters::prior(),
|
||||
weights,
|
||||
eligible_states: &[SleepState::QuietWake, SleepState::Active],
|
||||
time_preference: TimePreference::QuietWake,
|
||||
}
|
||||
}
|
||||
|
||||
/// Mood & arousal regulation — avoid overstimulation, tune the calming
|
||||
/// response. Lowest gamma weight, highest comfort + overstimulation penalty.
|
||||
pub fn mood_arousal() -> Self {
|
||||
let mut prior = StimulusParameters::prior();
|
||||
prior.brightness_level = 0.22;
|
||||
prior.volume_level = 0.22;
|
||||
prior.duty_cycle = DutyCycle::Ramped;
|
||||
let mut weights = ObjectiveWeights::default();
|
||||
weights.gamma_gain = 0.20;
|
||||
weights.phase_locking = 0.15;
|
||||
weights.comfort = 0.25;
|
||||
weights.breathing_stability = 0.20;
|
||||
weights.overstimulation = 0.20;
|
||||
Self {
|
||||
id: "mood-arousal",
|
||||
display_name: "Mood & Arousal Regulation (calming-response tuning)",
|
||||
evidence_level: EvidenceLevel::EarlyHuman,
|
||||
claim: "personalized calming-response optimization",
|
||||
envelope: SafetyEnvelope::conservative()
|
||||
.with_caps(0.30, 0.30)
|
||||
.expect("mood-arousal envelope is within the absolute bounds"),
|
||||
prior,
|
||||
weights,
|
||||
eligible_states: &[SleepState::QuietWake, SleepState::Drowsy],
|
||||
time_preference: TimePreference::Evening,
|
||||
}
|
||||
}
|
||||
|
||||
/// Home neuro-wellness — safe personalization without treatment claims. The
|
||||
/// most conservative envelope and the shortest sessions; speculative
|
||||
/// evidence, so the claim is explicitly wellness-only.
|
||||
pub fn home_wellness() -> Self {
|
||||
let mut prior = StimulusParameters::prior();
|
||||
prior.brightness_level = 0.20;
|
||||
prior.volume_level = 0.20;
|
||||
prior.duration_minutes = 6.0;
|
||||
Self {
|
||||
id: "home-wellness",
|
||||
display_name: "Home Neuro-Wellness (no treatment claim)",
|
||||
evidence_level: EvidenceLevel::Speculative,
|
||||
claim: "personal neural-rhythm wellness optimization",
|
||||
envelope: SafetyEnvelope::conservative()
|
||||
.with_caps(0.28, 0.28)
|
||||
.and_then(|e| e.with_max_duration_minutes(10.0))
|
||||
.expect("home-wellness envelope is within the absolute bounds"),
|
||||
prior,
|
||||
weights: ObjectiveWeights::default(),
|
||||
eligible_states: &[SleepState::QuietWake],
|
||||
time_preference: TimePreference::Any,
|
||||
}
|
||||
}
|
||||
|
||||
/// Drug-plus-device trial infrastructure — the strongest near-term use. The
|
||||
/// value is the *governed measurement layer* (RuView state + adherence,
|
||||
/// RuVector response curve, RuFlo protocol/safety/consent/sham log), so the
|
||||
/// claim is about a biomarker-correlated protocol layer, not therapy.
|
||||
pub fn trial_infrastructure() -> Self {
|
||||
Self {
|
||||
id: "trial-infrastructure",
|
||||
display_name: "Drug+Device Trial Infrastructure (governed protocol layer)",
|
||||
evidence_level: EvidenceLevel::StrongInfrastructure,
|
||||
claim: "governed, reproducible entrainment-protocol measurement",
|
||||
envelope: SafetyEnvelope::conservative(),
|
||||
prior: StimulusParameters::prior(),
|
||||
weights: ObjectiveWeights::default(),
|
||||
eligible_states: &[SleepState::QuietWake, SleepState::Drowsy],
|
||||
time_preference: TimePreference::Any,
|
||||
}
|
||||
}
|
||||
|
||||
/// Every built-in program — for catalog UIs and the acceptance test matrix.
|
||||
pub fn catalog() -> Vec<NeuroProgram> {
|
||||
vec![
|
||||
Self::alzheimers_research(),
|
||||
Self::post_stroke_cognition(),
|
||||
Self::sleep_optimization(),
|
||||
Self::attention_working_memory(),
|
||||
Self::mood_arousal(),
|
||||
Self::home_wellness(),
|
||||
Self::trial_infrastructure(),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn every_program_prior_is_inside_its_envelope() {
|
||||
for p in NeuroProgram::catalog() {
|
||||
assert!(
|
||||
p.envelope.contains(&p.prior),
|
||||
"program {} prior must be inside its envelope",
|
||||
p.id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_program_claim_is_a_disease_treatment_claim() {
|
||||
let banned = [
|
||||
"treat",
|
||||
"cure",
|
||||
"alzheimer",
|
||||
"stroke recovery cure",
|
||||
"therapy for",
|
||||
];
|
||||
for p in NeuroProgram::catalog() {
|
||||
let claim = p.claim.to_lowercase();
|
||||
for b in banned {
|
||||
assert!(
|
||||
!claim.contains(b),
|
||||
"program {} claim '{}' contains banned term '{}'",
|
||||
p.id,
|
||||
p.claim,
|
||||
b
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn objective_weights_are_well_formed() {
|
||||
for p in NeuroProgram::catalog() {
|
||||
let w = &p.weights;
|
||||
for v in [
|
||||
w.gamma_gain,
|
||||
w.phase_locking,
|
||||
w.breathing_stability,
|
||||
w.adherence,
|
||||
w.comfort,
|
||||
w.motion_artifact,
|
||||
w.adverse_event_risk,
|
||||
w.overstimulation,
|
||||
] {
|
||||
assert!((0.0..=1.0).contains(&v));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sleep_program_permits_asleep_others_do_not() {
|
||||
assert!(NeuroProgram::sleep_optimization().state_eligible(SleepState::Asleep));
|
||||
assert!(!NeuroProgram::attention_working_memory().state_eligible(SleepState::Asleep));
|
||||
assert!(!NeuroProgram::alzheimers_research().state_eligible(SleepState::Asleep));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sleep_program_caps_brightness_near_dark() {
|
||||
assert!(NeuroProgram::sleep_optimization().envelope.brightness_cap() <= 0.10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn program_ids_are_unique() {
|
||||
let cat = NeuroProgram::catalog();
|
||||
let mut ids: Vec<&str> = cat.iter().map(|p| p.id).collect();
|
||||
ids.sort_unstable();
|
||||
let n = ids.len();
|
||||
ids.dedup();
|
||||
assert_eq!(ids.len(), n);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alzheimers_program_matches_adr250_defaults() {
|
||||
// The default-enroll path must be unchanged (witness stability).
|
||||
let p = NeuroProgram::alzheimers_research();
|
||||
assert_eq!(p.envelope, SafetyEnvelope::conservative());
|
||||
assert_eq!(p.prior, StimulusParameters::prior());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
//! Deterministic proof bundle (mirrors the `nvsim` / `verify.py` pattern).
|
||||
//!
|
||||
//! Runs a fixed reference participant through the full governed pipeline
|
||||
//! (enroll → calibration sweep → witnessed audit) and hashes the resulting
|
||||
//! session witnesses into a single bundle digest. If the digest matches
|
||||
//! [`Proof::EXPECTED_WITNESS`], the optimizer math, simulator physics, response
|
||||
//! update, and session-hashing code paths are all byte-identical to the
|
||||
//! published reference. Any silent drift in any of them shifts the digest and
|
||||
//! the test fails loudly.
|
||||
|
||||
use crate::response::RuViewState;
|
||||
use crate::ruflo::{Consent, RufloGovernor};
|
||||
use crate::simulator::{stable_hash, LatentPerson, ResponseSimulator};
|
||||
use crate::stimulus::SafetyEnvelope;
|
||||
|
||||
/// Deterministic-proof harness for `ruview-gamma`.
|
||||
pub struct Proof;
|
||||
|
||||
impl Proof {
|
||||
/// Reference participant id (drives the latent physiology).
|
||||
pub const PERSON_ID: &'static str = "reference-subject-000";
|
||||
|
||||
/// Reference simulator seed.
|
||||
pub const SEED: u64 = 42;
|
||||
|
||||
/// SHA-256 (hex) over the concatenated session witnesses of the reference
|
||||
/// calibration run. Pinned so CI catches any drift.
|
||||
pub const EXPECTED_WITNESS: &'static str =
|
||||
"13cb164cc3b3b02da8cdfbb5c23fdd07431c58498396d75a3d9a470305981758";
|
||||
|
||||
/// Run the reference scenario and return its bundle witness (hex SHA-256).
|
||||
pub fn reference_witness() -> String {
|
||||
let envelope = SafetyEnvelope::conservative();
|
||||
let mut gov = RufloGovernor::enroll(Self::PERSON_ID, envelope, &[], Consent::Granted)
|
||||
.expect("reference participant enrolls cleanly");
|
||||
let sim = ResponseSimulator::new(Self::SEED);
|
||||
let latent = LatentPerson::from_id(Self::PERSON_ID);
|
||||
let state = RuViewState::calm_baseline();
|
||||
gov.run_calibration(&sim, &latent, &state, 5.0, 1_700_000_000_000)
|
||||
.expect("reference calibration runs");
|
||||
|
||||
// Concatenate every session witness in order, then hash the bundle.
|
||||
let mut chunks: Vec<&[u8]> = Vec::new();
|
||||
for rec in gov.audit_log() {
|
||||
chunks.push(rec.session_hash.as_bytes());
|
||||
}
|
||||
let digest = stable_hash(&chunks);
|
||||
hex(&digest)
|
||||
}
|
||||
}
|
||||
|
||||
fn hex(bytes: &[u8]) -> String {
|
||||
let mut s = String::with_capacity(bytes.len() * 2);
|
||||
for b in bytes {
|
||||
s.push_str(&format!("{:02x}", b));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn reference_witness_is_deterministic() {
|
||||
assert_eq!(Proof::reference_witness(), Proof::reference_witness());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_witness_matches_expected() {
|
||||
// If this fails after an intentional change, regenerate the constant
|
||||
// from the test output and document the change in CHANGELOG.
|
||||
assert_eq!(Proof::reference_witness(), Proof::EXPECTED_WITNESS);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
//! Personal response vector and session inputs (ADR-250 §6, §9, §10).
|
||||
//!
|
||||
//! This is the RuVector layer's data model. The 20-field
|
||||
//! [`PersonResponseVector`] is the compact adaptive memory updated after each
|
||||
//! session via [`PersonResponseVector::update`]
|
||||
//! (`R_{t+1} = update(R_t, stimulus_t, response_t, safety_t)`, ADR-250 §6).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::math::clamp_safe;
|
||||
use crate::stimulus::StimulusParameters;
|
||||
|
||||
/// Coarse posture class from RuView passive sensing (ADR-250 §9).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Posture {
|
||||
Seated,
|
||||
Reclined,
|
||||
Supine,
|
||||
Standing,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// Coarse sleep / arousal proxy (ADR-250 §9 item 7).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SleepState {
|
||||
QuietWake,
|
||||
Drowsy,
|
||||
Asleep,
|
||||
Active,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// Passive, non-camera RuView state for a session (ADR-250 §9, §13
|
||||
/// `ruview_state`). All scores are `[0,1]` unless noted.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RuViewState {
|
||||
/// Breaths per minute.
|
||||
pub breathing_rate: f64,
|
||||
/// Regularity of the breathing waveform `[0,1]`.
|
||||
pub breathing_stability: f64,
|
||||
/// Fraction of frames corrupted by motion `[0,1]` (lower is better).
|
||||
pub motion_artifact: f64,
|
||||
pub posture: Posture,
|
||||
/// Fraction of the session the body was still `[0,1]`.
|
||||
pub stillness_score: f64,
|
||||
/// Fidgeting / restlessness `[0,1]`.
|
||||
pub restlessness_score: f64,
|
||||
pub sleep_state: SleepState,
|
||||
/// Person was present for the session `[0,1]` adherence proxy.
|
||||
pub adherence: f64,
|
||||
/// Aggregate RuView sensing confidence `[0,1]`.
|
||||
pub sensor_confidence: f64,
|
||||
}
|
||||
|
||||
impl RuViewState {
|
||||
/// A calm, well-sensed seated baseline — used as a neutral default in
|
||||
/// simulation and tests.
|
||||
pub fn calm_baseline() -> Self {
|
||||
Self {
|
||||
breathing_rate: 13.0,
|
||||
breathing_stability: 0.85,
|
||||
motion_artifact: 0.05,
|
||||
posture: Posture::Seated,
|
||||
stillness_score: 0.9,
|
||||
restlessness_score: 0.1,
|
||||
sleep_state: SleepState::QuietWake,
|
||||
adherence: 1.0,
|
||||
sensor_confidence: 0.9,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Optional direct EEG entrainment measurement (ADR-250 §13 `eeg_optional`).
|
||||
/// When absent, the optimizer relies on RF-derived proxies only and must not
|
||||
/// claim verified neural entrainment (ADR-250 §16 negative consequence 3).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub struct EegMeasurement {
|
||||
/// Gamma-band power gain over baseline `[0,1]`.
|
||||
pub gamma_power_gain: f64,
|
||||
/// Phase-locking value `[0,1]`.
|
||||
pub phase_locking_value: f64,
|
||||
/// EEG artifact fraction `[0,1]` (lower is better).
|
||||
pub artifact_score: f64,
|
||||
}
|
||||
|
||||
/// Participant-reported subjective state (ADR-250 §13 `subjective`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SubjectiveReport {
|
||||
pub comfort: f64,
|
||||
pub fatigue: f64,
|
||||
}
|
||||
|
||||
impl Default for SubjectiveReport {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
comfort: 0.85,
|
||||
fatigue: 0.2,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything observed during one session, the input to the response update.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SessionObservation {
|
||||
pub stimulus: StimulusParameters,
|
||||
pub ruview: RuViewState,
|
||||
pub eeg: Option<EegMeasurement>,
|
||||
pub subjective: SubjectiveReport,
|
||||
/// `false` if any safety stop fired this session.
|
||||
pub safety_pass: bool,
|
||||
/// `true` if an adverse event was recorded (sticky into the vector).
|
||||
pub adverse_event: bool,
|
||||
}
|
||||
|
||||
/// The 20-field adaptive personal response vector (ADR-250 §6). Stored as named
|
||||
/// fields for clarity; [`as_array`](Self::as_array) gives the flat ordered
|
||||
/// representation used for nearest-neighbor and clustering in RuVector.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub struct PersonResponseVector {
|
||||
pub baseline_gamma: f64,
|
||||
pub baseline_alpha: f64,
|
||||
pub alpha_gamma_ratio: f64,
|
||||
pub gamma_power_gain: f64,
|
||||
pub phase_locking_value: f64,
|
||||
pub breathing_rate: f64,
|
||||
pub breathing_stability: f64,
|
||||
pub motion_artifact: f64,
|
||||
/// Posture encoded as an ordinal `[0,1]` for vector math.
|
||||
pub posture_state: f64,
|
||||
/// Sleep state encoded as an ordinal `[0,1]`.
|
||||
pub sleep_state: f64,
|
||||
pub restlessness_score: f64,
|
||||
pub stimulus_frequency: f64,
|
||||
pub brightness_level: f64,
|
||||
pub sound_level: f64,
|
||||
pub duty_cycle: f64,
|
||||
pub phase_offset: f64,
|
||||
pub session_duration: f64,
|
||||
pub comfort_score: f64,
|
||||
pub adherence_score: f64,
|
||||
/// 1.0 once any adverse event has ever occurred for this person (sticky).
|
||||
pub adverse_event_flag: f64,
|
||||
}
|
||||
|
||||
impl PersonResponseVector {
|
||||
/// Exponential-moving-average weight for session-to-session updates. Small
|
||||
/// so a single noisy session cannot dominate (ADR-250 §16 risk
|
||||
/// "Over-optimization", mitigation "conservative priors").
|
||||
pub const EMA_ALPHA: f64 = 0.3;
|
||||
|
||||
/// Initialize from a baseline reading before any stimulation.
|
||||
pub fn baseline(baseline_gamma: f64, baseline_alpha: f64, ruview: &RuViewState) -> Self {
|
||||
let ratio = if baseline_gamma > 1e-9 {
|
||||
baseline_alpha / baseline_gamma
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
Self {
|
||||
baseline_gamma,
|
||||
baseline_alpha,
|
||||
alpha_gamma_ratio: ratio,
|
||||
gamma_power_gain: 0.0,
|
||||
phase_locking_value: 0.0,
|
||||
breathing_rate: ruview.breathing_rate,
|
||||
breathing_stability: ruview.breathing_stability,
|
||||
motion_artifact: ruview.motion_artifact,
|
||||
posture_state: posture_ordinal(ruview.posture),
|
||||
sleep_state: sleep_ordinal(ruview.sleep_state),
|
||||
restlessness_score: ruview.restlessness_score,
|
||||
stimulus_frequency: 40.0,
|
||||
brightness_level: 0.0,
|
||||
sound_level: 0.0,
|
||||
duty_cycle: 0.0,
|
||||
phase_offset: 0.0,
|
||||
session_duration: 0.0,
|
||||
comfort_score: 0.85,
|
||||
adherence_score: ruview.adherence,
|
||||
adverse_event_flag: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Flat ordered array (ADR-250 §6 field order) for RuVector similarity ops.
|
||||
pub fn as_array(&self) -> [f64; 20] {
|
||||
[
|
||||
self.baseline_gamma,
|
||||
self.baseline_alpha,
|
||||
self.alpha_gamma_ratio,
|
||||
self.gamma_power_gain,
|
||||
self.phase_locking_value,
|
||||
self.breathing_rate,
|
||||
self.breathing_stability,
|
||||
self.motion_artifact,
|
||||
self.posture_state,
|
||||
self.sleep_state,
|
||||
self.restlessness_score,
|
||||
self.stimulus_frequency,
|
||||
self.brightness_level,
|
||||
self.sound_level,
|
||||
self.duty_cycle,
|
||||
self.phase_offset,
|
||||
self.session_duration,
|
||||
self.comfort_score,
|
||||
self.adherence_score,
|
||||
self.adverse_event_flag,
|
||||
]
|
||||
}
|
||||
|
||||
/// `R_{t+1} = update(R_t, stimulus_t, response_t, safety_t)` (ADR-250 §6).
|
||||
///
|
||||
/// EMA-blends the continuous response fields toward the latest observation;
|
||||
/// the adverse-event flag is *sticky* (monotonic 0→1) so a person's safety
|
||||
/// history can never be smoothed away.
|
||||
pub fn update(&mut self, obs: &SessionObservation) {
|
||||
let a = Self::EMA_ALPHA;
|
||||
let blend = |old: f64, new: f64| old + a * (new - old);
|
||||
|
||||
if let Some(eeg) = obs.eeg {
|
||||
self.gamma_power_gain = blend(self.gamma_power_gain, eeg.gamma_power_gain);
|
||||
self.phase_locking_value = blend(self.phase_locking_value, eeg.phase_locking_value);
|
||||
}
|
||||
self.breathing_rate = blend(self.breathing_rate, obs.ruview.breathing_rate);
|
||||
self.breathing_stability = blend(self.breathing_stability, obs.ruview.breathing_stability);
|
||||
self.motion_artifact = blend(self.motion_artifact, obs.ruview.motion_artifact);
|
||||
self.posture_state = posture_ordinal(obs.ruview.posture);
|
||||
self.sleep_state = sleep_ordinal(obs.ruview.sleep_state);
|
||||
self.restlessness_score = blend(self.restlessness_score, obs.ruview.restlessness_score);
|
||||
self.stimulus_frequency = obs.stimulus.frequency_hz;
|
||||
self.brightness_level = obs.stimulus.brightness_level;
|
||||
self.sound_level = obs.stimulus.volume_level;
|
||||
self.duty_cycle = duty_ordinal(obs.stimulus.duty_cycle);
|
||||
self.phase_offset = obs.stimulus.phase_offset_ms;
|
||||
self.session_duration = obs.stimulus.duration_minutes;
|
||||
self.comfort_score = blend(self.comfort_score, obs.subjective.comfort);
|
||||
self.adherence_score = blend(self.adherence_score, obs.ruview.adherence);
|
||||
if obs.adverse_event {
|
||||
self.adverse_event_flag = 1.0; // sticky
|
||||
}
|
||||
// Keep all `[0,1]` fields well-formed regardless of upstream noise.
|
||||
self.clamp_unit_fields();
|
||||
}
|
||||
|
||||
fn clamp_unit_fields(&mut self) {
|
||||
for f in [
|
||||
&mut self.gamma_power_gain,
|
||||
&mut self.phase_locking_value,
|
||||
&mut self.breathing_stability,
|
||||
&mut self.motion_artifact,
|
||||
&mut self.restlessness_score,
|
||||
&mut self.comfort_score,
|
||||
&mut self.adherence_score,
|
||||
] {
|
||||
*f = clamp_safe(*f, 0.0, 1.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn posture_ordinal(p: Posture) -> f64 {
|
||||
match p {
|
||||
Posture::Standing => 0.0,
|
||||
Posture::Seated => 0.33,
|
||||
Posture::Reclined => 0.66,
|
||||
Posture::Supine => 1.0,
|
||||
Posture::Unknown => 0.5,
|
||||
}
|
||||
}
|
||||
|
||||
fn sleep_ordinal(s: SleepState) -> f64 {
|
||||
match s {
|
||||
SleepState::Active => 0.0,
|
||||
SleepState::QuietWake => 0.33,
|
||||
SleepState::Drowsy => 0.66,
|
||||
SleepState::Asleep => 1.0,
|
||||
SleepState::Unknown => 0.5,
|
||||
}
|
||||
}
|
||||
|
||||
fn duty_ordinal(d: crate::stimulus::DutyCycle) -> f64 {
|
||||
match d {
|
||||
crate::stimulus::DutyCycle::Continuous => 0.0,
|
||||
crate::stimulus::DutyCycle::Ramped => 0.5,
|
||||
crate::stimulus::DutyCycle::Pulsed => 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::stimulus::StimulusParameters;
|
||||
|
||||
fn obs_with_adverse(adverse: bool) -> SessionObservation {
|
||||
SessionObservation {
|
||||
stimulus: StimulusParameters::prior(),
|
||||
ruview: RuViewState::calm_baseline(),
|
||||
eeg: Some(EegMeasurement {
|
||||
gamma_power_gain: 0.4,
|
||||
phase_locking_value: 0.6,
|
||||
artifact_score: 0.05,
|
||||
}),
|
||||
subjective: SubjectiveReport::default(),
|
||||
safety_pass: !adverse,
|
||||
adverse_event: adverse,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vector_has_twenty_fields() {
|
||||
let v = PersonResponseVector::baseline(0.2, 0.5, &RuViewState::calm_baseline());
|
||||
assert_eq!(v.as_array().len(), 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_moves_gamma_toward_observation() {
|
||||
let mut v = PersonResponseVector::baseline(0.2, 0.5, &RuViewState::calm_baseline());
|
||||
let before = v.gamma_power_gain;
|
||||
v.update(&obs_with_adverse(false));
|
||||
assert!(v.gamma_power_gain > before);
|
||||
assert!(v.gamma_power_gain < 0.4); // EMA, not a jump to the target
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adverse_flag_is_sticky() {
|
||||
let mut v = PersonResponseVector::baseline(0.2, 0.5, &RuViewState::calm_baseline());
|
||||
v.update(&obs_with_adverse(true));
|
||||
assert_eq!(v.adverse_event_flag, 1.0);
|
||||
// A subsequent clean session must NOT clear the flag.
|
||||
v.update(&obs_with_adverse(false));
|
||||
assert_eq!(v.adverse_event_flag, 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unit_fields_stay_bounded_under_noise() {
|
||||
let mut v = PersonResponseVector::baseline(0.2, 0.5, &RuViewState::calm_baseline());
|
||||
let mut obs = obs_with_adverse(false);
|
||||
obs.eeg = Some(EegMeasurement {
|
||||
gamma_power_gain: 99.0,
|
||||
phase_locking_value: -5.0,
|
||||
artifact_score: 0.0,
|
||||
});
|
||||
v.update(&obs);
|
||||
assert!(v.gamma_power_gain <= 1.0);
|
||||
assert!(v.phase_locking_value >= 0.0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,489 @@
|
||||
//! RuFlo governance layer (ADR-250 §11).
|
||||
//!
|
||||
//! The governor is the only public entry point that *runs* sessions. It
|
||||
//! enforces, in order: consent → inclusion/exclusion screen → envelope check →
|
||||
//! simulated/observed session → safety monitor → objective score → RuVector
|
||||
//! update → witnessed audit record. It also owns trial-mode separation (sham /
|
||||
//! blinding) and the **claim-discipline** statement (ADR-250 §18: "no disease
|
||||
//! treatment claim").
|
||||
|
||||
use crate::objective::{SafeEntrainmentObjective, ScoreInputs};
|
||||
use crate::optimizer::{BayesianOptimizer, CalibrationPlan, Recommendation};
|
||||
use crate::participant::{
|
||||
lock_class_for, stop_tag, DoseLimits, DoseViolation, LockClass, ParticipantSafetyState,
|
||||
};
|
||||
use crate::program::NeuroProgram;
|
||||
use crate::response::{
|
||||
PersonResponseVector, RuViewState, SessionObservation, SleepState, SubjectiveReport,
|
||||
};
|
||||
use crate::ruvector::{DriftDetector, DriftStatus};
|
||||
use crate::safety::{
|
||||
ExclusionCondition, ExclusionScreen, SafetyMonitor, SafetyTick, ScreenOutcome, StopReason,
|
||||
};
|
||||
use crate::session::{Outcome, SessionBuilder, SessionRecord, VersionTriple};
|
||||
use crate::simulator::{LatentPerson, ResponseSimulator};
|
||||
use crate::stimulus::{SafetyEnvelope, StimulusParameters};
|
||||
|
||||
/// The single, immutable product claim (ADR-250 §22). Exposed so any UI/report
|
||||
/// can render exactly this and nothing stronger.
|
||||
pub const PRODUCT_CLAIM: &str = "personalized entrainment optimization";
|
||||
|
||||
/// Consent state (ADR-250 §11 RuFlo responsibility 2).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Consent {
|
||||
Granted,
|
||||
Withdrawn,
|
||||
}
|
||||
|
||||
/// Trial mode for controlled studies (ADR-250 §21 Milestone 6).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TrialMode {
|
||||
/// Normal operation: real stimulation, adaptive optimization.
|
||||
Open,
|
||||
/// Sham: the participant-facing protocol is logged, but no entrainment is
|
||||
/// delivered (blinding). Outcomes show the no-treatment baseline.
|
||||
Sham,
|
||||
}
|
||||
|
||||
/// Governance refusals — every one is a *safe* refusal (fail closed).
|
||||
#[derive(Debug, thiserror::Error, PartialEq)]
|
||||
pub enum GovernanceError {
|
||||
#[error("participant is excluded from unsupervised use: {0:?}")]
|
||||
Excluded(Vec<ExclusionCondition>),
|
||||
#[error("clinical supervision required for: {0:?}")]
|
||||
SupervisionRequired(Vec<ExclusionCondition>),
|
||||
#[error("consent not granted (or withdrawn)")]
|
||||
NoConsent,
|
||||
#[error("requested stimulus is outside the approved safety envelope")]
|
||||
OutsideEnvelope,
|
||||
/// Finding 2: the participant is latched-locked after an adverse-event /
|
||||
/// distress / seizure-like stop (ADR-250 §8 terminate-and-lock). Only
|
||||
/// [`RufloGovernor::unlock_with_acknowledgment`] clears it.
|
||||
#[error(
|
||||
"participant is locked ({class:?} — {reason}); operator acknowledgment required to resume"
|
||||
)]
|
||||
ParticipantLocked { class: LockClass, reason: String },
|
||||
/// Finding 4: the rolling-24h daily-dose cap is exhausted.
|
||||
#[error("daily dose limit reached: {sittings} sittings in 24h exceeds the cap of {max}")]
|
||||
DailyDoseLimit { sittings: usize, max: usize },
|
||||
/// Finding 4: the minimum inter-session cooldown has not yet elapsed.
|
||||
#[error("inter-session cooldown active: {elapsed_minutes:.1} min elapsed, {required_minutes:.1} min required")]
|
||||
CooldownActive {
|
||||
elapsed_minutes: f64,
|
||||
required_minutes: f64,
|
||||
},
|
||||
/// [`RufloGovernor::unlock_with_acknowledgment`] was called but the
|
||||
/// participant was not locked.
|
||||
#[error("participant is not locked")]
|
||||
NotLocked,
|
||||
}
|
||||
|
||||
// Clinician export (`ClinicianReport` + `clinician_report`) lives in the child
|
||||
// module `report` (split out to keep this file under 500 lines); it is a
|
||||
// descendant module, so it retains access to the governor's private fields.
|
||||
#[path = "ruflo_report.rs"]
|
||||
mod report;
|
||||
pub use report::ClinicianReport;
|
||||
|
||||
// RuVector cohort bridge (`seed_from_cohort`, `export_anonymized_profile`),
|
||||
// split out to keep this file under 500 lines; descendant module → private access.
|
||||
#[path = "ruflo_ruvector.rs"]
|
||||
mod ruvector_bridge;
|
||||
|
||||
/// The governed adaptive-gamma protocol runner for one participant.
|
||||
pub struct RufloGovernor {
|
||||
person_id: String,
|
||||
envelope: SafetyEnvelope,
|
||||
objective: SafeEntrainmentObjective,
|
||||
optimizer: BayesianOptimizer,
|
||||
response: PersonResponseVector,
|
||||
versions: VersionTriple,
|
||||
consent: Consent,
|
||||
mode: TrialMode,
|
||||
confidence_floor: f64,
|
||||
audit: Vec<SessionRecord>,
|
||||
next_index: u64,
|
||||
// ADR-250 §10 item 4: per-person drift detection over the response vector.
|
||||
drift: DriftDetector,
|
||||
drift_status: DriftStatus,
|
||||
// Finding 2 & 4: persisted cross-session safety state (latched lock + the
|
||||
// dose/cooldown ledger). Serialize it into the session store so a NEW
|
||||
// governor for the same participant honors the lock and the dose history.
|
||||
safety_state: ParticipantSafetyState,
|
||||
dose_limits: DoseLimits,
|
||||
// Platform extension: the program this participant is enrolled under
|
||||
// (envelope/prior/objective/state-gating/claim). `None` for the bare
|
||||
// `enroll` path (Alzheimer's defaults), which keeps the pinned witness.
|
||||
program: Option<NeuroProgram>,
|
||||
}
|
||||
|
||||
impl RufloGovernor {
|
||||
/// Enroll a participant. Fails closed on exclusion or missing consent.
|
||||
pub fn enroll(
|
||||
person_id: impl Into<String>,
|
||||
envelope: SafetyEnvelope,
|
||||
conditions: &[ExclusionCondition],
|
||||
consent: Consent,
|
||||
) -> Result<Self, GovernanceError> {
|
||||
if consent != Consent::Granted {
|
||||
return Err(GovernanceError::NoConsent);
|
||||
}
|
||||
match ExclusionScreen.evaluate(conditions) {
|
||||
ScreenOutcome::Excluded(c) => return Err(GovernanceError::Excluded(c)),
|
||||
ScreenOutcome::RequiresClinicalSupervision(c) => {
|
||||
return Err(GovernanceError::SupervisionRequired(c))
|
||||
}
|
||||
ScreenOutcome::Cleared => {}
|
||||
}
|
||||
let baseline_ruview = RuViewState::calm_baseline();
|
||||
Ok(Self {
|
||||
person_id: person_id.into(),
|
||||
objective: SafeEntrainmentObjective::new(Default::default(), envelope),
|
||||
optimizer: BayesianOptimizer::default(),
|
||||
response: PersonResponseVector::baseline(0.2, 0.5, &baseline_ruview),
|
||||
versions: VersionTriple::default(),
|
||||
consent,
|
||||
mode: TrialMode::Open,
|
||||
confidence_floor: 0.5,
|
||||
envelope,
|
||||
audit: Vec::new(),
|
||||
next_index: 0,
|
||||
drift: DriftDetector::default(),
|
||||
drift_status: DriftStatus::Warmup,
|
||||
safety_state: ParticipantSafetyState::default(),
|
||||
dose_limits: DoseLimits::conservative(),
|
||||
program: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Minimum number of distinct cohort profiles required before
|
||||
/// [`seed_from_cohort`](Self::seed_from_cohort) will consume their priors — a
|
||||
/// privacy k-floor so a single (or pair of) donor profile(s) cannot shape a
|
||||
/// new participant's optimizer.
|
||||
pub const MIN_COHORT_PROFILES: usize = 3;
|
||||
|
||||
/// Enroll a participant under a [`NeuroProgram`] (the platform path): the
|
||||
/// program supplies the safety envelope, starting prior, and objective
|
||||
/// weighting for this use case. Same fail-closed consent/exclusion gate as
|
||||
/// [`enroll`](Self::enroll). The program's claim is only releasable through
|
||||
/// the acceptance gate (`crate::acceptance`), never directly.
|
||||
pub fn enroll_program(
|
||||
person_id: impl Into<String>,
|
||||
program: NeuroProgram,
|
||||
conditions: &[ExclusionCondition],
|
||||
consent: Consent,
|
||||
) -> Result<Self, GovernanceError> {
|
||||
let mut gov = Self::enroll(person_id, program.envelope, conditions, consent)?;
|
||||
gov.objective = SafeEntrainmentObjective::new(program.weights, program.envelope);
|
||||
gov.versions.protocol_version = format!("adr-250-{}-v0.1", program.id);
|
||||
gov.program = Some(program);
|
||||
Ok(gov)
|
||||
}
|
||||
|
||||
/// The program this participant is enrolled under, if any.
|
||||
pub fn program(&self) -> Option<&NeuroProgram> {
|
||||
self.program.as_ref()
|
||||
}
|
||||
|
||||
/// The program's starting prior, or the ADR-250 40 Hz prior if none.
|
||||
pub fn prior(&self) -> StimulusParameters {
|
||||
self.program
|
||||
.as_ref()
|
||||
.map(|p| p.prior)
|
||||
.unwrap_or_else(StimulusParameters::prior)
|
||||
}
|
||||
|
||||
/// Whether a session in `state` fits the enrolled program's protocol
|
||||
/// (e.g. the sleep program permits `Asleep`). Programs without state
|
||||
/// constraints (the bare path) accept any state.
|
||||
pub fn state_eligible(&self, state: SleepState) -> bool {
|
||||
self.program
|
||||
.as_ref()
|
||||
.map(|p| p.state_eligible(state))
|
||||
.unwrap_or(true)
|
||||
}
|
||||
|
||||
// `seed_from_cohort` + `export_anonymized_profile` (the RuVector cohort
|
||||
// bridge, ADR-250 §10) live in the child module `ruvector_bridge` to keep
|
||||
// this file under 500 lines; it retains private-field access.
|
||||
|
||||
/// Latest drift judgment (ADR-250 §10 item 4). `Drifted` recommends
|
||||
/// re-running the Phase-1 calibration sweep before trusting further
|
||||
/// optimization.
|
||||
pub fn drift_status(&self) -> DriftStatus {
|
||||
self.drift_status
|
||||
}
|
||||
|
||||
/// The persisted per-participant safety state (latched lock + dose ledger,
|
||||
/// Finding 2 & 4). Serialize it into the session store; reload it into a new
|
||||
/// governor with [`with_safety_state`](Self::with_safety_state) and the lock
|
||||
/// and dose history are honored across instances.
|
||||
pub fn safety_state(&self) -> &ParticipantSafetyState {
|
||||
&self.safety_state
|
||||
}
|
||||
|
||||
/// This participant's safety envelope.
|
||||
pub fn envelope(&self) -> &SafetyEnvelope {
|
||||
&self.envelope
|
||||
}
|
||||
|
||||
/// Whether the participant is currently latched-locked.
|
||||
pub fn is_locked(&self) -> bool {
|
||||
self.safety_state.is_locked()
|
||||
}
|
||||
|
||||
/// Load a previously-persisted safety state onto a fresh governor (e.g. for a
|
||||
/// returning participant). A locked state makes this governor refuse to run
|
||||
/// sessions until [`unlock_with_acknowledgment`](Self::unlock_with_acknowledgment).
|
||||
pub fn with_safety_state(mut self, state: ParticipantSafetyState) -> Self {
|
||||
self.safety_state = state;
|
||||
self
|
||||
}
|
||||
|
||||
/// Override the dose/cooldown policy (Finding 4). Defaults to
|
||||
/// [`DoseLimits::conservative`]; real deployments must keep a conservative
|
||||
/// policy — the permissive variant exists only for time-compressed
|
||||
/// simulation.
|
||||
pub fn with_dose_limits(mut self, limits: DoseLimits) -> Self {
|
||||
self.dose_limits = limits;
|
||||
self
|
||||
}
|
||||
|
||||
/// Lift a latched lock with an explicit operator acknowledgment, writing an
|
||||
/// audit record into the persisted safety state (ADR-250 §8 / §11, Finding 2).
|
||||
///
|
||||
/// # Errors
|
||||
/// [`GovernanceError::NotLocked`] if the participant was not locked.
|
||||
pub fn unlock_with_acknowledgment(
|
||||
&mut self,
|
||||
operator_note: &str,
|
||||
timestamp_ms: u64,
|
||||
) -> Result<(), GovernanceError> {
|
||||
match self.safety_state.unlock(operator_note, timestamp_ms) {
|
||||
Some(_) => Ok(()),
|
||||
None => Err(GovernanceError::NotLocked),
|
||||
}
|
||||
}
|
||||
|
||||
/// Switch trial mode (e.g., to `Sham` for a blinded arm).
|
||||
pub fn set_mode(&mut self, mode: TrialMode) {
|
||||
self.mode = mode;
|
||||
}
|
||||
|
||||
/// Withdraw consent — all subsequent `run_session` calls fail closed.
|
||||
pub fn withdraw_consent(&mut self) {
|
||||
self.consent = Consent::Withdrawn;
|
||||
}
|
||||
|
||||
/// Immutable view of the audit trail (every session is witnessed).
|
||||
pub fn audit_log(&self) -> &[SessionRecord] {
|
||||
&self.audit
|
||||
}
|
||||
|
||||
/// Current personal response vector (RuVector memory).
|
||||
pub fn response_vector(&self) -> &PersonResponseVector {
|
||||
&self.response
|
||||
}
|
||||
|
||||
/// Run the Phase-1 calibration sweep against a simulated participant,
|
||||
/// recording every session and seeding the optimizer.
|
||||
pub fn run_calibration(
|
||||
&mut self,
|
||||
sim: &ResponseSimulator,
|
||||
latent: &LatentPerson,
|
||||
state: &RuViewState,
|
||||
session_minutes: f64,
|
||||
base_timestamp_ms: u64,
|
||||
) -> Result<(), GovernanceError> {
|
||||
let mut plan = CalibrationPlan::new(&self.envelope);
|
||||
while let Some(stim) = plan.next_stimulus(&self.envelope, session_minutes) {
|
||||
// Finding 1: a safety stop is a control-flow event. `run_session`
|
||||
// still returns the (witnessed) record on a stop, so we must inspect
|
||||
// its safety outcome and TERMINATE the sweep — a stop in step N must
|
||||
// not let steps N+1.. proceed. The partial calibration stays in the
|
||||
// audit log, and any lock-warranting stop has already engaged the
|
||||
// governor lock (ADR-250 §8 terminate-and-lock).
|
||||
let record = self.run_session(sim, latent, state, &stim, base_timestamp_ms)?;
|
||||
if !record.outcome.safety_pass {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Recommend the next protocol given the current state (ADR-250 §14).
|
||||
pub fn recommend(&self, base: &StimulusParameters) -> Recommendation {
|
||||
self.optimizer.recommend(&self.envelope, base)
|
||||
}
|
||||
|
||||
/// Run one governed session end-to-end. Returns the witnessed record.
|
||||
///
|
||||
/// Fails closed if consent is absent or the stimulus is outside the
|
||||
/// envelope. Any safety stop is logged into the record (ADR-250 §18).
|
||||
pub fn run_session(
|
||||
&mut self,
|
||||
sim: &ResponseSimulator,
|
||||
latent: &LatentPerson,
|
||||
state: &RuViewState,
|
||||
stimulus: &StimulusParameters,
|
||||
timestamp_ms: u64,
|
||||
) -> Result<SessionRecord, GovernanceError> {
|
||||
if self.consent != Consent::Granted {
|
||||
return Err(GovernanceError::NoConsent);
|
||||
}
|
||||
// Finding 2: a locked participant refuses every session (fail closed)
|
||||
// until an explicit operator acknowledgment lifts the lock. This holds
|
||||
// across governor instances because the lock lives in `safety_state`,
|
||||
// which is serialized into the session store.
|
||||
if let Some(lock) = self.safety_state.lock_record() {
|
||||
return Err(GovernanceError::ParticipantLocked {
|
||||
class: lock.class,
|
||||
reason: lock.reason_tag.clone(),
|
||||
});
|
||||
}
|
||||
if !self.envelope.contains(stimulus) {
|
||||
return Err(GovernanceError::OutsideEnvelope);
|
||||
}
|
||||
// Finding 4: daily-dose cap + inter-sitting cooldown. Same-timestamp
|
||||
// calibration sub-sessions are one sitting and pass freely; a new
|
||||
// sitting must clear both the cooldown and the daily cap.
|
||||
if let Err(v) = self
|
||||
.safety_state
|
||||
.check_admission(timestamp_ms, &self.dose_limits)
|
||||
{
|
||||
return Err(match v {
|
||||
DoseViolation::DailyLimit { sittings, max } => {
|
||||
GovernanceError::DailyDoseLimit { sittings, max }
|
||||
}
|
||||
DoseViolation::Cooldown {
|
||||
elapsed_minutes,
|
||||
required_minutes,
|
||||
} => GovernanceError::CooldownActive {
|
||||
elapsed_minutes,
|
||||
required_minutes,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
let idx = self.next_index;
|
||||
self.next_index += 1;
|
||||
// Commit this sitting to the dose ledger (calibration steps included).
|
||||
self.safety_state.record_session(timestamp_ms);
|
||||
|
||||
// --- Observe (simulated) aggregate response (the scoring source). ---
|
||||
let mut resp = sim.simulate(latent, state, stimulus, idx);
|
||||
if self.mode == TrialMode::Sham {
|
||||
// Blinding: no entrainment is actually delivered.
|
||||
resp.eeg.gamma_power_gain *= 0.05;
|
||||
resp.eeg.phase_locking_value *= 0.05;
|
||||
}
|
||||
|
||||
// --- Finding 5: per-tick safety monitor over the WHOLE session. Every
|
||||
// tick is evaluated and the monitor latches; a mid-session latch
|
||||
// truncates the session at that tick. A clean session produces no
|
||||
// events (byte-identical witness to the prior single-summary path,
|
||||
// since for a clean run the only tick signal is the unchanged sensor
|
||||
// confidence). This makes the <500 ms stop-latency contract apply to
|
||||
// the integrated path, at least in simulation.
|
||||
let mut monitor = SafetyMonitor::new(self.confidence_floor);
|
||||
let ticks = sim.session_ticks(latent, state, stimulus, idx);
|
||||
let n_ticks = ticks.len().max(1);
|
||||
let mut safety_events = Vec::new();
|
||||
let mut stop_tick: Option<usize> = None;
|
||||
for (i, t) in ticks.iter().enumerate() {
|
||||
if let Some(stop) = monitor.evaluate(SafetyTick {
|
||||
adverse: t.adverse,
|
||||
sensor_confidence: t.sensor_confidence,
|
||||
stimulus_in_envelope: true,
|
||||
}) {
|
||||
safety_events.push(stop);
|
||||
stop_tick = Some(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
let safety_pass = !safety_events.iter().any(StopReason::is_safety_stop);
|
||||
let adverse_event = resp.adverse_event
|
||||
|| safety_events
|
||||
.iter()
|
||||
.any(|s| matches!(s, StopReason::AdverseEvent(_)));
|
||||
|
||||
// The stimulus *as delivered*: a mid-session latch truncates the
|
||||
// duration to the completed fraction (Finding 5). For a clean session
|
||||
// this equals the planned stimulus, so the witness is unchanged.
|
||||
let recorded_stimulus = if let Some(t) = stop_tick {
|
||||
let fraction = (t + 1) as f64 / n_ticks as f64;
|
||||
let mut truncated = *stimulus;
|
||||
truncated.duration_minutes = stimulus.duration_minutes * fraction;
|
||||
self.envelope.clamp(truncated)
|
||||
} else {
|
||||
*stimulus
|
||||
};
|
||||
|
||||
// Finding 2: terminate-and-lock — engage the latched lock if the stop
|
||||
// warrants it (persisted, so a new governor for this participant also
|
||||
// refuses until acknowledged).
|
||||
if let Some(stop) = safety_events.iter().find(|s| s.is_safety_stop()) {
|
||||
if let Some(class) = lock_class_for(stop) {
|
||||
self.safety_state
|
||||
.engage_lock(class, stop_tag(stop), timestamp_ms);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Score the session. ---
|
||||
let subjective = SubjectiveReport {
|
||||
comfort: resp.comfort,
|
||||
fatigue: 0.2,
|
||||
};
|
||||
let score = self.objective.score(&ScoreInputs {
|
||||
stimulus,
|
||||
ruview: &resp.ruview,
|
||||
eeg: Some(&resp.eeg),
|
||||
subjective: &subjective,
|
||||
adverse_event_risk: if adverse_event { 1.0 } else { 0.0 },
|
||||
});
|
||||
|
||||
// --- Feed the optimizer only when the session was safe. ---
|
||||
if safety_pass {
|
||||
self.optimizer.observe(stimulus.frequency_hz, score);
|
||||
}
|
||||
|
||||
// --- Update RuVector memory + drift detection (ADR-250 §10 item 4). ---
|
||||
self.response.update(&SessionObservation {
|
||||
stimulus: recorded_stimulus,
|
||||
ruview: resp.ruview,
|
||||
eeg: Some(resp.eeg),
|
||||
subjective,
|
||||
safety_pass,
|
||||
adverse_event,
|
||||
});
|
||||
self.drift_status = self.drift.update(&self.response.as_array());
|
||||
|
||||
// --- Recommend next frequency for the record. ---
|
||||
let next = self.optimizer.recommend(&self.envelope, stimulus);
|
||||
|
||||
// --- Witnessed audit record. ---
|
||||
let record = SessionBuilder::new(
|
||||
self.person_id.clone(),
|
||||
self.versions.clone(),
|
||||
timestamp_ms,
|
||||
recorded_stimulus,
|
||||
resp.ruview,
|
||||
subjective,
|
||||
Outcome {
|
||||
entrainment_score: score,
|
||||
safety_pass,
|
||||
recommended_next_frequency_hz: next.stimulus.frequency_hz,
|
||||
},
|
||||
)
|
||||
.with_eeg(resp.eeg)
|
||||
.with_safety_events(safety_events)
|
||||
.finalize();
|
||||
|
||||
self.audit.push(record.clone());
|
||||
Ok(record)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "ruflo_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,52 @@
|
||||
//! Clinician export for [`RufloGovernor`] (ADR-250 §11 responsibility 9, §17).
|
||||
//!
|
||||
//! Split out of `ruflo.rs` to keep that file under 500 lines. This is a child
|
||||
//! module of `ruflo`, so it retains access to the governor's private fields.
|
||||
|
||||
use super::{RufloGovernor, PRODUCT_CLAIM};
|
||||
|
||||
/// Clinician-facing export summary (ADR-250 §11 responsibility 9, §17).
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ClinicianReport {
|
||||
pub person_id: String,
|
||||
pub n_sessions: usize,
|
||||
pub n_safety_stops: usize,
|
||||
pub best_frequency_hz: Option<f64>,
|
||||
pub mean_entrainment: f64,
|
||||
pub adverse_event_recorded: bool,
|
||||
/// Whether the participant is currently latched-locked (Finding 2).
|
||||
pub locked: bool,
|
||||
pub claim: &'static str,
|
||||
}
|
||||
|
||||
impl RufloGovernor {
|
||||
/// Build the clinician export (ADR-250 §11 responsibility 9).
|
||||
pub fn clinician_report(&self) -> ClinicianReport {
|
||||
let n = self.audit.len();
|
||||
let n_stops = self
|
||||
.audit
|
||||
.iter()
|
||||
.flat_map(|r| &r.safety_events)
|
||||
.filter(|e| e.is_safety_stop())
|
||||
.count();
|
||||
let mean = if n > 0 {
|
||||
self.audit
|
||||
.iter()
|
||||
.map(|r| r.outcome.entrainment_score)
|
||||
.sum::<f64>()
|
||||
/ n as f64
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
ClinicianReport {
|
||||
person_id: self.person_id.clone(),
|
||||
n_sessions: n,
|
||||
n_safety_stops: n_stops,
|
||||
best_frequency_hz: self.optimizer.best().map(|(f, _)| f),
|
||||
mean_entrainment: mean,
|
||||
adverse_event_recorded: self.response.adverse_event_flag >= 1.0,
|
||||
locked: self.safety_state.is_locked(),
|
||||
claim: PRODUCT_CLAIM,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
//! RuVector cohort bridge for [`RufloGovernor`] (ADR-250 §10 items 3/6).
|
||||
//!
|
||||
//! Split out of `ruflo.rs` to keep it under 500 lines. This is a child module
|
||||
//! of `ruflo`, so it retains access to the governor's private fields.
|
||||
|
||||
use super::RufloGovernor;
|
||||
use crate::ruvector::{AnonymizedProfile, ProfileStore};
|
||||
|
||||
impl RufloGovernor {
|
||||
/// Seed the optimizer from a cohort of anonymized similar responders
|
||||
/// (ADR-250 §10 item 3): the `k` nearest profiles' frequency responses enter
|
||||
/// as **down-weighted pseudo-observations**, shaping where the optimizer
|
||||
/// looks first without ever counting as this person's measured data. Returns
|
||||
/// how many priors were installed.
|
||||
///
|
||||
/// Honors the privacy k-floor [`RufloGovernor::MIN_COHORT_PROFILES`]: a
|
||||
/// cohort smaller than that yields no priors at all.
|
||||
pub fn seed_from_cohort(&mut self, store: &ProfileStore, k: usize) -> usize {
|
||||
if store.len() < Self::MIN_COHORT_PROFILES {
|
||||
return 0;
|
||||
}
|
||||
let query = self.response.as_array();
|
||||
let priors = store.warm_start_prior(&query, k, self.optimizer.noise_var);
|
||||
for p in &priors {
|
||||
// Only frequencies inside this participant's envelope are usable.
|
||||
if p.frequency_hz >= self.envelope.min_hz() && p.frequency_hz <= self.envelope.max_hz()
|
||||
{
|
||||
self.optimizer
|
||||
.observe_prior(p.frequency_hz, p.expected_score, p.noise_var);
|
||||
}
|
||||
}
|
||||
priors.len()
|
||||
}
|
||||
|
||||
/// Export this participant as an anonymized profile for the cohort store
|
||||
/// (ADR-250 §10 items 3/6). Carries the one-way hashed tag, the response
|
||||
/// vector, and per-frequency scores from **safe sessions only** — never the
|
||||
/// `person_id`, never raw sensor data.
|
||||
pub fn export_anonymized_profile(&self) -> AnonymizedProfile {
|
||||
let frequency_scores: Vec<(f64, f64)> = self
|
||||
.audit
|
||||
.iter()
|
||||
.filter(|r| r.outcome.safety_pass)
|
||||
.map(|r| (r.stimulus.frequency_hz, r.outcome.entrainment_score))
|
||||
.collect();
|
||||
AnonymizedProfile {
|
||||
profile_tag: AnonymizedProfile::tag_for(&self.person_id),
|
||||
vector: self.response.as_array(),
|
||||
frequency_scores,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
//! Tests for the RuFlo governor. Split out of `ruflo.rs` (kept under 500 lines);
|
||||
//! this is a child module of `ruflo`, so it retains access to private fields.
|
||||
|
||||
use super::*;
|
||||
use crate::safety::AdverseEvent;
|
||||
use crate::simulator::{FaultSchedule, InjectedFault};
|
||||
|
||||
fn governor() -> RufloGovernor {
|
||||
RufloGovernor::enroll(
|
||||
"subject-A",
|
||||
SafetyEnvelope::conservative(),
|
||||
&[],
|
||||
Consent::Granted,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enroll_refuses_without_consent() {
|
||||
let r = RufloGovernor::enroll("x", SafetyEnvelope::conservative(), &[], Consent::Withdrawn);
|
||||
assert_eq!(r.err(), Some(GovernanceError::NoConsent));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enroll_refuses_excluded_condition() {
|
||||
let r = RufloGovernor::enroll(
|
||||
"x",
|
||||
SafetyEnvelope::conservative(),
|
||||
&[ExclusionCondition::EpilepsyOrSeizureHistory],
|
||||
Consent::Granted,
|
||||
);
|
||||
assert!(matches!(r, Err(GovernanceError::Excluded(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enroll_requires_supervision_for_migraine() {
|
||||
let r = RufloGovernor::enroll(
|
||||
"x",
|
||||
SafetyEnvelope::conservative(),
|
||||
&[ExclusionCondition::SevereMigraineSensitivity],
|
||||
Consent::Granted,
|
||||
);
|
||||
assert!(matches!(r, Err(GovernanceError::SupervisionRequired(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_session_refuses_out_of_envelope_stimulus() {
|
||||
let mut g = governor();
|
||||
let sim = ResponseSimulator::new(1);
|
||||
let latent = LatentPerson::from_id("subject-A");
|
||||
let state = RuViewState::calm_baseline();
|
||||
let mut bad = StimulusParameters::prior();
|
||||
bad.frequency_hz = 60.0;
|
||||
let r = g.run_session(&sim, &latent, &state, &bad, 0);
|
||||
assert_eq!(r.err(), Some(GovernanceError::OutsideEnvelope));
|
||||
assert!(g.audit_log().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn withdrawn_consent_blocks_further_sessions() {
|
||||
let mut g = governor();
|
||||
let sim = ResponseSimulator::new(1);
|
||||
let latent = LatentPerson::from_id("subject-A");
|
||||
let state = RuViewState::calm_baseline();
|
||||
g.run_session(&sim, &latent, &state, &StimulusParameters::prior(), 0)
|
||||
.unwrap();
|
||||
g.withdraw_consent();
|
||||
let r = g.run_session(&sim, &latent, &state, &StimulusParameters::prior(), 1);
|
||||
assert_eq!(r.err(), Some(GovernanceError::NoConsent));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calibration_then_recommendation_lands_near_latent_peak() {
|
||||
let mut g = governor();
|
||||
let sim = ResponseSimulator::new(99);
|
||||
let latent = LatentPerson::from_id("subject-peak");
|
||||
let state = RuViewState::calm_baseline();
|
||||
g.run_calibration(&sim, &latent, &state, 5.0, 0).unwrap();
|
||||
let rec = g.recommend(&StimulusParameters::prior());
|
||||
assert!(g.envelope.contains(&rec.stimulus));
|
||||
// Optimizer should prefer a frequency within ±2 Hz of the true peak
|
||||
// (calibration is short/noisy; ±2 Hz is a robust bound for the test).
|
||||
assert!((rec.stimulus.frequency_hz - latent.peak_hz).abs() <= 2.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_session_is_witnessed_and_logged() {
|
||||
let mut g = governor();
|
||||
let sim = ResponseSimulator::new(5);
|
||||
let latent = LatentPerson::from_id("subject-A");
|
||||
let state = RuViewState::calm_baseline();
|
||||
g.run_calibration(&sim, &latent, &state, 5.0, 0).unwrap();
|
||||
assert_eq!(g.audit_log().len(), 9); // 36..44 Hz
|
||||
for rec in g.audit_log() {
|
||||
assert_eq!(rec.session_hash.len(), 64); // hex SHA-256
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sham_mode_suppresses_entrainment() {
|
||||
let latent = LatentPerson::from_id("subject-strong");
|
||||
let state = RuViewState::calm_baseline();
|
||||
let sim = ResponseSimulator::new(11);
|
||||
let mut peak = StimulusParameters::prior();
|
||||
peak.frequency_hz = (latent.peak_hz * 10.0).round() / 10.0;
|
||||
peak.frequency_hz = peak.frequency_hz.clamp(36.0, 44.0);
|
||||
|
||||
let mut open = governor();
|
||||
let open_rec = open.run_session(&sim, &latent, &state, &peak, 0).unwrap();
|
||||
|
||||
let mut sham = governor();
|
||||
sham.set_mode(TrialMode::Sham);
|
||||
let sham_rec = sham.run_session(&sim, &latent, &state, &peak, 0).unwrap();
|
||||
|
||||
let open_g = open_rec.eeg_optional.unwrap().gamma_power_gain;
|
||||
let sham_g = sham_rec.eeg_optional.unwrap().gamma_power_gain;
|
||||
assert!(sham_g < open_g);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clinician_report_uses_only_allowed_claim() {
|
||||
let g = governor();
|
||||
assert_eq!(g.clinician_report().claim, PRODUCT_CLAIM);
|
||||
assert!(!PRODUCT_CLAIM.to_lowercase().contains("alzheimer"));
|
||||
assert!(!PRODUCT_CLAIM.to_lowercase().contains("treat"));
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// Safety-hardening adversarial tests (2026-06-11 review).
|
||||
// ====================================================================
|
||||
|
||||
/// Finding 1: a safety stop is a control-flow event — the sweep halts at the
|
||||
/// stopping step rather than running every remaining frequency.
|
||||
#[test]
|
||||
fn sweep_halts_mid_calibration_on_safety_stop() {
|
||||
let env = SafetyEnvelope::conservative();
|
||||
let latent = LatentPerson::from_id("subject-A");
|
||||
let state = RuViewState::calm_baseline();
|
||||
// Low sensor confidence at calibration step 3 (tick 0) → confidence stop.
|
||||
// (A confidence stop is retryable and does NOT lock the participant.)
|
||||
let sim = ResponseSimulator::with_fault(
|
||||
1,
|
||||
FaultSchedule {
|
||||
at_session_index: 3,
|
||||
at_tick: 0,
|
||||
fault: InjectedFault::LowConfidence(0.1),
|
||||
},
|
||||
);
|
||||
let mut g = RufloGovernor::enroll("subject-A", env, &[], Consent::Granted).unwrap();
|
||||
g.run_calibration(&sim, &latent, &state, 5.0, 0).unwrap();
|
||||
// Steps 0,1,2 passed, step 3 stopped → 4 records (not the full 9-step sweep).
|
||||
assert_eq!(g.audit_log().len(), 4);
|
||||
assert!(!g.audit_log().last().unwrap().outcome.safety_pass);
|
||||
assert!(!g.is_locked());
|
||||
}
|
||||
|
||||
/// Findings 1 & 2: an adverse-event stop terminates the session AND latches the
|
||||
/// governor lock; a fresh governor loaded with the persisted state still
|
||||
/// refuses until an explicit operator acknowledgment (which is itself audited).
|
||||
#[test]
|
||||
fn adverse_event_terminates_and_locks_across_instances_until_acknowledged() {
|
||||
let env = SafetyEnvelope::conservative();
|
||||
let latent = LatentPerson::from_id("subject-A");
|
||||
let state = RuViewState::calm_baseline();
|
||||
let stim = StimulusParameters::prior(); // 10 min → 10 ticks
|
||||
let sim = ResponseSimulator::with_fault(
|
||||
1,
|
||||
FaultSchedule {
|
||||
at_session_index: 0,
|
||||
at_tick: 3,
|
||||
fault: InjectedFault::Adverse(AdverseEvent::SeizureLikeSymptom),
|
||||
},
|
||||
);
|
||||
let mut g = RufloGovernor::enroll("subject-A", env, &[], Consent::Granted).unwrap();
|
||||
// The session still returns its witnessed record (Finding 1), marked stopped.
|
||||
let rec = g.run_session(&sim, &latent, &state, &stim, 0).unwrap();
|
||||
assert!(!rec.outcome.safety_pass);
|
||||
// …and the governor is now latched-locked (Finding 2 terminate-and-lock).
|
||||
assert!(g.is_locked());
|
||||
assert_eq!(
|
||||
g.safety_state().lock_record().unwrap().class,
|
||||
LockClass::SeizureLike
|
||||
);
|
||||
|
||||
// Same governor refuses to start any further session.
|
||||
let clean = ResponseSimulator::new(2);
|
||||
let err = g
|
||||
.run_session(&clean, &latent, &state, &stim, 4_000_000)
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, GovernanceError::ParticipantLocked { .. }));
|
||||
|
||||
// Persistence: a NEW governor loaded with the serialized state also refuses.
|
||||
let persisted = g.safety_state().clone();
|
||||
let mut g2 = RufloGovernor::enroll("subject-A", env, &[], Consent::Granted)
|
||||
.unwrap()
|
||||
.with_safety_state(persisted);
|
||||
assert!(matches!(
|
||||
g2.run_session(&clean, &latent, &state, &stim, 4_000_000),
|
||||
Err(GovernanceError::ParticipantLocked { .. })
|
||||
));
|
||||
|
||||
// Unlock with acknowledgment writes an audit record and lifts the lock.
|
||||
assert!(matches!(
|
||||
g2.unlock_with_acknowledgment("not yet locked?", 0),
|
||||
Ok(())
|
||||
));
|
||||
assert!(!g2.is_locked());
|
||||
assert_eq!(g2.safety_state().unlock_audit().len(), 1);
|
||||
assert_eq!(
|
||||
g2.safety_state().unlock_audit()[0].cleared_class,
|
||||
LockClass::SeizureLike
|
||||
);
|
||||
// A redundant unlock is refused (nothing locked).
|
||||
assert!(matches!(
|
||||
g2.unlock_with_acknowledgment("again", 1),
|
||||
Err(GovernanceError::NotLocked)
|
||||
));
|
||||
// Now a clean session runs (far enough out to clear the cooldown).
|
||||
assert!(g2
|
||||
.run_session(&clean, &latent, &state, &stim, 4_000_000)
|
||||
.is_ok());
|
||||
}
|
||||
|
||||
/// Finding 4: inter-session cooldown blocks a too-soon second sitting.
|
||||
#[test]
|
||||
fn cooldown_blocks_a_too_soon_session() {
|
||||
let env = SafetyEnvelope::conservative();
|
||||
let latent = LatentPerson::from_id("subject-A");
|
||||
let state = RuViewState::calm_baseline();
|
||||
let stim = StimulusParameters::prior();
|
||||
let sim = ResponseSimulator::new(1);
|
||||
let mut g = RufloGovernor::enroll("subject-A", env, &[], Consent::Granted).unwrap();
|
||||
g.run_session(&sim, &latent, &state, &stim, 0).unwrap();
|
||||
// 30 min later — inside the 60 min cooldown.
|
||||
let err = g
|
||||
.run_session(&sim, &latent, &state, &stim, 30 * 60 * 1000)
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, GovernanceError::CooldownActive { .. }));
|
||||
}
|
||||
|
||||
/// Finding 4: the daily-dose cap is enforced across governor instances via the
|
||||
/// persisted ledger; calibration sittings count toward the budget.
|
||||
#[test]
|
||||
fn daily_dose_cap_enforced_across_governor_instances() {
|
||||
let env = SafetyEnvelope::conservative();
|
||||
let latent = LatentPerson::from_id("subject-A");
|
||||
let state = RuViewState::calm_baseline();
|
||||
let stim = StimulusParameters::prior();
|
||||
let sim = ResponseSimulator::new(1);
|
||||
let hour = 60 * 60 * 1000u64;
|
||||
let mut g = RufloGovernor::enroll("subject-A", env, &[], Consent::Granted).unwrap();
|
||||
// Four sittings, each 2 h apart (clears cooldown), all within 24 h.
|
||||
for i in 0..4u64 {
|
||||
g.run_session(&sim, &latent, &state, &stim, i * 2 * hour)
|
||||
.unwrap();
|
||||
}
|
||||
// Persist + reload into a NEW governor instance.
|
||||
let persisted = g.safety_state().clone();
|
||||
let mut g2 = RufloGovernor::enroll("subject-A", env, &[], Consent::Granted)
|
||||
.unwrap()
|
||||
.with_safety_state(persisted);
|
||||
// The fifth sitting within 24 h is refused by the reloaded instance.
|
||||
let err = g2
|
||||
.run_session(&sim, &latent, &state, &stim, 9 * hour)
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
err,
|
||||
GovernanceError::DailyDoseLimit {
|
||||
sittings: 5,
|
||||
max: 4
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
/// A calibration sweep delivered at a single timestamp is ONE dose unit, so it
|
||||
/// is not a backdoor around the daily cap (Finding 4 documented invariant).
|
||||
#[test]
|
||||
fn calibration_sweep_is_one_dose_sitting() {
|
||||
let env = SafetyEnvelope::conservative();
|
||||
let latent = LatentPerson::from_id("subject-A");
|
||||
let state = RuViewState::calm_baseline();
|
||||
let sim = ResponseSimulator::new(5);
|
||||
let mut g = RufloGovernor::enroll("subject-A", env, &[], Consent::Granted).unwrap();
|
||||
g.run_calibration(&sim, &latent, &state, 5.0, 1_700_000_000_000)
|
||||
.unwrap();
|
||||
assert_eq!(g.audit_log().len(), 9);
|
||||
assert_eq!(g.safety_state().sittings_in_window(1_700_000_000_000), 1);
|
||||
}
|
||||
|
||||
/// Finding 5: a per-tick latch mid-session truncates the recorded (delivered)
|
||||
/// stimulus to the completed fraction.
|
||||
#[test]
|
||||
fn mid_session_tick_latch_truncates_the_session() {
|
||||
let env = SafetyEnvelope::conservative();
|
||||
let latent = LatentPerson::from_id("subject-A");
|
||||
let state = RuViewState::calm_baseline();
|
||||
let stim = StimulusParameters::prior(); // 10 min → 10 ticks
|
||||
// Adverse at tick 2 → delivered fraction (2+1)/10 = 0.3 → 3.0 min.
|
||||
let sim = ResponseSimulator::with_fault(
|
||||
9,
|
||||
FaultSchedule {
|
||||
at_session_index: 0,
|
||||
at_tick: 2,
|
||||
fault: InjectedFault::Adverse(AdverseEvent::Headache),
|
||||
},
|
||||
);
|
||||
let mut g = RufloGovernor::enroll("subject-A", env, &[], Consent::Granted).unwrap();
|
||||
let rec = g.run_session(&sim, &latent, &state, &stim, 0).unwrap();
|
||||
assert!(!rec.outcome.safety_pass);
|
||||
assert!((rec.stimulus.duration_minutes - 3.0).abs() < 1e-9);
|
||||
assert!(rec.stimulus.duration_minutes < stim.duration_minutes);
|
||||
}
|
||||
|
||||
/// Minor: `seed_from_cohort` honors the privacy k-floor (≥ 3 distinct profiles).
|
||||
#[test]
|
||||
fn seed_from_cohort_respects_min_cohort_floor() {
|
||||
use crate::ruvector::{AnonymizedProfile, ProfileStore, VECTOR_DIM};
|
||||
let env = SafetyEnvelope::conservative();
|
||||
let mk = |tag: &str| {
|
||||
let mut vector = [0.5; VECTOR_DIM];
|
||||
vector[5] = 13.0; // breathing_rate in range
|
||||
vector[11] = 39.0; // stimulus_frequency
|
||||
AnonymizedProfile {
|
||||
profile_tag: tag.into(),
|
||||
vector,
|
||||
frequency_scores: vec![(39.0, 0.8)],
|
||||
}
|
||||
};
|
||||
let mut store = ProfileStore::new();
|
||||
store.upsert(mk("a"));
|
||||
store.upsert(mk("b"));
|
||||
let mut g = RufloGovernor::enroll("subject-A", env, &[], Consent::Granted).unwrap();
|
||||
// Below the floor → no priors consumed.
|
||||
assert_eq!(g.seed_from_cohort(&store, 2), 0);
|
||||
// At/above the floor → priors may be consumed.
|
||||
store.upsert(mk("c"));
|
||||
assert!(g.seed_from_cohort(&store, 3) > 0);
|
||||
}
|
||||
@@ -0,0 +1,551 @@
|
||||
//! RuVector self-learning layer (ADR-250 §10, items 3–6).
|
||||
//!
|
||||
//! The adaptive memory *across* people: anonymized 20-field response vectors
|
||||
//! ([`crate::response::PersonResponseVector::as_array`]) stored in a
|
||||
//! [`ProfileStore`], queried by deterministic k-nearest-neighbor to
|
||||
//! **warm-start a new person's optimizer** from similar responders (instead of
|
||||
//! the flat 40 Hz prior), plus per-person **drift detection** (item 4) and
|
||||
//! cohort **response clustering** (item 5).
|
||||
//!
|
||||
//! Privacy posture: profiles carry only a one-way hashed tag (never a
|
||||
//! `person_id`) and the 20 normalized response fields — no identity, no raw
|
||||
//! sensor data. Cohort knowledge enters the optimizer exclusively as
|
||||
//! down-weighted pseudo-observations ([`crate::optimizer::BayesianOptimizer::
|
||||
//! observe_prior`]) that can shape *where to look first* but never define what
|
||||
//! this person's measured response is.
|
||||
//!
|
||||
//! Everything here is deterministic: distances are fixed-range normalized,
|
||||
//! ties break by insertion index, clustering uses farthest-point seeding from
|
||||
//! index 0 — same inputs, same outputs, on every machine.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::math::clamp_safe;
|
||||
use crate::simulator::stable_hash;
|
||||
|
||||
/// Dimensionality of the response vector (ADR-250 §6).
|
||||
pub const VECTOR_DIM: usize = 20;
|
||||
|
||||
/// Fixed per-field normalization ranges `(lo, hi)` for distance computation,
|
||||
/// in the ADR-250 §6 field order. Constants (not data-derived statistics) so
|
||||
/// distances are stable as the store grows.
|
||||
pub const NORM_RANGES: [(f64, f64); VECTOR_DIM] = [
|
||||
(0.0, 1.0), // baseline_gamma
|
||||
(0.0, 1.0), // baseline_alpha
|
||||
(0.0, 5.0), // alpha_gamma_ratio
|
||||
(0.0, 1.0), // gamma_power_gain
|
||||
(0.0, 1.0), // phase_locking_value
|
||||
(6.0, 30.0), // breathing_rate (bpm)
|
||||
(0.0, 1.0), // breathing_stability
|
||||
(0.0, 1.0), // motion_artifact
|
||||
(0.0, 1.0), // posture_state
|
||||
(0.0, 1.0), // sleep_state
|
||||
(0.0, 1.0), // restlessness_score
|
||||
(36.0, 44.0), // stimulus_frequency (Hz)
|
||||
(0.0, 1.0), // brightness_level
|
||||
(0.0, 1.0), // sound_level
|
||||
(0.0, 1.0), // duty_cycle
|
||||
(-5.0, 5.0), // phase_offset (ms)
|
||||
(0.0, 15.0), // session_duration (min)
|
||||
(0.0, 1.0), // comfort_score
|
||||
(0.0, 1.0), // adherence_score
|
||||
(0.0, 1.0), // adverse_event_flag
|
||||
];
|
||||
|
||||
/// Normalize a raw response vector to the unit hypercube using
|
||||
/// [`NORM_RANGES`]. Non-finite fields clamp to the range floor.
|
||||
pub fn normalize(v: &[f64; VECTOR_DIM]) -> [f64; VECTOR_DIM] {
|
||||
let mut out = [0.0; VECTOR_DIM];
|
||||
for (i, (&val, &(lo, hi))) in v.iter().zip(NORM_RANGES.iter()).enumerate() {
|
||||
out[i] = clamp_safe((val - lo) / (hi - lo), 0.0, 1.0);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Euclidean distance between two normalized vectors.
|
||||
fn unit_distance(a: &[f64; VECTOR_DIM], b: &[f64; VECTOR_DIM]) -> f64 {
|
||||
a.iter()
|
||||
.zip(b)
|
||||
.map(|(x, y)| (x - y) * (x - y))
|
||||
.sum::<f64>()
|
||||
.sqrt()
|
||||
}
|
||||
|
||||
/// One anonymized responder profile: the hashed tag, the response vector, and
|
||||
/// the per-frequency scores their sessions established.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct AnonymizedProfile {
|
||||
/// One-way tag: first 16 hex chars of SHA-256("gamma-profile" ‖ person_id).
|
||||
/// Never the `person_id` itself.
|
||||
pub profile_tag: String,
|
||||
/// Raw (un-normalized) 20-field response vector.
|
||||
pub vector: [f64; VECTOR_DIM],
|
||||
/// `(frequency_hz, safe-entrainment score)` summaries from this profile's
|
||||
/// safe sessions — the transferable response surface.
|
||||
pub frequency_scores: Vec<(f64, f64)>,
|
||||
}
|
||||
|
||||
impl AnonymizedProfile {
|
||||
/// Derive the one-way profile tag from a pseudonymous person id.
|
||||
pub fn tag_for(person_id: &str) -> String {
|
||||
let h = stable_hash(&[b"gamma-profile", person_id.as_bytes()]);
|
||||
let mut s = String::with_capacity(16);
|
||||
for b in &h[..8] {
|
||||
s.push_str(&format!("{b:02x}"));
|
||||
}
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
/// A cohort prior for one frequency, produced by [`ProfileStore::warm_start_prior`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct CohortPrior {
|
||||
pub frequency_hz: f64,
|
||||
/// Similarity-weighted mean score across the k nearest profiles.
|
||||
pub expected_score: f64,
|
||||
/// Noise variance to attach to the pseudo-observation: grows with cohort
|
||||
/// disagreement and with distance, so dissimilar or conflicting cohorts
|
||||
/// are trusted less.
|
||||
pub noise_var: f64,
|
||||
}
|
||||
|
||||
/// Deterministic in-memory store of anonymized profiles.
|
||||
///
|
||||
/// Linear-scan kNN: exact, allocation-light, and fast at research-cohort scale
|
||||
/// (sub-µs at hundreds of profiles). An HNSW backend (the `ruvector` crates)
|
||||
/// is a drop-in replacement once cohorts grow past ~10⁵ — the public API is
|
||||
/// already shaped for it.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct ProfileStore {
|
||||
profiles: Vec<AnonymizedProfile>,
|
||||
}
|
||||
|
||||
impl ProfileStore {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Number of stored profiles.
|
||||
pub fn len(&self) -> usize {
|
||||
self.profiles.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.profiles.is_empty()
|
||||
}
|
||||
|
||||
/// Insert or replace (by `profile_tag`) a profile.
|
||||
pub fn upsert(&mut self, profile: AnonymizedProfile) {
|
||||
if let Some(p) = self
|
||||
.profiles
|
||||
.iter_mut()
|
||||
.find(|p| p.profile_tag == profile.profile_tag)
|
||||
{
|
||||
*p = profile;
|
||||
} else {
|
||||
self.profiles.push(profile);
|
||||
}
|
||||
}
|
||||
|
||||
/// k nearest profiles to `query` (raw vector) as `(index, distance)`,
|
||||
/// ascending distance, ties broken by insertion index (deterministic).
|
||||
pub fn k_nearest(&self, query: &[f64; VECTOR_DIM], k: usize) -> Vec<(usize, f64)> {
|
||||
let q = normalize(query);
|
||||
let mut d: Vec<(usize, f64)> = self
|
||||
.profiles
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, p)| (i, unit_distance(&q, &normalize(&p.vector))))
|
||||
.collect();
|
||||
d.sort_by(|a, b| {
|
||||
a.1.partial_cmp(&b.1)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
.then(a.0.cmp(&b.0))
|
||||
});
|
||||
d.truncate(k);
|
||||
d
|
||||
}
|
||||
|
||||
/// Profile by index (as returned from [`k_nearest`](Self::k_nearest)).
|
||||
pub fn profile(&self, idx: usize) -> Option<&AnonymizedProfile> {
|
||||
self.profiles.get(idx)
|
||||
}
|
||||
|
||||
/// Build cohort priors for a new person: for each integer frequency the
|
||||
/// k nearest profiles have scored, the similarity-weighted mean score and
|
||||
/// an honesty-scaled noise variance (ADR-250 §10 item 3 + item 6
|
||||
/// "protocol recommendation").
|
||||
///
|
||||
/// `base_noise` is the optimizer's real-observation noise; priors carry at
|
||||
/// least `PRIOR_NOISE_FLOOR ×` that, inflated further by cohort variance
|
||||
/// and mean neighbor distance.
|
||||
pub fn warm_start_prior(
|
||||
&self,
|
||||
query: &[f64; VECTOR_DIM],
|
||||
k: usize,
|
||||
base_noise: f64,
|
||||
) -> Vec<CohortPrior> {
|
||||
let neighbors = self.k_nearest(query, k);
|
||||
if neighbors.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
// Bucket scores by quantized frequency (0.1 Hz) across neighbors,
|
||||
// weighting each neighbor by 1/(1+distance).
|
||||
use std::collections::BTreeMap;
|
||||
let mut buckets: BTreeMap<i64, Vec<(f64, f64)>> = BTreeMap::new(); // q_hz -> (score, weight)
|
||||
for &(idx, dist) in &neighbors {
|
||||
let w = 1.0 / (1.0 + dist);
|
||||
for &(hz, score) in &self.profiles[idx].frequency_scores {
|
||||
if !hz.is_finite() || !score.is_finite() {
|
||||
continue;
|
||||
}
|
||||
let q = (hz * 10.0).round() as i64;
|
||||
buckets.entry(q).or_default().push((score, w));
|
||||
}
|
||||
}
|
||||
let mean_dist = neighbors.iter().map(|(_, d)| d).sum::<f64>() / neighbors.len() as f64;
|
||||
buckets
|
||||
.into_iter()
|
||||
.map(|(q, entries)| {
|
||||
let wsum: f64 = entries.iter().map(|(_, w)| w).sum();
|
||||
let mean: f64 = entries.iter().map(|(s, w)| s * w).sum::<f64>() / wsum;
|
||||
let var: f64 = entries
|
||||
.iter()
|
||||
.map(|(s, w)| w * (s - mean) * (s - mean))
|
||||
.sum::<f64>()
|
||||
/ wsum;
|
||||
CohortPrior {
|
||||
frequency_hz: q as f64 / 10.0,
|
||||
expected_score: mean,
|
||||
// Floor × base, inflated by cohort disagreement and distance.
|
||||
noise_var: base_noise * Self::PRIOR_NOISE_FLOOR * (1.0 + mean_dist) + var,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Minimum factor by which a cohort prior is noisier than a real
|
||||
/// observation (priors must never outweigh measured sessions).
|
||||
pub const PRIOR_NOISE_FLOOR: f64 = 25.0;
|
||||
|
||||
/// Deterministic k-means over normalized vectors (ADR-250 §10 item 5):
|
||||
/// farthest-point seeding from index 0, fixed `iters` Lloyd steps, ties to
|
||||
/// the lowest cluster index. Returns each profile's cluster assignment.
|
||||
/// Returns an empty vec if the store is empty or `k == 0`.
|
||||
pub fn cluster(&self, k: usize, iters: usize) -> Vec<usize> {
|
||||
let n = self.profiles.len();
|
||||
if n == 0 || k == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let k = k.min(n);
|
||||
let pts: Vec<[f64; VECTOR_DIM]> =
|
||||
self.profiles.iter().map(|p| normalize(&p.vector)).collect();
|
||||
|
||||
// Farthest-point initialization (deterministic, no RNG).
|
||||
let mut centers: Vec<[f64; VECTOR_DIM]> = vec![pts[0]];
|
||||
while centers.len() < k {
|
||||
let (far_idx, _) = pts
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, p)| {
|
||||
let dmin = centers
|
||||
.iter()
|
||||
.map(|c| unit_distance(p, c))
|
||||
.fold(f64::INFINITY, f64::min);
|
||||
(i, dmin)
|
||||
})
|
||||
.fold(
|
||||
(0usize, -1.0f64),
|
||||
|acc, (i, d)| if d > acc.1 { (i, d) } else { acc },
|
||||
);
|
||||
centers.push(pts[far_idx]);
|
||||
}
|
||||
|
||||
let mut assign = vec![0usize; n];
|
||||
for _ in 0..iters {
|
||||
// Assignment step.
|
||||
for (i, p) in pts.iter().enumerate() {
|
||||
let mut best = 0usize;
|
||||
let mut bd = f64::INFINITY;
|
||||
for (c, center) in centers.iter().enumerate() {
|
||||
let d = unit_distance(p, center);
|
||||
if d < bd {
|
||||
bd = d;
|
||||
best = c;
|
||||
}
|
||||
}
|
||||
assign[i] = best;
|
||||
}
|
||||
// Update step.
|
||||
for (c, center) in centers.iter_mut().enumerate() {
|
||||
let members: Vec<&[f64; VECTOR_DIM]> = pts
|
||||
.iter()
|
||||
.zip(&assign)
|
||||
.filter(|(_, &a)| a == c)
|
||||
.map(|(p, _)| p)
|
||||
.collect();
|
||||
if members.is_empty() {
|
||||
continue; // keep the old center (deterministic)
|
||||
}
|
||||
let mut mean = [0.0; VECTOR_DIM];
|
||||
for m in &members {
|
||||
for (dst, src) in mean.iter_mut().zip(m.iter()) {
|
||||
*dst += src;
|
||||
}
|
||||
}
|
||||
for dst in mean.iter_mut() {
|
||||
*dst /= members.len() as f64;
|
||||
}
|
||||
*center = mean;
|
||||
}
|
||||
}
|
||||
assign
|
||||
}
|
||||
}
|
||||
|
||||
/// Fields that participate in **drift** distance: the person's physiology and
|
||||
/// response, *not* the stimulus parameters (indices 11–16) — those are inputs
|
||||
/// the protocol changes deliberately (the calibration sweep swings frequency
|
||||
/// across the whole band) and must not register as the person drifting.
|
||||
pub const DRIFT_MASK: [bool; VECTOR_DIM] = [
|
||||
true, // baseline_gamma
|
||||
true, // baseline_alpha
|
||||
true, // alpha_gamma_ratio
|
||||
true, // gamma_power_gain
|
||||
true, // phase_locking_value
|
||||
true, // breathing_rate
|
||||
true, // breathing_stability
|
||||
true, // motion_artifact
|
||||
true, // posture_state
|
||||
true, // sleep_state
|
||||
true, // restlessness_score
|
||||
false, // stimulus_frequency (protocol input)
|
||||
false, // brightness_level (protocol input)
|
||||
false, // sound_level (protocol input)
|
||||
false, // duty_cycle (protocol input)
|
||||
false, // phase_offset (protocol input)
|
||||
false, // session_duration (protocol input)
|
||||
true, // comfort_score
|
||||
true, // adherence_score
|
||||
true, // adverse_event_flag
|
||||
];
|
||||
|
||||
/// Euclidean distance over the [`DRIFT_MASK`]-selected fields of two
|
||||
/// normalized vectors.
|
||||
fn drift_distance(a: &[f64; VECTOR_DIM], b: &[f64; VECTOR_DIM]) -> f64 {
|
||||
a.iter()
|
||||
.zip(b)
|
||||
.zip(DRIFT_MASK.iter())
|
||||
.filter(|(_, &m)| m)
|
||||
.map(|((x, y), _)| (x - y) * (x - y))
|
||||
.sum::<f64>()
|
||||
.sqrt()
|
||||
}
|
||||
|
||||
/// Drift status for one person (ADR-250 §10 item 4).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum DriftStatus {
|
||||
/// Not enough sessions yet to judge.
|
||||
Warmup,
|
||||
/// Latest vector is consistent with this person's running centroid.
|
||||
Stable,
|
||||
/// Latest vector departed from the centroid — recommend recalibration
|
||||
/// (re-run the Phase-1 sweep) before trusting further optimization.
|
||||
Drifted,
|
||||
}
|
||||
|
||||
/// Per-person drift detector: running mean (Welford) of the normalized
|
||||
/// response vector; a session whose [`DRIFT_MASK`]-restricted distance from
|
||||
/// the centroid exceeds `threshold` flags drift. Deterministic and O(1) per
|
||||
/// update.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DriftDetector {
|
||||
centroid: [f64; VECTOR_DIM],
|
||||
count: u64,
|
||||
/// Distance (in normalized space) above which a session counts as drifted.
|
||||
pub threshold: f64,
|
||||
/// Sessions required before drift can be judged.
|
||||
pub warmup: u64,
|
||||
}
|
||||
|
||||
impl Default for DriftDetector {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
centroid: [0.0; VECTOR_DIM],
|
||||
count: 0,
|
||||
threshold: 0.35,
|
||||
warmup: 3,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DriftDetector {
|
||||
/// Feed the post-session response vector; returns the drift judgment for
|
||||
/// this session. The centroid update happens *after* the judgment, so a
|
||||
/// drifted session is compared against the pre-drift baseline.
|
||||
pub fn update(&mut self, raw: &[f64; VECTOR_DIM]) -> DriftStatus {
|
||||
let v = normalize(raw);
|
||||
let status = if self.count < self.warmup {
|
||||
DriftStatus::Warmup
|
||||
} else if drift_distance(&v, &self.centroid) > self.threshold {
|
||||
DriftStatus::Drifted
|
||||
} else {
|
||||
DriftStatus::Stable
|
||||
};
|
||||
// Welford running-mean update.
|
||||
self.count += 1;
|
||||
let inv = 1.0 / self.count as f64;
|
||||
for (c, x) in self.centroid.iter_mut().zip(v.iter()) {
|
||||
*c += (x - *c) * inv;
|
||||
}
|
||||
status
|
||||
}
|
||||
|
||||
/// Sessions observed so far.
|
||||
pub fn sessions(&self) -> u64 {
|
||||
self.count
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn profile(tag: &str, freq: f64, peak_score: f64) -> AnonymizedProfile {
|
||||
let mut vector = [0.5; VECTOR_DIM];
|
||||
vector[5] = 13.0; // breathing_rate in range
|
||||
vector[11] = freq; // stimulus_frequency
|
||||
AnonymizedProfile {
|
||||
profile_tag: tag.into(),
|
||||
vector,
|
||||
frequency_scores: vec![
|
||||
(freq - 1.0, peak_score - 0.2),
|
||||
(freq, peak_score),
|
||||
(freq + 1.0, peak_score - 0.2),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tag_is_one_way_and_stable() {
|
||||
let t1 = AnonymizedProfile::tag_for("subject-A");
|
||||
let t2 = AnonymizedProfile::tag_for("subject-A");
|
||||
assert_eq!(t1, t2);
|
||||
assert_eq!(t1.len(), 16);
|
||||
assert!(!t1.contains("subject"));
|
||||
assert_ne!(t1, AnonymizedProfile::tag_for("subject-B"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn knn_orders_by_distance_with_deterministic_ties() {
|
||||
let mut store = ProfileStore::new();
|
||||
store.upsert(profile("a", 38.0, 0.8));
|
||||
store.upsert(profile("b", 42.0, 0.8));
|
||||
store.upsert(profile("c", 38.0, 0.8)); // identical vector to "a"
|
||||
|
||||
let mut q = [0.5; VECTOR_DIM];
|
||||
q[5] = 13.0;
|
||||
q[11] = 38.0;
|
||||
let nn = store.k_nearest(&q, 2);
|
||||
assert_eq!(nn.len(), 2);
|
||||
// "a" (index 0) and "c" (index 2) are equidistant; tie → lower index first.
|
||||
assert_eq!(nn[0].0, 0);
|
||||
assert_eq!(nn[1].0, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upsert_replaces_by_tag() {
|
||||
let mut store = ProfileStore::new();
|
||||
store.upsert(profile("a", 38.0, 0.8));
|
||||
store.upsert(profile("a", 42.0, 0.9));
|
||||
assert_eq!(store.len(), 1);
|
||||
assert_eq!(store.profile(0).unwrap().frequency_scores[1].0, 42.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warm_start_prior_is_noisier_than_real_observations() {
|
||||
let mut store = ProfileStore::new();
|
||||
store.upsert(profile("a", 39.0, 0.8));
|
||||
store.upsert(profile("b", 39.0, 0.7));
|
||||
let mut q = [0.5; VECTOR_DIM];
|
||||
q[5] = 13.0;
|
||||
q[11] = 39.0;
|
||||
let base_noise = 1e-4;
|
||||
let priors = store.warm_start_prior(&q, 2, base_noise);
|
||||
assert!(!priors.is_empty());
|
||||
for p in &priors {
|
||||
assert!(p.noise_var >= base_noise * ProfileStore::PRIOR_NOISE_FLOOR);
|
||||
assert!(p.expected_score.is_finite());
|
||||
}
|
||||
// The shared peak frequency carries the highest expected score.
|
||||
let best = priors
|
||||
.iter()
|
||||
.max_by(|a, b| a.expected_score.partial_cmp(&b.expected_score).unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(best.frequency_hz, 39.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warm_start_empty_store_returns_nothing() {
|
||||
let store = ProfileStore::new();
|
||||
let q = [0.5; VECTOR_DIM];
|
||||
assert!(store.warm_start_prior(&q, 3, 1e-4).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clustering_separates_detuned_groups() {
|
||||
let mut store = ProfileStore::new();
|
||||
// Two clear groups: peaks near 37 Hz and near 43 Hz.
|
||||
for i in 0..4 {
|
||||
store.upsert(profile(&format!("lo{i}"), 37.0, 0.8));
|
||||
store.upsert(profile(&format!("hi{i}"), 43.0, 0.8));
|
||||
}
|
||||
let assign = store.cluster(2, 10);
|
||||
assert_eq!(assign.len(), 8);
|
||||
// All "lo" profiles share a cluster, all "hi" share the other.
|
||||
let lo: Vec<usize> = (0..8).step_by(2).map(|i| assign[i]).collect();
|
||||
let hi: Vec<usize> = (1..8).step_by(2).map(|i| assign[i]).collect();
|
||||
assert!(lo.iter().all(|&c| c == lo[0]));
|
||||
assert!(hi.iter().all(|&c| c == hi[0]));
|
||||
assert_ne!(lo[0], hi[0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clustering_is_deterministic() {
|
||||
let mut store = ProfileStore::new();
|
||||
for i in 0..6 {
|
||||
store.upsert(profile(&format!("p{i}"), 36.0 + i as f64, 0.7));
|
||||
}
|
||||
assert_eq!(store.cluster(3, 5), store.cluster(3, 5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drift_detector_warmup_then_stable_then_drift() {
|
||||
let mut d = DriftDetector::default();
|
||||
let mut calm = [0.5; VECTOR_DIM];
|
||||
calm[5] = 13.0;
|
||||
// Warmup sessions.
|
||||
for _ in 0..3 {
|
||||
assert_eq!(d.update(&calm), DriftStatus::Warmup);
|
||||
}
|
||||
// Consistent sessions are stable.
|
||||
assert_eq!(d.update(&calm), DriftStatus::Stable);
|
||||
// A strongly departed vector flags drift.
|
||||
let mut shifted = calm;
|
||||
shifted[3] = 0.0; // gamma gain collapsed
|
||||
shifted[7] = 1.0; // motion artifact saturated
|
||||
shifted[10] = 1.0; // restlessness saturated
|
||||
shifted[17] = 0.0; // comfort collapsed
|
||||
assert_eq!(d.update(&shifted), DriftStatus::Drifted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_handles_non_finite() {
|
||||
let mut v = [0.5; VECTOR_DIM];
|
||||
v[0] = f64::NAN;
|
||||
v[5] = f64::INFINITY;
|
||||
let n = normalize(&v);
|
||||
assert_eq!(n[0], 0.0);
|
||||
assert_eq!(n[5], 0.0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
//! Safety stop conditions, exclusion screening, and the in-session monitor
|
||||
//! (ADR-250 §12).
|
||||
//!
|
||||
//! Safety is a **hard constraint, not a weighted term** (ADR-250 §7). This
|
||||
//! module owns the two non-negotiable gates:
|
||||
//! 1. [`ExclusionScreen`] — who may participate at all.
|
||||
//! 2. [`SafetyMonitor`] — when an in-progress session must stop.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Adverse symptoms / events that force an immediate hard stop (ADR-250 §12).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AdverseEvent {
|
||||
Headache,
|
||||
Dizziness,
|
||||
Nausea,
|
||||
Agitation,
|
||||
VisualDiscomfort,
|
||||
AbnormalDistress,
|
||||
SeizureLikeSymptom,
|
||||
/// The participant pressed stop.
|
||||
UserStopRequest,
|
||||
}
|
||||
|
||||
impl AdverseEvent {
|
||||
pub fn tag(self) -> &'static str {
|
||||
match self {
|
||||
AdverseEvent::Headache => "headache",
|
||||
AdverseEvent::Dizziness => "dizziness",
|
||||
AdverseEvent::Nausea => "nausea",
|
||||
AdverseEvent::Agitation => "agitation",
|
||||
AdverseEvent::VisualDiscomfort => "visual_discomfort",
|
||||
AdverseEvent::AbnormalDistress => "abnormal_distress",
|
||||
AdverseEvent::SeizureLikeSymptom => "seizure_like_symptom",
|
||||
AdverseEvent::UserStopRequest => "user_stop_request",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Conditions requiring exclusion or explicit clinical supervision
|
||||
/// (ADR-250 §12).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ExclusionCondition {
|
||||
EpilepsyOrSeizureHistory,
|
||||
Photosensitivity,
|
||||
SevereMigraineSensitivity,
|
||||
SeverePsychiatricInstability,
|
||||
ImplantedNeurologicalDevice,
|
||||
SignificantSensoryImpairment,
|
||||
RecentMedicationChange,
|
||||
}
|
||||
|
||||
/// Result of pre-enrollment screening.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum ScreenOutcome {
|
||||
/// Cleared for unsupervised research participation.
|
||||
Cleared,
|
||||
/// May proceed only under clinician supervision (records the conditions).
|
||||
RequiresClinicalSupervision(Vec<ExclusionCondition>),
|
||||
/// Hard-excluded (records the conditions).
|
||||
Excluded(Vec<ExclusionCondition>),
|
||||
}
|
||||
|
||||
/// Inclusion/exclusion screen (ADR-250 §11 RuFlo responsibility 3, §12).
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ExclusionScreen;
|
||||
|
||||
impl ExclusionScreen {
|
||||
/// Apply the screening policy. Epilepsy/seizure and photosensitivity are
|
||||
/// hard exclusions for unsupervised use because the protocol is, by
|
||||
/// construction, flicker stimulation; the rest require supervision.
|
||||
pub fn evaluate(&self, conditions: &[ExclusionCondition]) -> ScreenOutcome {
|
||||
if conditions.is_empty() {
|
||||
return ScreenOutcome::Cleared;
|
||||
}
|
||||
let hard: Vec<ExclusionCondition> = conditions
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|c| {
|
||||
matches!(
|
||||
c,
|
||||
ExclusionCondition::EpilepsyOrSeizureHistory
|
||||
| ExclusionCondition::Photosensitivity
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
if !hard.is_empty() {
|
||||
return ScreenOutcome::Excluded(hard);
|
||||
}
|
||||
ScreenOutcome::RequiresClinicalSupervision(conditions.to_vec())
|
||||
}
|
||||
}
|
||||
|
||||
/// Why a session was stopped. `Completed` is the only non-stop terminal state.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case", tag = "kind", content = "detail")]
|
||||
pub enum StopReason {
|
||||
/// Ran to planned completion — not a safety stop.
|
||||
Completed,
|
||||
/// An adverse event was reported.
|
||||
AdverseEvent(AdverseEvent),
|
||||
/// Sensor confidence dropped below the floor (entrainment unverifiable).
|
||||
SensorConfidenceBelowFloor { value: f64, floor: f64 },
|
||||
/// A requested parameter fell outside the approved envelope.
|
||||
ProtocolOutsideEnvelope,
|
||||
}
|
||||
|
||||
impl StopReason {
|
||||
/// `true` for every reason that is a *safety* stop (everything but
|
||||
/// `Completed`). Used by the audit layer to assert "100% safety stops
|
||||
/// logged" (ADR-250 §18).
|
||||
pub fn is_safety_stop(&self) -> bool {
|
||||
!matches!(self, StopReason::Completed)
|
||||
}
|
||||
}
|
||||
|
||||
/// Live per-tick session telemetry the monitor evaluates.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct SafetyTick {
|
||||
/// Reported adverse event this tick, if any.
|
||||
pub adverse: Option<AdverseEvent>,
|
||||
/// Aggregate sensor confidence `[0,1]` (RuView + optional EEG).
|
||||
pub sensor_confidence: f64,
|
||||
/// Whether the currently-applied stimulus is inside the envelope.
|
||||
pub stimulus_in_envelope: bool,
|
||||
}
|
||||
|
||||
/// In-session safety monitor with a configurable confidence floor and a
|
||||
/// bounded stop latency contract (ADR-250 §17: safety-stop latency < 500 ms).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SafetyMonitor {
|
||||
/// Minimum acceptable sensor confidence (ADR-250 §12 condition 9).
|
||||
pub confidence_floor: f64,
|
||||
/// Declared worst-case evaluation latency in milliseconds; the monitor is
|
||||
/// O(1) per tick so the real figure is far below this, but the contract is
|
||||
/// asserted in tests against ADR-250 §17's 500 ms bound.
|
||||
pub max_eval_latency_ms: u32,
|
||||
triggered: Option<StopReason>,
|
||||
}
|
||||
|
||||
impl Default for SafetyMonitor {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
confidence_floor: 0.5,
|
||||
max_eval_latency_ms: 50,
|
||||
triggered: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SafetyMonitor {
|
||||
pub fn new(confidence_floor: f64) -> Self {
|
||||
Self {
|
||||
confidence_floor,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Evaluate one tick. Once a stop has fired the monitor is *latched* — it
|
||||
/// keeps returning the original stop reason so a session can never silently
|
||||
/// resume after a safety event (closed-loop "terminate and lock",
|
||||
/// ADR-250 §8 Phase 4).
|
||||
pub fn evaluate(&mut self, tick: SafetyTick) -> Option<StopReason> {
|
||||
if let Some(reason) = &self.triggered {
|
||||
return Some(reason.clone());
|
||||
}
|
||||
let reason = if let Some(ev) = tick.adverse {
|
||||
Some(StopReason::AdverseEvent(ev))
|
||||
} else if !tick.stimulus_in_envelope {
|
||||
Some(StopReason::ProtocolOutsideEnvelope)
|
||||
} else if tick.sensor_confidence < self.confidence_floor {
|
||||
Some(StopReason::SensorConfidenceBelowFloor {
|
||||
value: tick.sensor_confidence,
|
||||
floor: self.confidence_floor,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(r) = &reason {
|
||||
self.triggered = Some(r.clone());
|
||||
}
|
||||
reason
|
||||
}
|
||||
|
||||
/// Whether the monitor has latched a stop.
|
||||
pub fn is_stopped(&self) -> bool {
|
||||
self.triggered.is_some()
|
||||
}
|
||||
|
||||
/// The latched stop reason, if any.
|
||||
pub fn stop_reason(&self) -> Option<&StopReason> {
|
||||
self.triggered.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn empty_history_clears() {
|
||||
assert_eq!(ExclusionScreen.evaluate(&[]), ScreenOutcome::Cleared);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn epilepsy_is_hard_excluded() {
|
||||
let out = ExclusionScreen.evaluate(&[ExclusionCondition::EpilepsyOrSeizureHistory]);
|
||||
assert!(matches!(out, ScreenOutcome::Excluded(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn photosensitivity_is_hard_excluded() {
|
||||
let out = ExclusionScreen.evaluate(&[ExclusionCondition::Photosensitivity]);
|
||||
assert!(matches!(out, ScreenOutcome::Excluded(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migraine_requires_supervision() {
|
||||
let out = ExclusionScreen.evaluate(&[ExclusionCondition::SevereMigraineSensitivity]);
|
||||
assert!(matches!(out, ScreenOutcome::RequiresClinicalSupervision(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adverse_event_triggers_stop() {
|
||||
let mut m = SafetyMonitor::default();
|
||||
let r = m.evaluate(SafetyTick {
|
||||
adverse: Some(AdverseEvent::Dizziness),
|
||||
sensor_confidence: 0.9,
|
||||
stimulus_in_envelope: true,
|
||||
});
|
||||
assert_eq!(r, Some(StopReason::AdverseEvent(AdverseEvent::Dizziness)));
|
||||
assert!(r.unwrap().is_safety_stop());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn low_confidence_triggers_stop() {
|
||||
let mut m = SafetyMonitor::new(0.6);
|
||||
let r = m.evaluate(SafetyTick {
|
||||
adverse: None,
|
||||
sensor_confidence: 0.3,
|
||||
stimulus_in_envelope: true,
|
||||
});
|
||||
assert!(matches!(
|
||||
r,
|
||||
Some(StopReason::SensorConfidenceBelowFloor { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn out_of_envelope_triggers_stop() {
|
||||
let mut m = SafetyMonitor::default();
|
||||
let r = m.evaluate(SafetyTick {
|
||||
adverse: None,
|
||||
sensor_confidence: 0.9,
|
||||
stimulus_in_envelope: false,
|
||||
});
|
||||
assert_eq!(r, Some(StopReason::ProtocolOutsideEnvelope));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stop_is_latched_and_cannot_resume() {
|
||||
let mut m = SafetyMonitor::default();
|
||||
m.evaluate(SafetyTick {
|
||||
adverse: Some(AdverseEvent::Headache),
|
||||
sensor_confidence: 0.9,
|
||||
stimulus_in_envelope: true,
|
||||
});
|
||||
// A subsequent "all clear" tick must NOT clear the latch.
|
||||
let r = m.evaluate(SafetyTick {
|
||||
adverse: None,
|
||||
sensor_confidence: 1.0,
|
||||
stimulus_in_envelope: true,
|
||||
});
|
||||
assert_eq!(r, Some(StopReason::AdverseEvent(AdverseEvent::Headache)));
|
||||
assert!(m.is_stopped());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completed_is_not_a_safety_stop() {
|
||||
assert!(!StopReason::Completed.is_safety_stop());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
//! Session record and the reproducible session witness (ADR-250 §11, §13).
|
||||
//!
|
||||
//! `session_hash = hash(protocol_version, model_version, device_version,
|
||||
//! stimulus_parameters, sensor_summary, response_summary, safety_events)`.
|
||||
//!
|
||||
//! The hash is computed over a **canonical** serialization (fixed field order,
|
||||
//! quantized floats) so the same session content always yields the same digest
|
||||
//! across machines — the RuFlo reproducibility trail. The [`SessionId`] is
|
||||
//! *derived from* the hash, so identifiers are themselves reproducible (no
|
||||
//! random UUIDs that would break replay).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::response::{EegMeasurement, RuViewState, SubjectiveReport};
|
||||
use crate::safety::StopReason;
|
||||
use crate::simulator::stable_hash;
|
||||
use crate::stimulus::StimulusParameters;
|
||||
|
||||
/// Version triple identifying the exact software/hardware that ran a session.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct VersionTriple {
|
||||
pub protocol_version: String,
|
||||
pub model_version: String,
|
||||
pub device_version: String,
|
||||
}
|
||||
|
||||
impl Default for VersionTriple {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
protocol_version: "adr-250-v0.1".to_string(),
|
||||
model_version: "ruvector-gamma-v0.1".to_string(),
|
||||
device_version: "sim-harness-v0.1".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-session outcome summary (ADR-250 §13 `outcome`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Outcome {
|
||||
pub entrainment_score: f64,
|
||||
pub safety_pass: bool,
|
||||
pub recommended_next_frequency_hz: f64,
|
||||
}
|
||||
|
||||
/// A complete, hashable session record (ADR-250 §13).
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SessionRecord {
|
||||
/// Derived from the content hash (see [`SessionRecord::finalize`]).
|
||||
pub session_id: SessionId,
|
||||
/// Pseudonymous participant id.
|
||||
pub person_id: String,
|
||||
pub versions: VersionTriple,
|
||||
/// Caller-supplied epoch milliseconds (kept explicit for determinism — no
|
||||
/// wall-clock reads inside the crate).
|
||||
pub timestamp_ms: u64,
|
||||
pub stimulus: StimulusParameters,
|
||||
pub ruview_state: RuViewState,
|
||||
pub eeg_optional: Option<EegMeasurement>,
|
||||
pub subjective: SubjectiveReport,
|
||||
pub outcome: Outcome,
|
||||
/// Every safety event raised during the session (ADR-250 §18: 100% logged).
|
||||
pub safety_events: Vec<StopReason>,
|
||||
/// The session witness (hex SHA-256).
|
||||
pub session_hash: String,
|
||||
}
|
||||
|
||||
/// A reproducible session identifier: the first 16 hex chars of the witness.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SessionId(pub String);
|
||||
|
||||
/// Builder that gathers session inputs and finalizes them into an immutable,
|
||||
/// witnessed [`SessionRecord`].
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SessionBuilder {
|
||||
person_id: String,
|
||||
versions: VersionTriple,
|
||||
timestamp_ms: u64,
|
||||
stimulus: StimulusParameters,
|
||||
ruview: RuViewState,
|
||||
eeg: Option<EegMeasurement>,
|
||||
subjective: SubjectiveReport,
|
||||
outcome: Outcome,
|
||||
safety_events: Vec<StopReason>,
|
||||
}
|
||||
|
||||
impl SessionBuilder {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
person_id: impl Into<String>,
|
||||
versions: VersionTriple,
|
||||
timestamp_ms: u64,
|
||||
stimulus: StimulusParameters,
|
||||
ruview: RuViewState,
|
||||
subjective: SubjectiveReport,
|
||||
outcome: Outcome,
|
||||
) -> Self {
|
||||
Self {
|
||||
person_id: person_id.into(),
|
||||
versions,
|
||||
timestamp_ms,
|
||||
stimulus,
|
||||
ruview,
|
||||
eeg: None,
|
||||
subjective,
|
||||
outcome,
|
||||
safety_events: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_eeg(mut self, eeg: EegMeasurement) -> Self {
|
||||
self.eeg = Some(eeg);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_safety_events(mut self, events: Vec<StopReason>) -> Self {
|
||||
self.safety_events = events;
|
||||
self
|
||||
}
|
||||
|
||||
/// Compute the canonical witness and freeze the record.
|
||||
pub fn finalize(self) -> SessionRecord {
|
||||
let canon = self.canonical_bytes();
|
||||
let digest = stable_hash(&[&canon]);
|
||||
let hex = hex_encode(&digest);
|
||||
let session_id = SessionId(hex[..16].to_string());
|
||||
SessionRecord {
|
||||
session_id,
|
||||
person_id: self.person_id,
|
||||
versions: self.versions,
|
||||
timestamp_ms: self.timestamp_ms,
|
||||
stimulus: self.stimulus,
|
||||
ruview_state: self.ruview,
|
||||
eeg_optional: self.eeg,
|
||||
subjective: self.subjective,
|
||||
outcome: self.outcome,
|
||||
safety_events: self.safety_events,
|
||||
session_hash: hex,
|
||||
}
|
||||
}
|
||||
|
||||
/// Canonical byte serialization in the ADR-250 §11 hash field order.
|
||||
/// Floats are quantized so last-bit jitter never forks the witness.
|
||||
fn canonical_bytes(&self) -> Vec<u8> {
|
||||
let mut s = String::new();
|
||||
s.push_str(&self.versions.protocol_version);
|
||||
s.push('|');
|
||||
s.push_str(&self.versions.model_version);
|
||||
s.push('|');
|
||||
s.push_str(&self.versions.device_version);
|
||||
s.push('|');
|
||||
// stimulus_parameters
|
||||
push_q(&mut s, self.stimulus.frequency_hz, 10.0);
|
||||
s.push_str(self.stimulus.modality.tag());
|
||||
push_q(&mut s, self.stimulus.brightness_level, 100.0);
|
||||
push_q(&mut s, self.stimulus.volume_level, 100.0);
|
||||
s.push_str(self.stimulus.duty_cycle.tag());
|
||||
push_q(&mut s, self.stimulus.phase_offset_ms, 10.0);
|
||||
push_q(&mut s, self.stimulus.duration_minutes, 10.0);
|
||||
s.push('|');
|
||||
// sensor_summary (RuView + optional EEG)
|
||||
push_q(&mut s, self.ruview.breathing_rate, 10.0);
|
||||
push_q(&mut s, self.ruview.breathing_stability, 100.0);
|
||||
push_q(&mut s, self.ruview.motion_artifact, 100.0);
|
||||
push_q(&mut s, self.ruview.stillness_score, 100.0);
|
||||
if let Some(e) = &self.eeg {
|
||||
push_q(&mut s, e.gamma_power_gain, 100.0);
|
||||
push_q(&mut s, e.phase_locking_value, 100.0);
|
||||
push_q(&mut s, e.artifact_score, 100.0);
|
||||
} else {
|
||||
s.push_str("no_eeg");
|
||||
}
|
||||
s.push('|');
|
||||
// response_summary
|
||||
push_q(&mut s, self.outcome.entrainment_score, 1000.0);
|
||||
push_q(&mut s, self.outcome.recommended_next_frequency_hz, 10.0);
|
||||
s.push(if self.outcome.safety_pass { 'P' } else { 'F' });
|
||||
s.push('|');
|
||||
// safety_events
|
||||
for ev in &self.safety_events {
|
||||
s.push_str(&serde_json::to_string(ev).unwrap_or_default());
|
||||
s.push(';');
|
||||
}
|
||||
s.into_bytes()
|
||||
}
|
||||
}
|
||||
|
||||
/// Append a float quantized to `1/scale` resolution, as a stable integer token.
|
||||
fn push_q(s: &mut String, v: f64, scale: f64) {
|
||||
let q = if v.is_finite() {
|
||||
(v * scale).round() as i64
|
||||
} else {
|
||||
i64::MIN
|
||||
};
|
||||
s.push_str(&q.to_string());
|
||||
s.push(',');
|
||||
}
|
||||
|
||||
fn hex_encode(bytes: &[u8]) -> String {
|
||||
let mut s = String::with_capacity(bytes.len() * 2);
|
||||
for b in bytes {
|
||||
s.push_str(&format!("{:02x}", b));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::safety::{AdverseEvent, StopReason};
|
||||
|
||||
fn builder() -> SessionBuilder {
|
||||
SessionBuilder::new(
|
||||
"subject-A",
|
||||
VersionTriple::default(),
|
||||
1_700_000_000_000,
|
||||
StimulusParameters::prior(),
|
||||
RuViewState::calm_baseline(),
|
||||
SubjectiveReport::default(),
|
||||
Outcome {
|
||||
entrainment_score: 0.71,
|
||||
safety_pass: true,
|
||||
recommended_next_frequency_hz: 39.5,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identical_sessions_hash_identically() {
|
||||
let a = builder().finalize();
|
||||
let b = builder().finalize();
|
||||
assert_eq!(a.session_hash, b.session_hash);
|
||||
assert_eq!(a.session_id, b.session_id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changing_frequency_changes_hash() {
|
||||
let a = builder().finalize();
|
||||
let mut b2 = builder();
|
||||
b2.stimulus.frequency_hz = 41.0;
|
||||
let b = b2.finalize();
|
||||
assert_ne!(a.session_hash, b.session_hash);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn safety_events_alter_the_witness() {
|
||||
let a = builder().finalize();
|
||||
let b = builder()
|
||||
.with_safety_events(vec![StopReason::AdverseEvent(AdverseEvent::Dizziness)])
|
||||
.finalize();
|
||||
assert_ne!(a.session_hash, b.session_hash);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_id_is_hash_prefix() {
|
||||
let r = builder().finalize();
|
||||
assert_eq!(r.session_id.0, r.session_hash[..16]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_roundtrips_through_json() {
|
||||
let r = builder().finalize();
|
||||
let json = serde_json::to_string(&r).unwrap();
|
||||
let back: SessionRecord = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(r, back);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eeg_presence_changes_witness() {
|
||||
let a = builder().finalize();
|
||||
let b = builder()
|
||||
.with_eeg(EegMeasurement {
|
||||
gamma_power_gain: 0.4,
|
||||
phase_locking_value: 0.6,
|
||||
artifact_score: 0.03,
|
||||
})
|
||||
.finalize();
|
||||
assert_ne!(a.session_hash, b.session_hash);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
//! Synthetic response simulator — Milestone 1 (ADR-250 §21).
|
||||
//!
|
||||
//! `frequency_response_curve(person, state, stimulus)` — a deterministic stand-in
|
||||
//! for real EEG + RuView measurements so the optimizer, safety bounds, and
|
||||
//! RuVector update logic can be exercised and replayed bit-exactly **before any
|
||||
//! hardware or human exposure**.
|
||||
//!
|
||||
//! Determinism contract (same discipline as `nvsim`): the response for a given
|
||||
//! `(global_seed, person_id, session_index, stimulus)` is byte-identical across
|
||||
//! runs and machines. Noise is drawn from a ChaCha20 stream seeded from a
|
||||
//! SHA-256 of those inputs — no OS entropy, no wall clock.
|
||||
|
||||
use rand::{Rng, SeedableRng};
|
||||
use rand_chacha::ChaCha20Rng;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::response::{EegMeasurement, RuViewState, SleepState};
|
||||
use crate::safety::AdverseEvent;
|
||||
use crate::stimulus::StimulusParameters;
|
||||
|
||||
/// A person's hidden response physiology. Real people are not 40 Hz-identical;
|
||||
/// each has a latent best frequency the optimizer must *discover* (ADR-250 §1,
|
||||
/// the 2025 PLOS One 36–44 Hz finding).
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct LatentPerson {
|
||||
/// The individual's true peak entrainment frequency in Hz.
|
||||
pub peak_hz: f64,
|
||||
/// Tuning-curve width in Hz (smaller = sharper, harder to find).
|
||||
pub width_hz: f64,
|
||||
/// Maximum achievable gamma gain at the peak `[0,1]`.
|
||||
pub max_gain: f64,
|
||||
/// How quickly comfort falls as intensity rises `[0,1]`.
|
||||
pub intensity_sensitivity: f64,
|
||||
/// Intrinsic measurement noise level `[0,1]`.
|
||||
pub noise: f64,
|
||||
}
|
||||
|
||||
impl LatentPerson {
|
||||
/// Derive a stable latent person from a pseudonymous id. Deterministic: the
|
||||
/// same id always yields the same physiology, so multi-session studies are
|
||||
/// reproducible.
|
||||
pub fn from_id(person_id: &str) -> Self {
|
||||
let h = stable_hash(&[b"latent-person", person_id.as_bytes()]);
|
||||
// Map hash bytes to parameter ranges.
|
||||
let u = |i: usize| (h[i] as f64) / 255.0;
|
||||
Self {
|
||||
peak_hz: 37.0 + u(0) * 6.0, // 37..43 Hz
|
||||
width_hz: 1.5 + u(1) * 2.5, // 1.5..4.0 Hz
|
||||
max_gain: 0.45 + u(2) * 0.45, // 0.45..0.90
|
||||
intensity_sensitivity: 0.2 + u(3) * 0.6, // 0.2..0.8
|
||||
noise: 0.02 + u(4) * 0.08, // 0.02..0.10
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One simulated session outcome.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct SimulatedResponse {
|
||||
/// Synthetic EEG entrainment measurement.
|
||||
pub eeg: EegMeasurement,
|
||||
/// Updated RuView state for this session (motion/comfort feedback).
|
||||
pub ruview: RuViewState,
|
||||
/// Subjective comfort `[0,1]`.
|
||||
pub comfort: f64,
|
||||
/// Whether an adverse event occurred (rises with overstimulation).
|
||||
pub adverse_event: bool,
|
||||
}
|
||||
|
||||
/// A deterministic safety-fault injection (Finding 5, 2026-06-11 review). The
|
||||
/// synthetic simulator is the M1 validation harness; real adverse events arrive
|
||||
/// from hardware/clinic. Under the absolute intensity caps (≤ 0.6) the organic
|
||||
/// overstimulation path can no longer reach the adverse threshold, so this
|
||||
/// scheduled injection is how the integrated per-tick monitor + terminate-and-
|
||||
/// lock path is exercised in simulation.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct FaultSchedule {
|
||||
/// Session index at which the fault surfaces.
|
||||
pub at_session_index: u64,
|
||||
/// Tick (0-based, ≈ minutes) within that session at which it surfaces.
|
||||
pub at_tick: usize,
|
||||
/// What surfaces.
|
||||
pub fault: InjectedFault,
|
||||
}
|
||||
|
||||
/// The kind of safety fault a [`FaultSchedule`] surfaces.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum InjectedFault {
|
||||
/// Surface an adverse event at the scheduled tick (engages terminate-and-lock
|
||||
/// if its class warrants it).
|
||||
Adverse(AdverseEvent),
|
||||
/// Drop sensor confidence to this value at the scheduled tick.
|
||||
LowConfidence(f64),
|
||||
}
|
||||
|
||||
/// One per-tick safety sample the in-session monitor evaluates (Finding 5).
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct SafetySample {
|
||||
/// Adverse event surfaced at this tick, if any.
|
||||
pub adverse: Option<AdverseEvent>,
|
||||
/// Sensor confidence at this tick `[0,1]`.
|
||||
pub sensor_confidence: f64,
|
||||
}
|
||||
|
||||
/// Deterministic response simulator.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ResponseSimulator {
|
||||
seed: u64,
|
||||
/// Optional deterministic fault injection for the per-tick safety path.
|
||||
fault: Option<FaultSchedule>,
|
||||
}
|
||||
|
||||
impl ResponseSimulator {
|
||||
pub fn new(seed: u64) -> Self {
|
||||
Self { seed, fault: None }
|
||||
}
|
||||
|
||||
/// A simulator that surfaces a scheduled safety fault, for validating the
|
||||
/// integrated per-tick monitor + terminate-and-lock path (Finding 5).
|
||||
pub fn with_fault(seed: u64, fault: FaultSchedule) -> Self {
|
||||
Self {
|
||||
seed,
|
||||
fault: Some(fault),
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-tick safety samples for one session (Finding 5). One tick ≈ one
|
||||
/// minute of the planned duration (≥ 1). Normal sessions yield clean
|
||||
/// samples — the aggregate [`simulate`](Self::simulate) cannot produce an
|
||||
/// adverse event inside the absolute caps — so the witness of a clean
|
||||
/// session is unchanged; a configured [`FaultSchedule`] surfaces a
|
||||
/// deterministic event at its tick.
|
||||
pub fn session_ticks(
|
||||
&self,
|
||||
person: &LatentPerson,
|
||||
state: &RuViewState,
|
||||
stimulus: &StimulusParameters,
|
||||
session_index: u64,
|
||||
) -> Vec<SafetySample> {
|
||||
let n = (stimulus.duration_minutes.round() as i64).clamp(1, 600) as usize;
|
||||
// Organic adverse signal from the aggregate physics (kept wired even if
|
||||
// it cannot fire under the absolute caps): surfaced mid-session.
|
||||
let agg = self.simulate(person, state, stimulus, session_index);
|
||||
let organic_tick = n / 2;
|
||||
(0..n)
|
||||
.map(|i| {
|
||||
let mut sample = SafetySample {
|
||||
adverse: None,
|
||||
sensor_confidence: state.sensor_confidence,
|
||||
};
|
||||
if agg.adverse_event && i == organic_tick {
|
||||
sample.adverse = Some(AdverseEvent::AbnormalDistress);
|
||||
}
|
||||
if let Some(f) = &self.fault {
|
||||
if f.at_session_index == session_index && f.at_tick == i {
|
||||
match f.fault {
|
||||
InjectedFault::Adverse(ev) => sample.adverse = Some(ev),
|
||||
InjectedFault::LowConfidence(c) => sample.sensor_confidence = c,
|
||||
}
|
||||
}
|
||||
}
|
||||
sample
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Simulate a session. `session_index` makes repeated identical protocols
|
||||
/// produce independent (but reproducible) noise draws.
|
||||
pub fn simulate(
|
||||
&self,
|
||||
person: &LatentPerson,
|
||||
state: &RuViewState,
|
||||
stimulus: &StimulusParameters,
|
||||
session_index: u64,
|
||||
) -> SimulatedResponse {
|
||||
let mut rng = self.session_rng(person, stimulus, session_index);
|
||||
|
||||
// --- Frequency tuning curve: Gaussian bump around the latent peak. ---
|
||||
let df = stimulus.frequency_hz - person.peak_hz;
|
||||
let tuning = (-0.5 * (df / person.width_hz).powi(2)).exp();
|
||||
|
||||
// --- State modulation: calm, still, awake-but-quiet entrains best. ---
|
||||
let state_factor = state_modulation(state);
|
||||
|
||||
// --- Intensity helps gamma up to a point, then risks overstimulation. ---
|
||||
let intensity = 0.5 * (stimulus.brightness_level + stimulus.volume_level);
|
||||
// Mild positive contribution from intensity, saturating.
|
||||
let intensity_gain = 0.6 + 0.4 * (1.0 - (-3.0 * intensity).exp());
|
||||
|
||||
let mut noise = || -> f64 { (rng.gen::<f64>() - 0.5) * 2.0 * person.noise };
|
||||
|
||||
let gamma_power_gain =
|
||||
clamp01(person.max_gain * tuning * state_factor * intensity_gain + noise());
|
||||
// Phase locking tracks tuning but is less intensity-dependent.
|
||||
let phase_locking_value = clamp01(0.9 * tuning * state_factor + noise());
|
||||
// Artifact rises with motion and intensity.
|
||||
let artifact_score = clamp01(state.motion_artifact + 0.1 * intensity + noise().abs());
|
||||
|
||||
// --- Comfort: high intensity and long duration erode comfort. ---
|
||||
let dur_load = (stimulus.duration_minutes / 15.0).clamp(0.0, 1.0);
|
||||
let comfort = clamp01(
|
||||
0.95 - person.intensity_sensitivity * (0.7 * intensity + 0.3 * dur_load) + noise(),
|
||||
);
|
||||
|
||||
// --- Adverse event: rare, and only when truly overstimulated. ---
|
||||
let overstim = intensity * dur_load;
|
||||
let adverse_event = overstim > 0.85 && rng.gen::<f64>() < (overstim - 0.85) * 2.0;
|
||||
|
||||
// Feedback into RuView state: discomfort raises motion/restlessness.
|
||||
let mut ruview = *state;
|
||||
ruview.motion_artifact = clamp01(state.motion_artifact + (1.0 - comfort) * 0.1);
|
||||
ruview.restlessness_score = clamp01(state.restlessness_score + (1.0 - comfort) * 0.15);
|
||||
|
||||
SimulatedResponse {
|
||||
eeg: EegMeasurement {
|
||||
gamma_power_gain,
|
||||
phase_locking_value,
|
||||
artifact_score,
|
||||
},
|
||||
ruview,
|
||||
comfort,
|
||||
adverse_event,
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-session ChaCha20 stream seeded from all inputs — reproducible noise.
|
||||
fn session_rng(
|
||||
&self,
|
||||
person: &LatentPerson,
|
||||
stimulus: &StimulusParameters,
|
||||
session_index: u64,
|
||||
) -> ChaCha20Rng {
|
||||
// Quantize stimulus to 0.1 Hz / 0.01 intensity so floating noise in the
|
||||
// last bits cannot fork the stream; matches the ±0.1 Hz control spec.
|
||||
let q_freq = (stimulus.frequency_hz * 10.0).round() as i64;
|
||||
let q_bright = (stimulus.brightness_level * 100.0).round() as i64;
|
||||
let q_vol = (stimulus.volume_level * 100.0).round() as i64;
|
||||
let q_peak = (person.peak_hz * 1000.0).round() as i64;
|
||||
let h = stable_hash(&[
|
||||
b"session-rng",
|
||||
&self.seed.to_le_bytes(),
|
||||
&session_index.to_le_bytes(),
|
||||
&q_freq.to_le_bytes(),
|
||||
&q_bright.to_le_bytes(),
|
||||
&q_vol.to_le_bytes(),
|
||||
&q_peak.to_le_bytes(),
|
||||
]);
|
||||
let mut seed32 = [0u8; 32];
|
||||
seed32.copy_from_slice(&h);
|
||||
ChaCha20Rng::from_seed(seed32)
|
||||
}
|
||||
}
|
||||
|
||||
fn state_modulation(state: &RuViewState) -> f64 {
|
||||
let sleep = match state.sleep_state {
|
||||
SleepState::QuietWake => 1.0,
|
||||
SleepState::Drowsy => 0.85,
|
||||
SleepState::Asleep => 0.6,
|
||||
SleepState::Active => 0.7,
|
||||
SleepState::Unknown => 0.8,
|
||||
};
|
||||
// Stillness and breathing stability help; restlessness hurts.
|
||||
let calm = 0.5 + 0.3 * state.stillness_score + 0.2 * state.breathing_stability
|
||||
- 0.2 * state.restlessness_score;
|
||||
(sleep * calm).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn clamp01(v: f64) -> f64 {
|
||||
if v.is_nan() {
|
||||
0.0
|
||||
} else {
|
||||
v.clamp(0.0, 1.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// SHA-256 over concatenated byte chunks → 32 bytes. Deterministic and
|
||||
/// portable; the witness foundation for the whole crate.
|
||||
pub(crate) fn stable_hash(chunks: &[&[u8]]) -> [u8; 32] {
|
||||
let mut hasher = Sha256::new();
|
||||
for c in chunks {
|
||||
hasher.update((c.len() as u64).to_le_bytes());
|
||||
hasher.update(c);
|
||||
}
|
||||
hasher.finalize().into()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::stimulus::StimulusParameters;
|
||||
|
||||
#[test]
|
||||
fn same_inputs_produce_identical_output() {
|
||||
let sim = ResponseSimulator::new(42);
|
||||
let person = LatentPerson::from_id("subject-A");
|
||||
let state = RuViewState::calm_baseline();
|
||||
let stim = StimulusParameters::prior();
|
||||
let a = sim.simulate(&person, &state, &stim, 0);
|
||||
let b = sim.simulate(&person, &state, &stim, 0);
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_session_index_changes_noise() {
|
||||
let sim = ResponseSimulator::new(42);
|
||||
let person = LatentPerson::from_id("subject-A");
|
||||
let state = RuViewState::calm_baseline();
|
||||
let stim = StimulusParameters::prior();
|
||||
let a = sim.simulate(&person, &state, &stim, 0);
|
||||
let b = sim.simulate(&person, &state, &stim, 1);
|
||||
assert_ne!(a.eeg.gamma_power_gain, b.eeg.gamma_power_gain);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn latent_person_is_stable_per_id() {
|
||||
assert_eq!(
|
||||
LatentPerson::from_id("subject-A"),
|
||||
LatentPerson::from_id("subject-A")
|
||||
);
|
||||
assert_ne!(
|
||||
LatentPerson::from_id("subject-A").peak_hz,
|
||||
LatentPerson::from_id("subject-Z").peak_hz
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn peak_frequency_entrains_better_than_band_edge() {
|
||||
let sim = ResponseSimulator::new(7);
|
||||
let person = LatentPerson::from_id("subject-B");
|
||||
let state = RuViewState::calm_baseline();
|
||||
|
||||
let mut at_peak = StimulusParameters::prior();
|
||||
at_peak.frequency_hz = (person.peak_hz * 10.0).round() / 10.0;
|
||||
let mut at_edge = StimulusParameters::prior();
|
||||
at_edge.frequency_hz = 36.0;
|
||||
|
||||
let r_peak = sim.simulate(&person, &state, &at_peak, 0);
|
||||
let r_edge = sim.simulate(&person, &state, &at_edge, 0);
|
||||
// Only assert when the latent peak is genuinely away from the edge.
|
||||
if (person.peak_hz - 36.0).abs() > 1.0 {
|
||||
assert!(r_peak.eeg.gamma_power_gain > r_edge.eeg.gamma_power_gain);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calm_state_entrains_better_than_restless() {
|
||||
let sim = ResponseSimulator::new(3);
|
||||
let person = LatentPerson::from_id("subject-C");
|
||||
let stim = StimulusParameters::prior();
|
||||
|
||||
let calm = RuViewState::calm_baseline();
|
||||
let mut restless = RuViewState::calm_baseline();
|
||||
restless.restlessness_score = 0.9;
|
||||
restless.stillness_score = 0.2;
|
||||
restless.sleep_state = SleepState::Active;
|
||||
|
||||
let r_calm = sim.simulate(&person, &calm, &stim, 0);
|
||||
let r_restless = sim.simulate(&person, &restless, &stim, 0);
|
||||
assert!(r_calm.eeg.gamma_power_gain > r_restless.eeg.gamma_power_gain);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
//! Stimulus parameters and the safety envelope (ADR-250 §5, §12).
|
||||
//!
|
||||
//! The [`SafetyEnvelope`] is the load-bearing safety primitive of the whole
|
||||
//! crate: **no recommendation, calibration step, or closed-loop nudge may ever
|
||||
//! produce a [`StimulusParameters`] that fails [`SafetyEnvelope::validate`].**
|
||||
//! Every code path that emits a stimulus setting routes through
|
||||
//! [`SafetyEnvelope::clamp`] (best-effort coercion) and is asserted against
|
||||
//! [`SafetyEnvelope::contains`] in tests.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::math::clamp_safe;
|
||||
|
||||
/// Stimulation modality (ADR-250 §5).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Modality {
|
||||
/// Sound only.
|
||||
Audio,
|
||||
/// Light only.
|
||||
Visual,
|
||||
/// Combined audio-visual — GENUS-style, the preferred protocol.
|
||||
AudioVisual,
|
||||
}
|
||||
|
||||
impl Modality {
|
||||
/// Canonical lowercase tag used in the session witness.
|
||||
pub fn tag(self) -> &'static str {
|
||||
match self {
|
||||
Modality::Audio => "audio",
|
||||
Modality::Visual => "visual",
|
||||
Modality::AudioVisual => "audio_visual",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Duty-cycle shape (ADR-250 §5). Conservative ordering: `Continuous` first.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum DutyCycle {
|
||||
/// Steady stimulation for the whole session.
|
||||
Continuous,
|
||||
/// Amplitude ramps up/down — gentlest onset.
|
||||
Ramped,
|
||||
/// On/off pulsing — explored only after tolerance is established.
|
||||
Pulsed,
|
||||
}
|
||||
|
||||
impl DutyCycle {
|
||||
pub fn tag(self) -> &'static str {
|
||||
match self {
|
||||
DutyCycle::Continuous => "continuous",
|
||||
DutyCycle::Ramped => "ramped",
|
||||
DutyCycle::Pulsed => "pulsed",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A concrete stimulation setting. All intensity fields are normalized `[0,1]`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub struct StimulusParameters {
|
||||
/// Carrier/flicker frequency in Hz (search band 36–44, prior 40).
|
||||
pub frequency_hz: f64,
|
||||
/// Stimulation modality.
|
||||
pub modality: Modality,
|
||||
/// Visual brightness in `[0,1]` (capped well below unsafe flicker intensity).
|
||||
pub brightness_level: f64,
|
||||
/// Audio volume in `[0,1]` (comfort-bounded).
|
||||
pub volume_level: f64,
|
||||
/// Duty-cycle shape.
|
||||
pub duty_cycle: DutyCycle,
|
||||
/// Inter-modality phase offset in milliseconds.
|
||||
pub phase_offset_ms: f64,
|
||||
/// Session duration in minutes.
|
||||
pub duration_minutes: f64,
|
||||
}
|
||||
|
||||
impl StimulusParameters {
|
||||
/// The evidence-based starting prior: 40 Hz combined audio-visual, gentle
|
||||
/// intensities, continuous, short (ADR-250 §5 "Starting prior").
|
||||
pub fn prior() -> Self {
|
||||
Self {
|
||||
frequency_hz: 40.0,
|
||||
modality: Modality::AudioVisual,
|
||||
brightness_level: 0.30,
|
||||
volume_level: 0.28,
|
||||
duty_cycle: DutyCycle::Continuous,
|
||||
phase_offset_ms: 0.0,
|
||||
duration_minutes: 10.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reasons a [`StimulusParameters`] is rejected by the envelope. Each variant
|
||||
/// is logged verbatim into the RuFlo safety trail.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case", tag = "kind", content = "detail")]
|
||||
pub enum EnvelopeViolation {
|
||||
/// Frequency outside `[min_hz, max_hz]`.
|
||||
Frequency { value: f64, min: f64, max: f64 },
|
||||
/// Brightness above the hard cap.
|
||||
Brightness { value: f64, cap: f64 },
|
||||
/// Volume above the hard cap.
|
||||
Volume { value: f64, cap: f64 },
|
||||
/// Duration above the per-stage maximum.
|
||||
Duration { value: f64, max: f64 },
|
||||
/// A non-finite (NaN/Inf) field was supplied.
|
||||
NonFinite { field: &'static str },
|
||||
}
|
||||
|
||||
/// Compiled-in **absolute** bounds — the floor under every envelope (Finding 3,
|
||||
/// 2026-06-11 safety review). No `SafetyEnvelope` can be constructed (by code,
|
||||
/// builder, or deserialization) that violates these. They make safety a
|
||||
/// property of *construction*, not of default values, mirroring the firmware's
|
||||
/// compiled-in stance.
|
||||
pub mod absolute {
|
||||
/// Hard frequency floor (Hz). Chosen **>= 30 Hz** so the entire envelope —
|
||||
/// including its lowest edge — sits above the photosensitive/provocative
|
||||
/// 15–25 Hz flicker band with margin. A config can never push stimulation
|
||||
/// into that band.
|
||||
pub const MIN_HZ: f64 = 30.0;
|
||||
/// Hard frequency ceiling (Hz). Above the 36–44 Hz search band with room
|
||||
/// for future programs, bounded so the actuator is never driven absurdly fast.
|
||||
pub const MAX_HZ: f64 = 60.0;
|
||||
/// Hard brightness cap — no envelope may uncap flicker brightness toward 1.0.
|
||||
pub const BRIGHTNESS_CAP: f64 = 0.6;
|
||||
/// Hard volume cap — comfort ceiling.
|
||||
pub const VOLUME_CAP: f64 = 0.6;
|
||||
/// Hard maximum single-session duration (minutes) — no `1e6`-minute sessions.
|
||||
pub const MAX_DURATION_MINUTES: f64 = 30.0;
|
||||
/// Hard maximum absolute inter-modality phase offset (ms).
|
||||
pub const MAX_PHASE_OFFSET_MS: f64 = 20.0;
|
||||
}
|
||||
|
||||
/// Why an attempted [`SafetyEnvelope`] construction was rejected. Every variant
|
||||
/// is a *safe* refusal: a rejected envelope is never built, so deserialization
|
||||
/// of a hostile config fails closed instead of silently widening the bounds.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, thiserror::Error)]
|
||||
pub enum EnvelopeError {
|
||||
/// A field was NaN/Inf.
|
||||
#[error("envelope field `{field}` is not finite")]
|
||||
NonFinite { field: &'static str },
|
||||
/// `min_hz` below the compiled-in photosensitive-safe floor.
|
||||
#[error("min_hz {min_hz} is below the absolute floor {floor} Hz (photosensitive-band guard)")]
|
||||
FrequencyFloor { min_hz: f64, floor: f64 },
|
||||
/// `max_hz` above the compiled-in ceiling.
|
||||
#[error("max_hz {max_hz} exceeds the absolute ceiling {ceiling} Hz")]
|
||||
FrequencyCeiling { max_hz: f64, ceiling: f64 },
|
||||
/// Band is empty or inverted.
|
||||
#[error("frequency band is empty/inverted: min_hz {min_hz} >= max_hz {max_hz}")]
|
||||
BandInverted { min_hz: f64, max_hz: f64 },
|
||||
/// Brightness cap out of `[0, ABS]`.
|
||||
#[error("brightness_cap {cap} outside [0, {abs}]")]
|
||||
BrightnessCap { cap: f64, abs: f64 },
|
||||
/// Volume cap out of `[0, ABS]`.
|
||||
#[error("volume_cap {cap} outside [0, {abs}]")]
|
||||
VolumeCap { cap: f64, abs: f64 },
|
||||
/// Duration out of `(0, ABS]`.
|
||||
#[error("max_duration_minutes {minutes} outside (0, {abs}]")]
|
||||
Duration { minutes: f64, abs: f64 },
|
||||
/// Phase offset cap out of `[0, ABS]`.
|
||||
#[error("max_phase_offset_ms {ms} outside [0, {abs}]")]
|
||||
PhaseOffset { ms: f64, abs: f64 },
|
||||
}
|
||||
|
||||
/// The predefined safety envelope. Optimization happens **only inside** these
|
||||
/// bounds; the system "must never autonomously expand beyond the allowed safety
|
||||
/// envelope" (ADR-250 §12). The envelope itself is data, never widened by the
|
||||
/// optimizer — only an operator constructs a wider one deliberately.
|
||||
///
|
||||
/// **Safety by construction (Finding 3, 2026-06-11 review):** the bound fields
|
||||
/// are private and reachable only through [`SafetyEnvelope::try_new`] (and the
|
||||
/// `with_*` builders), which reject anything outside the compiled-in
|
||||
/// [`absolute`] bounds. Deserialization routes through the same validator via
|
||||
/// `#[serde(try_from)]`, so no config — however hostile — can place the band in
|
||||
/// the 15–25 Hz photosensitive zone, uncap brightness/volume toward 1.0, or set
|
||||
/// a million-minute session.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(into = "SafetyEnvelopeWire", try_from = "SafetyEnvelopeWire")]
|
||||
pub struct SafetyEnvelope {
|
||||
min_hz: f64,
|
||||
max_hz: f64,
|
||||
brightness_cap: f64,
|
||||
volume_cap: f64,
|
||||
max_duration_minutes: f64,
|
||||
max_phase_offset_ms: f64,
|
||||
}
|
||||
|
||||
/// Transparent serde mirror of [`SafetyEnvelope`]. Deserialization always passes
|
||||
/// through [`SafetyEnvelope::try_new`] (`try_from`), so the absolute bounds
|
||||
/// cannot be bypassed; serialization is a plain projection (`into`).
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||
struct SafetyEnvelopeWire {
|
||||
min_hz: f64,
|
||||
max_hz: f64,
|
||||
brightness_cap: f64,
|
||||
volume_cap: f64,
|
||||
max_duration_minutes: f64,
|
||||
max_phase_offset_ms: f64,
|
||||
}
|
||||
|
||||
impl From<SafetyEnvelope> for SafetyEnvelopeWire {
|
||||
fn from(e: SafetyEnvelope) -> Self {
|
||||
Self {
|
||||
min_hz: e.min_hz,
|
||||
max_hz: e.max_hz,
|
||||
brightness_cap: e.brightness_cap,
|
||||
volume_cap: e.volume_cap,
|
||||
max_duration_minutes: e.max_duration_minutes,
|
||||
max_phase_offset_ms: e.max_phase_offset_ms,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<SafetyEnvelopeWire> for SafetyEnvelope {
|
||||
type Error = EnvelopeError;
|
||||
fn try_from(w: SafetyEnvelopeWire) -> Result<Self, Self::Error> {
|
||||
Self::try_new(
|
||||
w.min_hz,
|
||||
w.max_hz,
|
||||
w.brightness_cap,
|
||||
w.volume_cap,
|
||||
w.max_duration_minutes,
|
||||
w.max_phase_offset_ms,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl SafetyEnvelope {
|
||||
/// Construct an envelope, enforcing the compiled-in [`absolute`] bounds. The
|
||||
/// **only** constructor; `conservative()` and the `with_*` builders all route
|
||||
/// through it, and so does deserialization. Returns [`EnvelopeError`] (a safe
|
||||
/// refusal) for any out-of-bounds request.
|
||||
pub fn try_new(
|
||||
min_hz: f64,
|
||||
max_hz: f64,
|
||||
brightness_cap: f64,
|
||||
volume_cap: f64,
|
||||
max_duration_minutes: f64,
|
||||
max_phase_offset_ms: f64,
|
||||
) -> Result<Self, EnvelopeError> {
|
||||
for (field, v) in [
|
||||
("min_hz", min_hz),
|
||||
("max_hz", max_hz),
|
||||
("brightness_cap", brightness_cap),
|
||||
("volume_cap", volume_cap),
|
||||
("max_duration_minutes", max_duration_minutes),
|
||||
("max_phase_offset_ms", max_phase_offset_ms),
|
||||
] {
|
||||
if !v.is_finite() {
|
||||
return Err(EnvelopeError::NonFinite { field });
|
||||
}
|
||||
}
|
||||
if min_hz < absolute::MIN_HZ {
|
||||
return Err(EnvelopeError::FrequencyFloor {
|
||||
min_hz,
|
||||
floor: absolute::MIN_HZ,
|
||||
});
|
||||
}
|
||||
if max_hz > absolute::MAX_HZ {
|
||||
return Err(EnvelopeError::FrequencyCeiling {
|
||||
max_hz,
|
||||
ceiling: absolute::MAX_HZ,
|
||||
});
|
||||
}
|
||||
if min_hz >= max_hz {
|
||||
return Err(EnvelopeError::BandInverted { min_hz, max_hz });
|
||||
}
|
||||
if !(0.0..=absolute::BRIGHTNESS_CAP).contains(&brightness_cap) {
|
||||
return Err(EnvelopeError::BrightnessCap {
|
||||
cap: brightness_cap,
|
||||
abs: absolute::BRIGHTNESS_CAP,
|
||||
});
|
||||
}
|
||||
if !(0.0..=absolute::VOLUME_CAP).contains(&volume_cap) {
|
||||
return Err(EnvelopeError::VolumeCap {
|
||||
cap: volume_cap,
|
||||
abs: absolute::VOLUME_CAP,
|
||||
});
|
||||
}
|
||||
if max_duration_minutes <= 0.0 || max_duration_minutes > absolute::MAX_DURATION_MINUTES {
|
||||
return Err(EnvelopeError::Duration {
|
||||
minutes: max_duration_minutes,
|
||||
abs: absolute::MAX_DURATION_MINUTES,
|
||||
});
|
||||
}
|
||||
if !(0.0..=absolute::MAX_PHASE_OFFSET_MS).contains(&max_phase_offset_ms) {
|
||||
return Err(EnvelopeError::PhaseOffset {
|
||||
ms: max_phase_offset_ms,
|
||||
abs: absolute::MAX_PHASE_OFFSET_MS,
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
min_hz,
|
||||
max_hz,
|
||||
brightness_cap,
|
||||
volume_cap,
|
||||
max_duration_minutes,
|
||||
max_phase_offset_ms,
|
||||
})
|
||||
}
|
||||
|
||||
/// Lower band edge (Hz). Always `>= absolute::MIN_HZ`.
|
||||
pub fn min_hz(&self) -> f64 {
|
||||
self.min_hz
|
||||
}
|
||||
/// Upper band edge (Hz). Always `<= absolute::MAX_HZ`.
|
||||
pub fn max_hz(&self) -> f64 {
|
||||
self.max_hz
|
||||
}
|
||||
/// Hard brightness cap. Always `<= absolute::BRIGHTNESS_CAP`.
|
||||
pub fn brightness_cap(&self) -> f64 {
|
||||
self.brightness_cap
|
||||
}
|
||||
/// Hard volume cap. Always `<= absolute::VOLUME_CAP`.
|
||||
pub fn volume_cap(&self) -> f64 {
|
||||
self.volume_cap
|
||||
}
|
||||
/// Maximum session duration (minutes). Always `<= absolute::MAX_DURATION_MINUTES`.
|
||||
pub fn max_duration_minutes(&self) -> f64 {
|
||||
self.max_duration_minutes
|
||||
}
|
||||
/// Maximum absolute inter-modality phase offset (ms).
|
||||
pub fn max_phase_offset_ms(&self) -> f64 {
|
||||
self.max_phase_offset_ms
|
||||
}
|
||||
|
||||
/// Re-band an existing envelope (re-validated against the absolute bounds).
|
||||
pub fn with_band(self, min_hz: f64, max_hz: f64) -> Result<Self, EnvelopeError> {
|
||||
Self::try_new(
|
||||
min_hz,
|
||||
max_hz,
|
||||
self.brightness_cap,
|
||||
self.volume_cap,
|
||||
self.max_duration_minutes,
|
||||
self.max_phase_offset_ms,
|
||||
)
|
||||
}
|
||||
/// Override the intensity caps (re-validated against the absolute bounds).
|
||||
pub fn with_caps(self, brightness_cap: f64, volume_cap: f64) -> Result<Self, EnvelopeError> {
|
||||
Self::try_new(
|
||||
self.min_hz,
|
||||
self.max_hz,
|
||||
brightness_cap,
|
||||
volume_cap,
|
||||
self.max_duration_minutes,
|
||||
self.max_phase_offset_ms,
|
||||
)
|
||||
}
|
||||
/// Override the maximum session duration (re-validated against the absolute bounds).
|
||||
pub fn with_max_duration_minutes(self, minutes: f64) -> Result<Self, EnvelopeError> {
|
||||
Self::try_new(
|
||||
self.min_hz,
|
||||
self.max_hz,
|
||||
self.brightness_cap,
|
||||
self.volume_cap,
|
||||
minutes,
|
||||
self.max_phase_offset_ms,
|
||||
)
|
||||
}
|
||||
|
||||
/// Conservative research-grade default envelope (ADR-250 §5 default ranges,
|
||||
/// "safety_profile: conservative"). Well inside every [`absolute`] bound.
|
||||
pub fn conservative() -> Self {
|
||||
Self::try_new(36.0, 44.0, 0.40, 0.40, 15.0, 5.0)
|
||||
.expect("conservative envelope is within the compiled-in absolute bounds")
|
||||
}
|
||||
|
||||
/// `true` iff every field of `s` lies inside the envelope and is finite.
|
||||
pub fn contains(&self, s: &StimulusParameters) -> bool {
|
||||
self.validate(s).is_ok()
|
||||
}
|
||||
|
||||
/// Validate a setting, returning every violation found (not just the first)
|
||||
/// so the safety log is complete.
|
||||
pub fn validate(&self, s: &StimulusParameters) -> Result<(), Vec<EnvelopeViolation>> {
|
||||
let mut v = Vec::new();
|
||||
for (field, val) in [
|
||||
("frequency_hz", s.frequency_hz),
|
||||
("brightness_level", s.brightness_level),
|
||||
("volume_level", s.volume_level),
|
||||
("duration_minutes", s.duration_minutes),
|
||||
("phase_offset_ms", s.phase_offset_ms),
|
||||
] {
|
||||
if !val.is_finite() {
|
||||
v.push(EnvelopeViolation::NonFinite { field });
|
||||
}
|
||||
}
|
||||
if !v.is_empty() {
|
||||
return Err(v);
|
||||
}
|
||||
if s.frequency_hz < self.min_hz || s.frequency_hz > self.max_hz {
|
||||
v.push(EnvelopeViolation::Frequency {
|
||||
value: s.frequency_hz,
|
||||
min: self.min_hz,
|
||||
max: self.max_hz,
|
||||
});
|
||||
}
|
||||
if s.brightness_level > self.brightness_cap || s.brightness_level < 0.0 {
|
||||
v.push(EnvelopeViolation::Brightness {
|
||||
value: s.brightness_level,
|
||||
cap: self.brightness_cap,
|
||||
});
|
||||
}
|
||||
if s.volume_level > self.volume_cap || s.volume_level < 0.0 {
|
||||
v.push(EnvelopeViolation::Volume {
|
||||
value: s.volume_level,
|
||||
cap: self.volume_cap,
|
||||
});
|
||||
}
|
||||
if s.duration_minutes > self.max_duration_minutes || s.duration_minutes <= 0.0 {
|
||||
v.push(EnvelopeViolation::Duration {
|
||||
value: s.duration_minutes,
|
||||
max: self.max_duration_minutes,
|
||||
});
|
||||
}
|
||||
if v.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(v)
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort coercion of `s` into the envelope. Used as a defensive final
|
||||
/// stage on any emitted recommendation; coercion can only ever *reduce*
|
||||
/// intensity / pull frequency inward, never expand it. The result always
|
||||
/// satisfies [`contains`](Self::contains).
|
||||
pub fn clamp(&self, mut s: StimulusParameters) -> StimulusParameters {
|
||||
s.frequency_hz = clamp_safe(s.frequency_hz, self.min_hz, self.max_hz);
|
||||
s.brightness_level = clamp_safe(s.brightness_level, 0.0, self.brightness_cap);
|
||||
s.volume_level = clamp_safe(s.volume_level, 0.0, self.volume_cap);
|
||||
s.phase_offset_ms = clamp_safe(
|
||||
s.phase_offset_ms,
|
||||
-self.max_phase_offset_ms,
|
||||
self.max_phase_offset_ms,
|
||||
);
|
||||
// Duration must be strictly positive; floor at 1 minute.
|
||||
s.duration_minutes = clamp_safe(s.duration_minutes, 1.0, self.max_duration_minutes);
|
||||
s
|
||||
}
|
||||
|
||||
/// The calibration sweep grid (ADR-250 §8 Phase 1): integer-Hz steps across
|
||||
/// the band, intersected with the envelope. Returns frequencies in Hz.
|
||||
pub fn calibration_frequencies(&self) -> Vec<f64> {
|
||||
let lo = self.min_hz.ceil() as i32;
|
||||
let hi = self.max_hz.floor() as i32;
|
||||
(lo..=hi).map(|f| f as f64).collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "stimulus_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,149 @@
|
||||
//! Unit tests for `stimulus` — split out to keep stimulus.rs under 500 lines.
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn prior_is_inside_conservative_envelope() {
|
||||
let env = SafetyEnvelope::conservative();
|
||||
assert!(env.contains(&StimulusParameters::prior()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frequency_outside_band_is_rejected() {
|
||||
let env = SafetyEnvelope::conservative();
|
||||
let mut s = StimulusParameters::prior();
|
||||
s.frequency_hz = 50.0;
|
||||
let err = env.validate(&s).unwrap_err();
|
||||
assert!(matches!(err[0], EnvelopeViolation::Frequency { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn brightness_above_cap_is_rejected() {
|
||||
let env = SafetyEnvelope::conservative();
|
||||
let mut s = StimulusParameters::prior();
|
||||
s.brightness_level = 0.9;
|
||||
assert!(!env.contains(&s));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clamp_output_is_always_contained() {
|
||||
let env = SafetyEnvelope::conservative();
|
||||
let hostile = StimulusParameters {
|
||||
frequency_hz: 1000.0,
|
||||
modality: Modality::Visual,
|
||||
brightness_level: 5.0,
|
||||
volume_level: -2.0,
|
||||
duty_cycle: DutyCycle::Pulsed,
|
||||
phase_offset_ms: 999.0,
|
||||
duration_minutes: 1e6,
|
||||
};
|
||||
assert!(env.contains(&env.clamp(hostile)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clamp_neutralizes_nan() {
|
||||
let env = SafetyEnvelope::conservative();
|
||||
let mut s = StimulusParameters::prior();
|
||||
s.frequency_hz = f64::NAN;
|
||||
s.brightness_level = f64::INFINITY;
|
||||
let c = env.clamp(s);
|
||||
assert!(env.contains(&c));
|
||||
assert_eq!(c.frequency_hz, env.min_hz());
|
||||
assert_eq!(c.brightness_level, 0.0);
|
||||
}
|
||||
|
||||
// ---- Finding 3: absolute bounds are a property of construction ----
|
||||
|
||||
#[test]
|
||||
fn conservative_is_within_absolute_bounds() {
|
||||
let e = SafetyEnvelope::conservative();
|
||||
assert!(e.min_hz() >= absolute::MIN_HZ);
|
||||
assert!(e.max_hz() <= absolute::MAX_HZ);
|
||||
assert!(e.brightness_cap() <= absolute::BRIGHTNESS_CAP);
|
||||
assert!(e.volume_cap() <= absolute::VOLUME_CAP);
|
||||
assert!(e.max_duration_minutes() <= absolute::MAX_DURATION_MINUTES);
|
||||
// The whole conservative band clears the 15–25 Hz photosensitive zone.
|
||||
assert!(e.min_hz() > 25.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_new_rejects_photosensitive_band() {
|
||||
// An 18–22 Hz envelope is squarely inside the provocative band.
|
||||
let err = SafetyEnvelope::try_new(18.0, 22.0, 0.3, 0.3, 10.0, 0.0).unwrap_err();
|
||||
assert!(matches!(err, EnvelopeError::FrequencyFloor { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_new_rejects_uncapped_brightness_and_volume() {
|
||||
assert!(matches!(
|
||||
SafetyEnvelope::try_new(36.0, 44.0, 1.0, 0.3, 10.0, 0.0).unwrap_err(),
|
||||
EnvelopeError::BrightnessCap { .. }
|
||||
));
|
||||
assert!(matches!(
|
||||
SafetyEnvelope::try_new(36.0, 44.0, 0.3, 1.0, 10.0, 0.0).unwrap_err(),
|
||||
EnvelopeError::VolumeCap { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_new_rejects_million_minute_session() {
|
||||
assert!(matches!(
|
||||
SafetyEnvelope::try_new(36.0, 44.0, 0.3, 0.3, 1e6, 0.0).unwrap_err(),
|
||||
EnvelopeError::Duration { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_new_rejects_ceiling_and_inverted_band() {
|
||||
assert!(matches!(
|
||||
SafetyEnvelope::try_new(36.0, 80.0, 0.3, 0.3, 10.0, 0.0).unwrap_err(),
|
||||
EnvelopeError::FrequencyCeiling { .. }
|
||||
));
|
||||
assert!(matches!(
|
||||
SafetyEnvelope::try_new(44.0, 36.0, 0.3, 0.3, 10.0, 0.0).unwrap_err(),
|
||||
EnvelopeError::BandInverted { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialization_cannot_build_an_18_22hz_envelope() {
|
||||
// Hostile config places the band in the photosensitive zone — serde must
|
||||
// route through try_new and FAIL, not silently accept it.
|
||||
let hostile = r#"{
|
||||
"min_hz": 18.0, "max_hz": 22.0,
|
||||
"brightness_cap": 1.0, "volume_cap": 1.0,
|
||||
"max_duration_minutes": 1000000.0, "max_phase_offset_ms": 0.0
|
||||
}"#;
|
||||
let parsed: Result<SafetyEnvelope, _> = serde_json::from_str(hostile);
|
||||
assert!(
|
||||
parsed.is_err(),
|
||||
"deserialization must reject a photosensitive-band envelope"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_envelope_roundtrips_through_serde() {
|
||||
let e = SafetyEnvelope::conservative();
|
||||
let json = serde_json::to_string(&e).unwrap();
|
||||
let back: SafetyEnvelope = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(e, back);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_builders_revalidate_against_absolute_bounds() {
|
||||
let e = SafetyEnvelope::conservative();
|
||||
// Legal override succeeds.
|
||||
assert!(e.with_caps(0.5, 0.5).is_ok());
|
||||
// Illegal override (above the brightness ceiling) is refused.
|
||||
assert!(e.with_caps(0.9, 0.3).is_err());
|
||||
// Re-banding into the photosensitive zone is refused.
|
||||
assert!(e.with_band(18.0, 22.0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calibration_grid_is_36_to_44() {
|
||||
let env = SafetyEnvelope::conservative();
|
||||
let grid = env.calibration_frequencies();
|
||||
assert_eq!(grid.first(), Some(&36.0));
|
||||
assert_eq!(grid.last(), Some(&44.0));
|
||||
assert_eq!(grid.len(), 9);
|
||||
}
|
||||
@@ -563,6 +563,12 @@ impl crate::traits::CanonicalFrame for CsiFrame {
|
||||
/// (each fixed-width LE; `device_id` length-prefixed; `calibration_id` as
|
||||
/// 16 UUID bytes or 16 zero bytes for `None`) ‖ `(nrows, ncols)` as u32 LE
|
||||
/// ‖ complex payload as `ComplexSample::to_le_bytes()` in stream-major order.
|
||||
///
|
||||
/// # Panics
|
||||
/// If `calibration_id` is `Some(Uuid::nil())`: the nil UUID is the wire
|
||||
/// sentinel for `None`, so encoding it would alias two distinct frames to
|
||||
/// the same bytes (and the same witness hash) — a non-injective encoding
|
||||
/// is refused rather than silently produced.
|
||||
fn to_canonical_bytes(&self) -> Vec<u8> {
|
||||
let m = &self.metadata;
|
||||
// 16 (id) + ~48 (meta) + 8 (shape) + 16 * n_samples
|
||||
@@ -600,7 +606,17 @@ impl crate::traits::CanonicalFrame for CsiFrame {
|
||||
b.extend_from_slice(&m.noise_floor_dbm.to_le_bytes());
|
||||
b.extend_from_slice(&m.sequence_number.to_le_bytes());
|
||||
match m.calibration_id {
|
||||
Some(id) => b.extend_from_slice(id.as_bytes()),
|
||||
Some(id) => {
|
||||
// Some(nil) would alias the None sentinel on the wire: the
|
||||
// bytes would decode to a *different* frame (calibration_id
|
||||
// None) with the same witness. Refuse the non-injective
|
||||
// encoding (see the trait-impl `# Panics` doc).
|
||||
assert!(
|
||||
id != Uuid::nil(),
|
||||
"calibration_id Some(Uuid::nil()) is unencodable: nil is the None sentinel"
|
||||
);
|
||||
b.extend_from_slice(id.as_bytes());
|
||||
}
|
||||
None => b.extend_from_slice(&[0u8; 16]),
|
||||
}
|
||||
b.extend_from_slice(&m.model_id.to_le_bytes());
|
||||
@@ -616,6 +632,205 @@ impl crate::traits::CanonicalFrame for CsiFrame {
|
||||
}
|
||||
}
|
||||
|
||||
/// Errors decoding a frame from its canonical bytes.
|
||||
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
|
||||
pub enum CanonicalDecodeError {
|
||||
/// The buffer ended before the layout was fully read.
|
||||
#[error("canonical buffer truncated at byte {at} (need {need} more)")]
|
||||
Truncated {
|
||||
/// Byte offset where reading failed.
|
||||
at: usize,
|
||||
/// How many more bytes were needed.
|
||||
need: usize,
|
||||
},
|
||||
/// A discriminant byte held an unknown value.
|
||||
#[error("invalid {field} discriminant {value}")]
|
||||
BadDiscriminant {
|
||||
/// Which field failed.
|
||||
field: &'static str,
|
||||
/// The offending byte.
|
||||
value: u8,
|
||||
},
|
||||
/// The device-id bytes were not UTF-8.
|
||||
#[error("device id is not valid UTF-8")]
|
||||
BadDeviceId,
|
||||
/// Shape (nrows × ncols) disagrees with the remaining payload length.
|
||||
#[error("payload length mismatch: shape {rows}x{cols} needs {expect} bytes, found {found}")]
|
||||
PayloadMismatch {
|
||||
/// Declared rows.
|
||||
rows: usize,
|
||||
/// Declared cols.
|
||||
cols: usize,
|
||||
/// Bytes the shape implies.
|
||||
expect: usize,
|
||||
/// Bytes actually present.
|
||||
found: usize,
|
||||
},
|
||||
/// Trailing bytes after the declared payload.
|
||||
#[error("{0} trailing bytes after payload")]
|
||||
TrailingBytes(usize),
|
||||
/// A reserved region that must be all-zero held nonzero bytes. Accepting
|
||||
/// them would let two distinct byte strings decode to the same frame
|
||||
/// (re-encoding could not reproduce the original — forged bytes would be
|
||||
/// indistinguishable after a replay round-trip).
|
||||
#[error("reserved bytes for {field} must be zero")]
|
||||
ReservedNotZero {
|
||||
/// Which field's reserved region was nonzero.
|
||||
field: &'static str,
|
||||
},
|
||||
}
|
||||
|
||||
/// Byte cursor for the canonical layout.
|
||||
struct Cursor<'a> {
|
||||
b: &'a [u8],
|
||||
at: usize,
|
||||
}
|
||||
|
||||
impl<'a> Cursor<'a> {
|
||||
fn take(&mut self, n: usize) -> Result<&'a [u8], CanonicalDecodeError> {
|
||||
if self.b.len() - self.at < n {
|
||||
return Err(CanonicalDecodeError::Truncated {
|
||||
at: self.at,
|
||||
need: n - (self.b.len() - self.at),
|
||||
});
|
||||
}
|
||||
let s = &self.b[self.at..self.at + n];
|
||||
self.at += n;
|
||||
Ok(s)
|
||||
}
|
||||
fn u8(&mut self) -> Result<u8, CanonicalDecodeError> {
|
||||
Ok(self.take(1)?[0])
|
||||
}
|
||||
fn u16(&mut self) -> Result<u16, CanonicalDecodeError> {
|
||||
Ok(u16::from_le_bytes(self.take(2)?.try_into().unwrap()))
|
||||
}
|
||||
fn u32(&mut self) -> Result<u32, CanonicalDecodeError> {
|
||||
Ok(u32::from_le_bytes(self.take(4)?.try_into().unwrap()))
|
||||
}
|
||||
fn i64(&mut self) -> Result<i64, CanonicalDecodeError> {
|
||||
Ok(i64::from_le_bytes(self.take(8)?.try_into().unwrap()))
|
||||
}
|
||||
fn f32(&mut self) -> Result<f32, CanonicalDecodeError> {
|
||||
Ok(f32::from_le_bytes(self.take(4)?.try_into().unwrap()))
|
||||
}
|
||||
fn i8(&mut self) -> Result<i8, CanonicalDecodeError> {
|
||||
Ok(self.take(1)?[0] as i8)
|
||||
}
|
||||
fn uuid(&mut self) -> Result<Uuid, CanonicalDecodeError> {
|
||||
Ok(Uuid::from_bytes(self.take(16)?.try_into().unwrap()))
|
||||
}
|
||||
}
|
||||
|
||||
impl CsiFrame {
|
||||
/// Reconstruct a frame from its [`to_canonical_bytes`] encoding — the
|
||||
/// replay half of the ADR-136 contract. Round-trip law (tested):
|
||||
/// `from_canonical_bytes(f.to_canonical_bytes())` yields a frame with the
|
||||
/// **same id, metadata, payload, and witness hash** as `f`.
|
||||
///
|
||||
/// Amplitude/phase are recomputed from the complex payload (they are
|
||||
/// projections, not independent state).
|
||||
///
|
||||
/// [`to_canonical_bytes`]: crate::traits::CanonicalFrame::to_canonical_bytes
|
||||
///
|
||||
/// # Errors
|
||||
/// [`CanonicalDecodeError`] on truncation, bad discriminants, non-UTF-8
|
||||
/// device id, nonzero reserved bytes, shape/payload disagreement, or
|
||||
/// trailing bytes — every malformed input fails closed. Strictness
|
||||
/// guarantees injectivity on the accepted domain: any accepted byte
|
||||
/// string re-encodes to exactly itself.
|
||||
pub fn from_canonical_bytes(bytes: &[u8]) -> Result<Self, CanonicalDecodeError> {
|
||||
let mut c = Cursor { b: bytes, at: 0 };
|
||||
|
||||
let id = FrameId::from_uuid(c.uuid()?);
|
||||
|
||||
let seconds = c.i64()?;
|
||||
let nanos = c.u32()?;
|
||||
let dev_len = c.u32()? as usize;
|
||||
let device_id = core::str::from_utf8(c.take(dev_len)?)
|
||||
.map_err(|_| CanonicalDecodeError::BadDeviceId)?
|
||||
.to_string();
|
||||
let frequency_band = match c.u8()? {
|
||||
0 => FrequencyBand::Band2_4GHz,
|
||||
1 => FrequencyBand::Band5GHz,
|
||||
2 => FrequencyBand::Band6GHz,
|
||||
v => {
|
||||
return Err(CanonicalDecodeError::BadDiscriminant {
|
||||
field: "frequency_band",
|
||||
value: v,
|
||||
})
|
||||
}
|
||||
};
|
||||
let channel = c.u8()?;
|
||||
let bandwidth_mhz = c.u16()?;
|
||||
let tx_antennas = c.u8()?;
|
||||
let rx_antennas = c.u8()?;
|
||||
let spacing_mm = match c.u8()? {
|
||||
1 => Some(c.f32()?),
|
||||
0 => {
|
||||
// Reserved padding must be zero (decoder strictness =
|
||||
// injectivity on the accepted domain): otherwise forged
|
||||
// nonzero padding would decode to the same frame as the
|
||||
// canonical encoding and re-encode differently.
|
||||
if c.take(4)? != [0u8; 4] {
|
||||
return Err(CanonicalDecodeError::ReservedNotZero { field: "spacing_mm" });
|
||||
}
|
||||
None
|
||||
}
|
||||
v => {
|
||||
return Err(CanonicalDecodeError::BadDiscriminant {
|
||||
field: "spacing_mm",
|
||||
value: v,
|
||||
})
|
||||
}
|
||||
};
|
||||
let rssi_dbm = c.i8()?;
|
||||
let noise_floor_dbm = c.i8()?;
|
||||
let sequence_number = c.u32()?;
|
||||
let cal = c.uuid()?;
|
||||
let calibration_id = if cal == Uuid::nil() { None } else { Some(cal) };
|
||||
let model_id = c.u16()?;
|
||||
let model_version = c.u16()?;
|
||||
|
||||
let rows = c.u32()? as usize;
|
||||
let cols = c.u32()? as usize;
|
||||
let expect = rows.saturating_mul(cols).saturating_mul(16);
|
||||
let found = bytes.len() - c.at;
|
||||
if found < expect {
|
||||
return Err(CanonicalDecodeError::PayloadMismatch { rows, cols, expect, found });
|
||||
}
|
||||
let mut samples = Vec::with_capacity(rows * cols);
|
||||
for _ in 0..rows * cols {
|
||||
let raw: [u8; 16] = c.take(16)?.try_into().unwrap();
|
||||
samples.push(ComplexSample::from_le_bytes(raw).0);
|
||||
}
|
||||
if c.at != bytes.len() {
|
||||
return Err(CanonicalDecodeError::TrailingBytes(bytes.len() - c.at));
|
||||
}
|
||||
let data = Array2::from_shape_vec((rows, cols), samples).map_err(|_| {
|
||||
CanonicalDecodeError::PayloadMismatch { rows, cols, expect, found }
|
||||
})?;
|
||||
|
||||
let metadata = CsiMetadata {
|
||||
timestamp: Timestamp { seconds, nanos },
|
||||
device_id: DeviceId::new(device_id),
|
||||
frequency_band,
|
||||
channel,
|
||||
bandwidth_mhz,
|
||||
antenna_config: AntennaConfig { tx_antennas, rx_antennas, spacing_mm },
|
||||
rssi_dbm,
|
||||
noise_floor_dbm,
|
||||
sequence_number,
|
||||
calibration_id,
|
||||
model_id,
|
||||
model_version,
|
||||
};
|
||||
|
||||
let amplitude = data.mapv(num_complex::Complex::norm);
|
||||
let phase = data.mapv(num_complex::Complex::arg);
|
||||
Ok(Self { id, metadata, data, amplitude, phase })
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Signal Types
|
||||
// =============================================================================
|
||||
@@ -1307,6 +1522,133 @@ mod tests {
|
||||
assert_ne!(frame.witness_hash(), frame2.witness_hash());
|
||||
}
|
||||
|
||||
/// AC7 — replay: `from_canonical_bytes` is the exact inverse of
|
||||
/// `to_canonical_bytes` — same id, metadata, payload, and witness hash.
|
||||
/// This is the capture-to-claim law: a stored canonical capture replays to
|
||||
/// a frame the pipeline cannot distinguish from the original.
|
||||
#[test]
|
||||
fn ac7_canonical_round_trip_replays_identically() {
|
||||
use ndarray::Array2;
|
||||
let mut meta = CsiMetadata::new(DeviceId::new("node-α"), FrequencyBand::Band6GHz, 37);
|
||||
meta.set_calibration(uuid::Uuid::new_v4());
|
||||
meta.set_model(9, 0x0203);
|
||||
meta.antenna_config.spacing_mm = Some(62.5);
|
||||
meta.rssi_dbm = -41;
|
||||
meta.sequence_number = 123_456;
|
||||
let data = Array2::from_shape_fn((2, 56), |(r, c)| {
|
||||
Complex64::new((r as f64 + 1.0) * (c as f64).cos(), (c as f64 * 0.1).tan())
|
||||
});
|
||||
let frame = CsiFrame::new(meta, data);
|
||||
|
||||
let bytes = frame.to_canonical_bytes();
|
||||
let replayed = CsiFrame::from_canonical_bytes(&bytes).expect("decodes");
|
||||
|
||||
assert_eq!(replayed.id, frame.id);
|
||||
// Field-wise metadata equality (CsiMetadata has no PartialEq; the
|
||||
// byte-identical re-encoding below covers every field regardless).
|
||||
assert_eq!(replayed.metadata.device_id, frame.metadata.device_id);
|
||||
assert_eq!(replayed.metadata.calibration_id, frame.metadata.calibration_id);
|
||||
assert_eq!(replayed.metadata.model_version, frame.metadata.model_version);
|
||||
assert_eq!(replayed.metadata.antenna_config.spacing_mm, Some(62.5));
|
||||
assert_eq!(replayed.data, frame.data);
|
||||
// Witness equality — the strongest statement of equivalence.
|
||||
assert_eq!(replayed.witness_hash(), frame.witness_hash());
|
||||
// Re-encoding is byte-identical.
|
||||
assert_eq!(replayed.to_canonical_bytes(), bytes);
|
||||
// Projections recomputed consistently.
|
||||
assert_eq!(replayed.amplitude, frame.amplitude);
|
||||
}
|
||||
|
||||
/// AC8 — the decoder fails closed on every malformed-input class.
|
||||
#[test]
|
||||
fn ac8_canonical_decode_fails_closed() {
|
||||
use ndarray::Array2;
|
||||
let meta = CsiMetadata::new(DeviceId::new("n"), FrequencyBand::Band2_4GHz, 1);
|
||||
let data = Array2::from_shape_fn((1, 4), |(_, c)| Complex64::new(c as f64, 0.0));
|
||||
let frame = CsiFrame::new(meta, data);
|
||||
let bytes = frame.to_canonical_bytes();
|
||||
|
||||
// Truncation anywhere fails: in the payload it is caught by the
|
||||
// shape-vs-length check (PayloadMismatch); in the header by Truncated.
|
||||
assert!(matches!(
|
||||
CsiFrame::from_canonical_bytes(&bytes[..bytes.len() - 1]),
|
||||
Err(CanonicalDecodeError::PayloadMismatch { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
CsiFrame::from_canonical_bytes(&bytes[..10]),
|
||||
Err(CanonicalDecodeError::Truncated { .. })
|
||||
));
|
||||
|
||||
// Trailing junk fails.
|
||||
let mut padded = bytes.clone();
|
||||
padded.extend_from_slice(&[0u8; 3]);
|
||||
assert!(matches!(
|
||||
CsiFrame::from_canonical_bytes(&padded),
|
||||
Err(CanonicalDecodeError::TrailingBytes(3))
|
||||
));
|
||||
|
||||
// Bad frequency-band discriminant fails. Band byte sits right after
|
||||
// id(16) + seconds(8) + nanos(4) + dev_len(4) + dev("n" = 1).
|
||||
let mut bad = bytes.clone();
|
||||
bad[16 + 8 + 4 + 4 + 1] = 9;
|
||||
assert!(matches!(
|
||||
CsiFrame::from_canonical_bytes(&bad),
|
||||
Err(CanonicalDecodeError::BadDiscriminant { field: "frequency_band", value: 9 })
|
||||
));
|
||||
|
||||
// A nil calibration uuid decodes as None (the documented encoding).
|
||||
let replayed = CsiFrame::from_canonical_bytes(&bytes).unwrap();
|
||||
assert_eq!(replayed.metadata.calibration_id, None);
|
||||
}
|
||||
|
||||
/// AC8b (review finding 7) — decoder strictness = injectivity on the
|
||||
/// accepted domain: forged nonzero bytes in the `spacing_mm` reserved
|
||||
/// region are rejected, so for accepted inputs `re-encode != original`
|
||||
/// is impossible.
|
||||
#[test]
|
||||
fn ac8b_forged_reserved_spacing_bytes_rejected() {
|
||||
use ndarray::Array2;
|
||||
let meta = CsiMetadata::new(DeviceId::new("n"), FrequencyBand::Band2_4GHz, 1);
|
||||
let data = Array2::from_shape_fn((1, 4), |(_, c)| Complex64::new(c as f64, 0.0));
|
||||
let frame = CsiFrame::new(meta, data);
|
||||
let bytes = frame.to_canonical_bytes();
|
||||
|
||||
// Spacing tag sits after id(16)+secs(8)+nanos(4)+dev_len(4)+dev("n"=1)
|
||||
// + band(1)+channel(1)+bw(2)+tx(1)+rx(1); the 4 reserved bytes follow.
|
||||
let tag_off = 16 + 8 + 4 + 4 + 1 + 1 + 1 + 2 + 1 + 1;
|
||||
assert_eq!(bytes[tag_off], 0, "fixture must encode spacing_mm = None");
|
||||
assert_eq!(&bytes[tag_off + 1..tag_off + 5], &[0u8; 4]);
|
||||
|
||||
// Sanity: the canonical bytes decode and re-encode byte-identically.
|
||||
let ok = CsiFrame::from_canonical_bytes(&bytes).unwrap();
|
||||
assert_eq!(ok.to_canonical_bytes(), bytes);
|
||||
|
||||
// Forge each reserved byte: the decoder must fail closed (before the
|
||||
// fix it decoded to the same frame, whose re-encoding differed from
|
||||
// the forged original — a witness-replay ambiguity).
|
||||
for i in 1..=4 {
|
||||
let mut forged = bytes.clone();
|
||||
forged[tag_off + i] = 0xAB;
|
||||
assert!(matches!(
|
||||
CsiFrame::from_canonical_bytes(&forged),
|
||||
Err(CanonicalDecodeError::ReservedNotZero { field: "spacing_mm" })
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// AC8c (review finding 7) — `Some(Uuid::nil())` calibration is an
|
||||
/// encoding error: nil is the wire sentinel for `None`, so encoding it
|
||||
/// would alias two distinct frames to one byte string (and one witness).
|
||||
#[test]
|
||||
#[should_panic(expected = "nil is the None sentinel")]
|
||||
fn ac8c_nil_calibration_id_is_an_encoding_error() {
|
||||
use ndarray::Array2;
|
||||
let mut meta = CsiMetadata::new(DeviceId::new("n"), FrequencyBand::Band2_4GHz, 1);
|
||||
meta.calibration_id = Some(uuid::Uuid::nil());
|
||||
let data = Array2::from_shape_fn((1, 2), |(_, c)| Complex64::new(c as f64, 0.0));
|
||||
let _ = CsiFrame::new(meta, data).to_canonical_bytes();
|
||||
}
|
||||
|
||||
/// AC3 — `serde(default)` forward-read of pre-ADR-136 metadata JSON.
|
||||
#[cfg(feature = "serde")]
|
||||
#[test]
|
||||
|
||||
@@ -19,6 +19,9 @@ wifi-densepose-worldgraph = { version = "0.3.0", path = "../wifi-densepose-world
|
||||
wifi-densepose-geo = { version = "0.1.0", path = "../wifi-densepose-geo" }
|
||||
# Deterministic witness over the trust decision (ADR-137 §2.7 / ADR-028).
|
||||
blake3 = { version = "1.5", default-features = false }
|
||||
# Dynamic min-cut over the live mesh coupling graph (mesh_guard.rs):
|
||||
# incremental partition-risk monitoring + structural recalibration trigger.
|
||||
ruvector-mincut = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
criterion = { version = "0.5", features = ["html_reports"] }
|
||||
|
||||
@@ -48,5 +48,41 @@ fn bench_cycle(c: &mut Criterion) {
|
||||
});
|
||||
}
|
||||
|
||||
criterion_group!(benches, bench_cycle);
|
||||
/// Mesh guard in isolation: cold build (node set appears) vs steady state
|
||||
/// (identical weights next cycle → change-gated, zero graph updates) for a
|
||||
/// 12-node mesh — the full ADR-029 deployment size.
|
||||
fn bench_mesh_guard(c: &mut Criterion) {
|
||||
use wifi_densepose_engine::MeshGuard;
|
||||
let nodes: Vec<u8> = (0..12).collect();
|
||||
let w = |i: usize, j: usize| 0.4 + 0.01 * ((i + j) % 7) as f64;
|
||||
|
||||
c.bench_function("mesh_guard_cold_build_12n", |b| {
|
||||
b.iter_batched(
|
||||
MeshGuard::default,
|
||||
|mut g| g.update(&nodes, w),
|
||||
BatchSize::SmallInput,
|
||||
);
|
||||
});
|
||||
|
||||
c.bench_function("mesh_guard_steady_state_12n", |b| {
|
||||
let mut g = MeshGuard::default();
|
||||
g.update(&nodes, w); // warm
|
||||
b.iter(|| g.update(&nodes, w));
|
||||
});
|
||||
|
||||
c.bench_function("mesh_guard_one_edge_change_12n", |b| {
|
||||
let mut g = MeshGuard::default();
|
||||
g.update(&nodes, w);
|
||||
let mut flip = false;
|
||||
b.iter(|| {
|
||||
flip = !flip;
|
||||
let delta = if flip { 0.2 } else { 0.0 };
|
||||
g.update(&nodes, |i, j| {
|
||||
if (i.min(j), i.max(j)) == (0, 1) { 0.4 + delta } else { w(i, j) }
|
||||
})
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
criterion_group!(benches, bench_cycle, bench_mesh_guard);
|
||||
criterion_main!(benches);
|
||||
|
||||
@@ -46,6 +46,9 @@ use wifi_densepose_worldgraph::{
|
||||
WorldId, WorldNode, ZoneBoundsEnu,
|
||||
};
|
||||
|
||||
pub mod mesh_guard;
|
||||
pub use mesh_guard::{MeshGuard, MeshPartitionReport};
|
||||
|
||||
/// Errors from an engine cycle.
|
||||
#[derive(Debug)]
|
||||
pub enum EngineError {
|
||||
@@ -97,6 +100,15 @@ pub struct TrustedOutput {
|
||||
/// BLAKE3 witness over the trust decision (provenance ‖ class ‖ calibration)
|
||||
/// — a deterministic, signed-belief fingerprint (ADR-137 §2.7 / ADR-028).
|
||||
pub witness: [u8; 32],
|
||||
/// Whether the drift→recalibration advisor recommends re-running the
|
||||
/// ADR-135 baseline / refitting the per-room adapter (ADR-150 §3.4):
|
||||
/// sustained low coherence or an ADR-142 change-point this cycle.
|
||||
pub recalibration_recommended: bool,
|
||||
/// Dynamic min-cut partition report over the live mesh coupling graph
|
||||
/// (None for meshes of fewer than two nodes). `at_risk` counts as a
|
||||
/// structural event for the recalibration advisor and names the nodes
|
||||
/// (`weak_side`) closest to splitting off — failure/jamming triage.
|
||||
pub mesh: Option<MeshPartitionReport>,
|
||||
}
|
||||
|
||||
/// Composition root for the RuView streaming engine.
|
||||
@@ -116,6 +128,74 @@ pub struct StreamingEngine {
|
||||
slam: RfSlam,
|
||||
// ADR-139 live loop: stable track_id -> PersonTrack WorldId.
|
||||
person_tracks: BTreeMap<u64, WorldId>,
|
||||
// WorldGraph belief retention: max live SemanticState nodes. The live loop
|
||||
// appends one belief per cycle (1.7M/day at 20 Hz); durable history is the
|
||||
// recorder's job, so old beliefs are evicted deterministically past this cap.
|
||||
semantic_retention: usize,
|
||||
// Per-room calibration adapter (ADR-150 §3.4: ~11 KB LoRA on a frozen
|
||||
// base). Identity is part of the trust chain: when set, the adapter id is
|
||||
// appended to the provenance model_version, so swapping adapters changes
|
||||
// the witness. None = shared base model.
|
||||
adapter: Option<AdapterInfo>,
|
||||
// Drift→recalibration advisor (ADR-135 trigger for ADR-150 §3.4 refit).
|
||||
recal: RecalibrationAdvisor,
|
||||
// Dynamic min-cut mesh partition guard (incremental, change-gated).
|
||||
mesh: MeshGuard,
|
||||
}
|
||||
|
||||
/// Identity of an active per-room calibration adapter (ADR-150 §3.4). The id
|
||||
/// must be content-derived (e.g. a hash prefix of the adapter file) so the
|
||||
/// provenance/witness chain pins the exact weights that shaped inference.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AdapterInfo {
|
||||
/// Content-derived adapter identity (e.g. first 16 hex of its SHA-256).
|
||||
pub adapter_id: String,
|
||||
/// Number of in-room samples the adapter was fitted on (0 if unknown).
|
||||
pub trained_samples: u32,
|
||||
}
|
||||
|
||||
/// Recommends re-running calibration / adapter refit when the live signal
|
||||
/// degrades persistently (ADR-135 drift → ADR-150 §3.4 few-shot recalibration).
|
||||
///
|
||||
/// Two triggers, both cheap and deterministic:
|
||||
/// - `low_coherence_streak`: N consecutive cycles whose base coherence fell
|
||||
/// below the floor (sustained degradation, not a single bad frame);
|
||||
/// - any ADR-142 change-point this cycle (the environment itself changed).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RecalibrationAdvisor {
|
||||
/// Coherence below this counts toward the streak.
|
||||
pub coherence_floor: f32,
|
||||
/// Consecutive low-coherence cycles required to recommend recalibration.
|
||||
pub streak_threshold: u32,
|
||||
streak: u32,
|
||||
}
|
||||
|
||||
impl Default for RecalibrationAdvisor {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
coherence_floor: 0.5,
|
||||
streak_threshold: 60, // ~3 s at 20 Hz of sustained degradation
|
||||
streak: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RecalibrationAdvisor {
|
||||
/// Feed one cycle's evidence; returns whether recalibration is recommended.
|
||||
fn observe(&mut self, base_coherence: f32, change_point: bool) -> bool {
|
||||
if base_coherence < self.coherence_floor {
|
||||
self.streak = self.streak.saturating_add(1);
|
||||
} else {
|
||||
self.streak = 0;
|
||||
}
|
||||
change_point || self.streak >= self.streak_threshold
|
||||
}
|
||||
|
||||
/// Current consecutive low-coherence cycle count.
|
||||
#[must_use]
|
||||
pub fn streak(&self) -> u32 {
|
||||
self.streak
|
||||
}
|
||||
}
|
||||
|
||||
impl StreamingEngine {
|
||||
@@ -135,9 +215,53 @@ impl StreamingEngine {
|
||||
evolution: None,
|
||||
slam: RfSlam::with_discovery(0.5, 5, 0.6),
|
||||
person_tracks: BTreeMap::new(),
|
||||
semantic_retention: Self::DEFAULT_SEMANTIC_RETENTION,
|
||||
adapter: None,
|
||||
recal: RecalibrationAdvisor::default(),
|
||||
mesh: MeshGuard::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Activate a per-room calibration adapter (ADR-150 §3.4). From the next
|
||||
/// cycle on, the adapter id is part of provenance `model_version` — and
|
||||
/// therefore of the witness — so the exact weights shaping inference are
|
||||
/// pinned in the trust chain. Pass the result of hashing the adapter file.
|
||||
pub fn set_room_adapter(&mut self, info: AdapterInfo) {
|
||||
self.adapter = Some(info);
|
||||
}
|
||||
|
||||
/// Deactivate the adapter (revert to the shared base model).
|
||||
pub fn clear_room_adapter(&mut self) {
|
||||
self.adapter = None;
|
||||
}
|
||||
|
||||
/// The active adapter, if any.
|
||||
#[must_use]
|
||||
pub fn room_adapter(&self) -> Option<&AdapterInfo> {
|
||||
self.adapter.as_ref()
|
||||
}
|
||||
|
||||
/// Tune the drift→recalibration advisor (floor + streak threshold).
|
||||
pub fn set_recalibration_advisor(&mut self, advisor: RecalibrationAdvisor) {
|
||||
self.recal = advisor;
|
||||
}
|
||||
|
||||
/// Mutable access to the mesh partition guard (risk threshold, quantum,
|
||||
/// min-node count). Operators tune the partition-risk sensitivity here.
|
||||
pub fn mesh_guard_mut(&mut self) -> &mut MeshGuard {
|
||||
&mut self.mesh
|
||||
}
|
||||
|
||||
/// Default cap on live `SemanticState` beliefs in the WorldGraph
|
||||
/// (~6 minutes of full-rate history at 20 Hz; older beliefs are evicted —
|
||||
/// durable history belongs to the recorder).
|
||||
pub const DEFAULT_SEMANTIC_RETENTION: usize = 7_200;
|
||||
|
||||
/// Override the `SemanticState` retention cap (minimum 1).
|
||||
pub fn set_semantic_retention(&mut self, max_states: usize) {
|
||||
self.semantic_retention = max_states.max(1);
|
||||
}
|
||||
|
||||
/// ADR-139 live loop: create or update a `PersonTrack` node by stable
|
||||
/// `track_id`, locate it in `room`, and wire an `Observes` edge from
|
||||
/// `sensor` (so the privacy rollup can suppress it under identity-strict
|
||||
@@ -321,21 +445,47 @@ impl StreamingEngine {
|
||||
// 4. Evolution change-point (ADR-142) over per-node mean amplitude.
|
||||
let change_point = self.track_evolution(node_frames, now_ms, room);
|
||||
|
||||
// 5. Privacy control plane (ADR-141): demote on a fusion-level OR an
|
||||
// array-level contradiction (monotonic — information only removed).
|
||||
// 5. Mesh partition guard (ADR-032): dynamic min-cut over the coupling
|
||||
// graph. Coupling between nodes i and j is the product of their
|
||||
// fusion attention weights scaled by the node count, so a node the
|
||||
// fuser down-weights is exactly a node weakly coupled in the graph.
|
||||
// (Change-gated incremental updates: steady state touches 0 edges.)
|
||||
let node_ids: Vec<u8> = node_frames.iter().map(|f| f.node_id).collect();
|
||||
let weights = &quality.per_node_weights;
|
||||
let n = weights.len() as f64;
|
||||
let mesh = self.mesh.update(&node_ids, |i, j| {
|
||||
let wi = weights.get(i).copied().unwrap_or(0.0) as f64;
|
||||
let wj = weights.get(j).copied().unwrap_or(0.0) as f64;
|
||||
wi * wj * n
|
||||
});
|
||||
let mesh_at_risk = mesh.as_ref().is_some_and(|m| m.at_risk);
|
||||
|
||||
// 6. Privacy control plane (ADR-141): demote on a fusion-level OR an
|
||||
// array-level contradiction OR a mesh close to partitioning. The
|
||||
// last is a security/reliability signal (ADR-032): a fragmenting
|
||||
// array makes the fused belief less trustworthy, so we emit at a
|
||||
// more restricted class. Monotonic — information is only ever
|
||||
// removed — and the demotion is part of the witness.
|
||||
let base_class = self.privacy.active_class();
|
||||
let demoted = quality.forces_privacy_demotion() || array_contradiction;
|
||||
let demoted = quality.forces_privacy_demotion() || array_contradiction || mesh_at_risk;
|
||||
let effective_class = if demoted { demote_one(base_class) } else { base_class };
|
||||
|
||||
// 6. Semantic state with mandatory provenance (ADR-139/140). The
|
||||
// 7. Semantic state with mandatory provenance (ADR-139/140). The
|
||||
// calibration version comes from the *agreed* epoch (None on mismatch).
|
||||
// When a per-room adapter is active (ADR-150 §3.4) its content-derived
|
||||
// id is part of model_version — and therefore of the witness — so the
|
||||
// exact weights shaping inference are pinned in the trust chain.
|
||||
let calibration_version = match quality.calibration_id {
|
||||
Some(c) => format!("cal:{:016x}", c.0),
|
||||
None => "cal:none".to_string(),
|
||||
};
|
||||
let model_version = match &self.adapter {
|
||||
Some(a) => format!("rfenc-v{}+adapter:{}", self.model_version, a.adapter_id),
|
||||
None => format!("rfenc-v{}", self.model_version),
|
||||
};
|
||||
let provenance = SemanticProvenance {
|
||||
evidence: quality.evidence_refs.iter().map(|e| format!("{e:?}")).collect(),
|
||||
model_version: format!("rfenc-v{}", self.model_version),
|
||||
model_version,
|
||||
calibration_version,
|
||||
privacy_decision: format!("{:?}/{:?}", self.privacy.active_mode(), effective_class),
|
||||
};
|
||||
@@ -350,10 +500,23 @@ impl StreamingEngine {
|
||||
provenance.clone(),
|
||||
&[room],
|
||||
);
|
||||
// Retention: bound the live belief set (one node is appended per cycle;
|
||||
// without this the graph grows ~1.7M nodes/day at 20 Hz). Deterministic
|
||||
// eviction; the just-added belief is always newest and survives.
|
||||
self.world.prune_semantic_states(self.semantic_retention);
|
||||
|
||||
// 7. Deterministic witness over the trust decision (ADR-137 §2.7).
|
||||
// 8. Deterministic witness over the trust decision (ADR-137 §2.7).
|
||||
// `effective_class` already reflects any mesh-risk demotion, so a
|
||||
// fragmenting array shifts the witness — partition risk is auditable.
|
||||
let witness = witness_of(&provenance, effective_class);
|
||||
|
||||
// 9. Drift→recalibration advisor (ADR-135 → ADR-150 §3.4): sustained
|
||||
// low coherence, an environment change-point, or a mesh close to
|
||||
// partitioning recommends refit.
|
||||
let recalibration_recommended = self
|
||||
.recal
|
||||
.observe(quality.base_coherence, change_point.is_some() || mesh_at_risk);
|
||||
|
||||
self.cycle += 1;
|
||||
Ok(TrustedOutput {
|
||||
semantic_id,
|
||||
@@ -364,6 +527,8 @@ impl StreamingEngine {
|
||||
directional,
|
||||
change_point,
|
||||
witness,
|
||||
recalibration_recommended,
|
||||
mesh,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -547,6 +712,205 @@ mod tests {
|
||||
assert_eq!(o1.quality.per_node_weights, o2.quality.per_node_weights);
|
||||
}
|
||||
|
||||
/// ADR-150 §3.4 adapter provenance: activating a per-room adapter changes
|
||||
/// the provenance model_version AND the witness — the exact weights shaping
|
||||
/// inference are pinned in the trust chain, so an adapter can never swap
|
||||
/// silently. Clearing it restores the base identity (and base witness).
|
||||
#[test]
|
||||
fn adapter_identity_is_witnessed() {
|
||||
let cal = CalibrationId(9);
|
||||
let frames = [node_frame(0, 1000, 56), node_frame(1, 1001, 56)];
|
||||
|
||||
let (mut e, room) = engine();
|
||||
let base = e.process_cycle(&frames, cal, room, 1_000).unwrap();
|
||||
assert_eq!(base.provenance.model_version, "rfenc-v1");
|
||||
|
||||
e.set_room_adapter(AdapterInfo {
|
||||
adapter_id: "a1b2c3d4e5f60718".into(),
|
||||
trained_samples: 150,
|
||||
});
|
||||
let adapted = e.process_cycle(&frames, cal, room, 2_000).unwrap();
|
||||
assert_eq!(
|
||||
adapted.provenance.model_version,
|
||||
"rfenc-v1+adapter:a1b2c3d4e5f60718"
|
||||
);
|
||||
assert_ne!(adapted.witness, base.witness, "adapter must shift the witness");
|
||||
|
||||
// A different adapter id yields a different witness again.
|
||||
e.set_room_adapter(AdapterInfo {
|
||||
adapter_id: "ffffffffffffffff".into(),
|
||||
trained_samples: 150,
|
||||
});
|
||||
let other = e.process_cycle(&frames, cal, room, 3_000).unwrap();
|
||||
assert_ne!(other.witness, adapted.witness);
|
||||
|
||||
// Clearing restores the base identity and the base witness.
|
||||
e.clear_room_adapter();
|
||||
let back = e.process_cycle(&frames, cal, room, 4_000).unwrap();
|
||||
assert_eq!(back.provenance.model_version, "rfenc-v1");
|
||||
assert_eq!(back.witness, base.witness);
|
||||
}
|
||||
|
||||
/// Drift→recalibration advisor logic: a sustained low-coherence streak
|
||||
/// recommends refit; a single healthy cycle resets the streak; a
|
||||
/// change-point recommends immediately regardless of streak.
|
||||
#[test]
|
||||
fn recalibration_advisor_streak_and_change_point() {
|
||||
let mut adv = RecalibrationAdvisor {
|
||||
coherence_floor: 0.5,
|
||||
streak_threshold: 3,
|
||||
..Default::default()
|
||||
};
|
||||
// Healthy cycles never recommend and keep the streak at zero.
|
||||
for _ in 0..5 {
|
||||
assert!(!adv.observe(0.9, false));
|
||||
}
|
||||
assert_eq!(adv.streak(), 0);
|
||||
// Two low cycles: not yet.
|
||||
assert!(!adv.observe(0.2, false));
|
||||
assert!(!adv.observe(0.2, false));
|
||||
// Third consecutive low cycle: fire.
|
||||
assert!(adv.observe(0.2, false));
|
||||
// Recovery resets the streak.
|
||||
assert!(!adv.observe(0.9, false));
|
||||
assert_eq!(adv.streak(), 0);
|
||||
// A change-point recommends immediately, even at full coherence.
|
||||
assert!(adv.observe(0.9, true));
|
||||
}
|
||||
|
||||
/// Engine-level: clean coherent cycles never recommend recalibration (the
|
||||
/// advisor is wired into process_cycle and stays quiet on healthy input).
|
||||
#[test]
|
||||
fn healthy_cycles_do_not_recommend_recalibration() {
|
||||
let (mut e, room) = engine();
|
||||
e.set_recalibration_advisor(RecalibrationAdvisor {
|
||||
coherence_floor: 0.5,
|
||||
streak_threshold: 3,
|
||||
..Default::default()
|
||||
});
|
||||
let cal = CalibrationId(2);
|
||||
for i in 0..5u64 {
|
||||
let frames = [
|
||||
node_frame(0, 1_000 + i * 50_000, 56),
|
||||
node_frame(1, 1_001 + i * 50_000, 56),
|
||||
];
|
||||
let out = e.process_cycle(&frames, cal, room, i as i64).unwrap();
|
||||
assert!(!out.recalibration_recommended);
|
||||
}
|
||||
}
|
||||
|
||||
/// Maximum total coupling mass of an n-node mesh whose attention weights
|
||||
/// sum to 1 (coupling = wᵢ·wⱼ·n): Σ_{i<j} wᵢwⱼ·n = n(1−Σwᵢ²)/2 ≤ (n−1)/2.
|
||||
/// Any cut is a subset of the edges, so every achievable cut value is
|
||||
/// bounded by this mass — a risk threshold at or above it is *guaranteed*
|
||||
/// to be crossed (deterministic fixture, review finding 4).
|
||||
fn max_coupling_mass(n_nodes: usize) -> f64 {
|
||||
(n_nodes as f64 - 1.0) / 2.0
|
||||
}
|
||||
|
||||
/// Mesh guard wiring: a balanced 2-node cycle reports a mesh (cut exists)
|
||||
/// but never flags risk (min_nodes=3); a 3-node mesh whose cut value
|
||||
/// *deterministically* falls at or below the configured risk threshold
|
||||
/// (threshold = the provable upper bound on any achievable cut) is flagged
|
||||
/// at_risk, and the structural event feeds the recalibration advisor
|
||||
/// immediately — no conditional assertions (review finding 4).
|
||||
#[test]
|
||||
fn mesh_partition_risk_feeds_recalibration() {
|
||||
let (mut e, room) = engine();
|
||||
let cal = CalibrationId(3);
|
||||
|
||||
// Balanced 2-node mesh: report present, no risk.
|
||||
let out = e
|
||||
.process_cycle(&[node_frame(0, 1000, 56), node_frame(1, 1001, 56)], cal, room, 1)
|
||||
.unwrap();
|
||||
let mesh = out.mesh.expect("2-node mesh reports");
|
||||
assert!(!mesh.at_risk);
|
||||
assert!(!out.recalibration_recommended);
|
||||
|
||||
// 3-node mesh with the operator risk threshold set to the provable
|
||||
// cut upper bound: the crossing is deterministic regardless of the
|
||||
// fuser's exact weighting.
|
||||
e.mesh_guard_mut().risk_threshold = max_coupling_mass(3);
|
||||
let frames = [
|
||||
node_frame(0, 10_000_000, 56),
|
||||
node_frame(1, 10_000_001, 56),
|
||||
node_frame(2, 10_000_002, 56),
|
||||
];
|
||||
let out3 = e.process_cycle(&frames, cal, room, 2).unwrap();
|
||||
let m3 = out3.mesh.expect("3-node mesh reports");
|
||||
assert!(m3.at_risk, "cut ≤ threshold must flag partition risk");
|
||||
assert!(
|
||||
out3.recalibration_recommended,
|
||||
"mesh risk is a structural event — the advisor must fire immediately, no streak"
|
||||
);
|
||||
assert!(m3.cut_value.is_finite() && m3.cut_value >= 0.0);
|
||||
}
|
||||
|
||||
/// Mesh partition risk demotes the privacy class and shifts the witness —
|
||||
/// a fragmenting array makes the fused belief less trustworthy, so it is
|
||||
/// emitted at a more restricted class, and that demotion is auditable.
|
||||
/// Both cycles use the *same 3-node topology and frames*; the engines
|
||||
/// differ only in the forced mesh risk, so the witness delta is
|
||||
/// attributable to the risk demotion alone (review finding 4).
|
||||
#[test]
|
||||
fn mesh_risk_demotes_privacy_and_shifts_witness() {
|
||||
let cal = CalibrationId(8);
|
||||
let frames3 = [
|
||||
node_frame(0, 1000, 56),
|
||||
node_frame(1, 1001, 56),
|
||||
node_frame(2, 1002, 56),
|
||||
];
|
||||
|
||||
// Baseline: same topology, default risk threshold — clean cycle, not
|
||||
// demoted (PrivateHome → Anonymous), mesh healthy.
|
||||
let (mut e1, r1) = engine();
|
||||
let base = e1.process_cycle(&frames3, cal, r1, 5_000).unwrap();
|
||||
assert!(!base.mesh.as_ref().unwrap().at_risk);
|
||||
assert!(!base.demoted);
|
||||
assert_eq!(base.effective_class, PrivacyClass::Anonymous);
|
||||
|
||||
// Forced risk: identical frames/topology, threshold at the provable
|
||||
// cut upper bound so the crossing is deterministic.
|
||||
let (mut e2, r2) = engine();
|
||||
e2.mesh_guard_mut().risk_threshold = max_coupling_mass(3);
|
||||
let risky = e2.process_cycle(&frames3, cal, r2, 5_000).unwrap();
|
||||
assert!(risky.mesh.as_ref().unwrap().at_risk);
|
||||
assert!(risky.demoted, "mesh risk must demote");
|
||||
// PrivateHome base Anonymous(2) → demoted to Restricted(3).
|
||||
assert_eq!(risky.effective_class, PrivacyClass::Restricted);
|
||||
assert!(risky.provenance.privacy_decision.contains("Restricted"));
|
||||
assert_ne!(
|
||||
risky.witness, base.witness,
|
||||
"same topology, risk-only delta must shift the witness"
|
||||
);
|
||||
}
|
||||
|
||||
/// WorldGraph belief retention: the live loop appends one SemanticState per
|
||||
/// cycle; past the cap the oldest beliefs are evicted so graph memory is
|
||||
/// bounded, while structural nodes and the newest belief always survive.
|
||||
#[test]
|
||||
fn semantic_state_growth_is_bounded() {
|
||||
let (mut e, room) = engine();
|
||||
e.set_semantic_retention(5);
|
||||
let cal = CalibrationId(1);
|
||||
let mut last_id = None;
|
||||
let baseline_nodes = 2; // room + sensor
|
||||
for i in 0..20u64 {
|
||||
let frames = [
|
||||
node_frame(0, 1000 + i * 50_000, 56),
|
||||
node_frame(1, 1001 + i * 50_000, 56),
|
||||
];
|
||||
let out = e.process_cycle(&frames, cal, room, 5_000 + i as i64).unwrap();
|
||||
last_id = Some(out.semantic_id);
|
||||
assert!(e.world().node_count() <= baseline_nodes + 5);
|
||||
}
|
||||
// 20 cycles ran, only 5 beliefs remain, newest is still present.
|
||||
assert_eq!(e.world().node_count(), baseline_nodes + 5);
|
||||
assert!(e.world().node(last_id.unwrap()).is_some());
|
||||
// Structural nodes survive eviction.
|
||||
assert!(e.world().node(room).is_some());
|
||||
}
|
||||
|
||||
fn node_frame_scaled(node_id: u8, ts_us: u64, n_sub: usize, scale: f32) -> MultiBandCsiFrame {
|
||||
MultiBandCsiFrame {
|
||||
node_id,
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
//! Mesh partition guard: dynamic min-cut over the live multistatic node graph.
|
||||
//!
|
||||
//! The fusion mesh (nodes = sensing nodes, edge weights = fusion coupling
|
||||
//! derived from per-node attention weights) changes *incrementally* at cycle
|
||||
//! rate — one node's coupling drifts, a node joins or drops. This module
|
||||
//! maintains a [`ruvector_mincut::DynamicMinCut`] over that graph and exposes,
|
||||
//! per cycle:
|
||||
//!
|
||||
//! - the **min-cut value** — the cheapest set of couplings whose loss splits
|
||||
//! the mesh in two: a principled, global "how close is the array to
|
||||
//! partitioning" number (vs per-node heuristics that miss multi-node
|
||||
//! structure);
|
||||
//! - the **weak side** — which specific nodes are about to partition (feeds
|
||||
//! failure/jamming triage, ADR-032 posture);
|
||||
//! - an **at-risk flag** consumed by the engine: it counts as a structural
|
||||
//! event for the drift→recalibration advisor.
|
||||
//!
|
||||
//! ## Cost model (the optimization)
|
||||
//!
|
||||
//! Weights are quantized (default 1/64; a *nonzero* coupling below one quantum
|
||||
//! saturates to quantum 1 so a live coupling is never erased — see
|
||||
//! [`MeshGuard::weight_quantum`]) and updates are **change-gated**: an
|
||||
//! edge is touched only when its quantized weight actually moves, so the
|
||||
//! steady-state cycle applies *zero* graph updates and reuses the cached cut —
|
||||
//! O(active-changes) per cycle, not O(n²) rebuilds. The exact (deterministic)
|
||||
//! algorithm is used; mesh sizes are ≤ tens of nodes, far inside its budget.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use ruvector_mincut::{DynamicMinCut, MinCutBuilder};
|
||||
|
||||
/// Per-cycle report from the mesh guard.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct MeshPartitionReport {
|
||||
/// Current min-cut value over the coupling graph (higher = more robust).
|
||||
pub cut_value: f64,
|
||||
/// True when the mesh has ≥ `min_nodes` nodes and the cut value fell to or
|
||||
/// below the risk threshold — the array is close to splitting.
|
||||
pub at_risk: bool,
|
||||
/// The smaller side of the min-cut partition (node ids): the nodes that
|
||||
/// would be isolated if the weak couplings failed.
|
||||
pub weak_side: Vec<u8>,
|
||||
/// Incremental edge updates applied this cycle (0 in steady state).
|
||||
pub updates_applied: usize,
|
||||
}
|
||||
|
||||
/// Dynamic min-cut guard over the live mesh.
|
||||
pub struct MeshGuard {
|
||||
mincut: Option<DynamicMinCut>,
|
||||
/// Node set the structure was built over (sorted). A change forces rebuild.
|
||||
nodes: Vec<u8>,
|
||||
/// Quantized edge weights currently installed, keyed `(u, v)` with `u < v`.
|
||||
edges: BTreeMap<(u8, u8), i64>,
|
||||
/// Weight quantum: weights are snapped to multiples of this before
|
||||
/// comparison/installation, gating out sub-quantum jitter.
|
||||
///
|
||||
/// Policy: a **nonzero** coupling below one quantum saturates to quantum 1
|
||||
/// instead of quantizing to 0 — quantization never erases a live coupling.
|
||||
/// (Without the floor, a balanced mesh of ≥ 65 nodes — attention weights
|
||||
/// ~1/n ⇒ couplings ~1/n < 1/64 — had every edge erased and was reported
|
||||
/// permanently "already partitioned"/at-risk.) Exact zero stays zero: a
|
||||
/// truly absent coupling *is* a partition. Relative weakness below one
|
||||
/// quantum is not resolved; lower this quantum if that resolution matters.
|
||||
pub weight_quantum: f64,
|
||||
/// Cut value at or below which the mesh counts as at partition risk.
|
||||
pub risk_threshold: f64,
|
||||
/// Minimum node count for risk to be meaningful (a 2-node mesh always has
|
||||
/// a trivial cut; default 3).
|
||||
pub min_nodes: usize,
|
||||
}
|
||||
|
||||
impl Default for MeshGuard {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
mincut: None,
|
||||
nodes: Vec::new(),
|
||||
edges: BTreeMap::new(),
|
||||
weight_quantum: 1.0 / 64.0,
|
||||
risk_threshold: 0.25,
|
||||
min_nodes: 3,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MeshGuard {
|
||||
/// Quantize a raw weight to the guard's grid (floor; weights are ≥ 0).
|
||||
/// Nonzero sub-quantum weights saturate to quantum 1 — see the
|
||||
/// [`Self::weight_quantum`] policy (review finding: sub-quantum couplings
|
||||
/// must not produce a false "already partitioned").
|
||||
fn quantize(&self, w: f64) -> i64 {
|
||||
let w = w.max(0.0);
|
||||
let q = (w / self.weight_quantum).floor() as i64;
|
||||
if q == 0 && w > 0.0 {
|
||||
1
|
||||
} else {
|
||||
q
|
||||
}
|
||||
}
|
||||
|
||||
/// Update the guard with this cycle's mesh: `nodes` are the contributing
|
||||
/// node ids and `coupling(i, j)` returns the fusion coupling between
|
||||
/// `nodes[i]` and `nodes[j]` (symmetric, ≥ 0).
|
||||
///
|
||||
/// Returns `None` for meshes of fewer than 2 nodes (no cut exists).
|
||||
pub fn update(
|
||||
&mut self,
|
||||
nodes: &[u8],
|
||||
coupling: impl Fn(usize, usize) -> f64,
|
||||
) -> Option<MeshPartitionReport> {
|
||||
if nodes.len() < 2 {
|
||||
// Mesh degenerated: drop state so a later rebuild starts clean.
|
||||
self.mincut = None;
|
||||
self.nodes.clear();
|
||||
self.edges.clear();
|
||||
return None;
|
||||
}
|
||||
let mut sorted: Vec<u8> = nodes.to_vec();
|
||||
sorted.sort_unstable();
|
||||
sorted.dedup();
|
||||
|
||||
// Desired quantized edge set for this cycle.
|
||||
let mut desired: BTreeMap<(u8, u8), i64> = BTreeMap::new();
|
||||
for i in 0..nodes.len() {
|
||||
for j in (i + 1)..nodes.len() {
|
||||
let (a, b) = if nodes[i] < nodes[j] {
|
||||
(nodes[i], nodes[j])
|
||||
} else {
|
||||
(nodes[j], nodes[i])
|
||||
};
|
||||
if a == b {
|
||||
continue;
|
||||
}
|
||||
let q = self.quantize(coupling(i, j));
|
||||
desired.insert((a, b), q);
|
||||
}
|
||||
}
|
||||
|
||||
// Change detection: count quantized-weight moves vs the installed set.
|
||||
let changed = if self.mincut.is_none() || self.nodes != sorted {
|
||||
usize::MAX // node set changed / first cycle: rebuild unconditionally
|
||||
} else {
|
||||
desired
|
||||
.iter()
|
||||
.filter(|(k, &q)| self.edges.get(k).copied().unwrap_or(0) != q)
|
||||
.count()
|
||||
};
|
||||
|
||||
let mut updates = 0usize;
|
||||
if changed > 0 {
|
||||
// Measured policy (criterion, 12-node mesh): a full exact rebuild
|
||||
// is ~170 µs while ONE DynamicMinCut delete+insert is ~240 µs —
|
||||
// the incremental machinery's overheads target much larger graphs.
|
||||
// At mesh scale the optimum is: change-gate aggressively (the
|
||||
// steady state below is ~7 µs and covers almost every cycle) and
|
||||
// rebuild whenever anything actually moved.
|
||||
let edges: Vec<(u64, u64, f64)> = desired
|
||||
.iter()
|
||||
.filter(|(_, &q)| q > 0)
|
||||
.map(|(&(a, b), &q)| {
|
||||
(u64::from(a), u64::from(b), q as f64 * self.weight_quantum)
|
||||
})
|
||||
.collect();
|
||||
updates = if changed == usize::MAX { edges.len() } else { changed };
|
||||
self.mincut = MinCutBuilder::new().exact().with_edges(edges).build().ok();
|
||||
self.nodes = sorted;
|
||||
self.edges = desired;
|
||||
}
|
||||
// changed == 0: steady state — zero graph work, cached cut reused.
|
||||
|
||||
// Nodes with no positive coupling never enter the cut structure (zero
|
||||
// edges are not installed) — they are already partitioned. Report them
|
||||
// as the degenerate cut before consulting the structure.
|
||||
let mut isolated: Vec<u8> = self
|
||||
.nodes
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|&v| {
|
||||
!self
|
||||
.edges
|
||||
.iter()
|
||||
.any(|(&(a, b), &q)| q > 0 && (a == v || b == v))
|
||||
})
|
||||
.collect();
|
||||
if !isolated.is_empty() {
|
||||
isolated.sort_unstable();
|
||||
return Some(MeshPartitionReport {
|
||||
cut_value: 0.0,
|
||||
at_risk: self.nodes.len() >= self.min_nodes,
|
||||
weak_side: isolated,
|
||||
updates_applied: updates,
|
||||
});
|
||||
}
|
||||
|
||||
let mc = self.mincut.as_ref()?;
|
||||
// A disconnected coupling graph is the degenerate cut: value 0.
|
||||
let cut_value = if mc.is_connected() { mc.min_cut_value() } else { 0.0 };
|
||||
let (side_a, side_b) = mc.partition();
|
||||
let weak_raw = if side_a.len() <= side_b.len() { side_a } else { side_b };
|
||||
let mut weak_side: Vec<u8> = weak_raw.into_iter().map(|v| v as u8).collect();
|
||||
weak_side.sort_unstable();
|
||||
let at_risk = self.nodes.len() >= self.min_nodes && cut_value <= self.risk_threshold;
|
||||
|
||||
Some(MeshPartitionReport { cut_value, at_risk, weak_side, updates_applied: updates })
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Triangle with one weakly-attached node: the cut isolates that node and
|
||||
/// the cut value equals its total coupling.
|
||||
#[test]
|
||||
fn weakly_attached_node_is_the_weak_side() {
|
||||
let mut g = MeshGuard::default();
|
||||
let nodes = [0u8, 1, 2];
|
||||
// 0–1 strongly coupled; node 2 hangs on by 0.05 + 0.05.
|
||||
let w = |i: usize, j: usize| match (i.min(j), i.max(j)) {
|
||||
(0, 1) => 1.0,
|
||||
_ => 0.05,
|
||||
};
|
||||
let r = g.update(&nodes, w).expect("3-node mesh");
|
||||
assert!(r.cut_value <= 0.13, "cut {} should be ~0.10", r.cut_value);
|
||||
assert_eq!(r.weak_side, vec![2]);
|
||||
assert!(r.at_risk, "weak coupling must flag partition risk");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strong_mesh_is_not_at_risk() {
|
||||
let mut g = MeshGuard::default();
|
||||
let r = g.update(&[0, 1, 2, 3], |_, _| 0.9).expect("mesh");
|
||||
assert!(r.cut_value > g.risk_threshold);
|
||||
assert!(!r.at_risk);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_node_mesh_reports_but_never_risks() {
|
||||
let mut g = MeshGuard::default();
|
||||
let r = g.update(&[0, 1], |_, _| 0.01).expect("2-node mesh");
|
||||
// Trivial cut exists but min_nodes=3 keeps the flag off.
|
||||
assert!(!r.at_risk);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fewer_than_two_nodes_yields_none() {
|
||||
let mut g = MeshGuard::default();
|
||||
assert!(g.update(&[7], |_, _| 1.0).is_none());
|
||||
assert!(g.update(&[], |_, _| 1.0).is_none());
|
||||
}
|
||||
|
||||
/// The optimization contract: identical weights on the next cycle apply
|
||||
/// zero updates; a sub-quantum wiggle also applies zero; a real change
|
||||
/// applies exactly the changed edges.
|
||||
#[test]
|
||||
fn steady_state_applies_zero_updates() {
|
||||
let mut g = MeshGuard::default();
|
||||
let nodes = [0u8, 1, 2, 3];
|
||||
let first = g.update(&nodes, |_, _| 0.5).unwrap();
|
||||
assert_eq!(first.updates_applied, 6); // cold build installs all edges
|
||||
|
||||
let second = g.update(&nodes, |_, _| 0.5).unwrap();
|
||||
assert_eq!(second.updates_applied, 0);
|
||||
|
||||
// Sub-quantum jitter (quantum is 1/64 ≈ 0.0156) is gated out.
|
||||
let third = g.update(&nodes, |_, _| 0.5 + 0.004).unwrap();
|
||||
assert_eq!(third.updates_applied, 0);
|
||||
|
||||
// One genuinely changed edge touches exactly one edge.
|
||||
let fourth = g
|
||||
.update(&nodes, |i, j| if (i.min(j), i.max(j)) == (0, 1) { 0.1 } else { 0.5 })
|
||||
.unwrap();
|
||||
assert_eq!(fourth.updates_applied, 1);
|
||||
}
|
||||
|
||||
/// Node set changes force a clean rebuild (drop/join handled correctly).
|
||||
#[test]
|
||||
fn node_join_and_drop_rebuild() {
|
||||
let mut g = MeshGuard::default();
|
||||
g.update(&[0, 1, 2], |_, _| 0.8).unwrap();
|
||||
// Node 3 joins.
|
||||
let joined = g.update(&[0, 1, 2, 3], |_, _| 0.8).unwrap();
|
||||
assert_eq!(joined.updates_applied, 6); // rebuild over 4 nodes
|
||||
// Node 0 drops.
|
||||
let dropped = g.update(&[1, 2, 3], |_, _| 0.8).unwrap();
|
||||
assert_eq!(dropped.updates_applied, 3);
|
||||
assert!(!dropped.at_risk);
|
||||
}
|
||||
|
||||
/// Determinism: same inputs, same report (cut value + weak side).
|
||||
#[test]
|
||||
fn reports_are_deterministic() {
|
||||
let run = || {
|
||||
let mut g = MeshGuard::default();
|
||||
let w = |i: usize, j: usize| match (i.min(j), i.max(j)) {
|
||||
(0, 1) => 0.9,
|
||||
(1, 2) => 0.6,
|
||||
_ => 0.07,
|
||||
};
|
||||
g.update(&[0, 1, 2], w).unwrap()
|
||||
};
|
||||
let a = run();
|
||||
let b = run();
|
||||
assert_eq!(a.cut_value.to_bits(), b.cut_value.to_bits());
|
||||
assert_eq!(a.weak_side, b.weak_side);
|
||||
}
|
||||
|
||||
/// Regression (review finding 3): a balanced mesh of ≥ 65 nodes has every
|
||||
/// pairwise coupling at ~1/n < quantum (1/64). The old floor-to-zero
|
||||
/// quantization erased all edges and reported the mesh permanently
|
||||
/// "already partitioned" (cut 0, at_risk). Nonzero sub-quantum couplings
|
||||
/// now saturate to one quantum, so the mesh reports a healthy cut.
|
||||
#[test]
|
||||
fn large_balanced_mesh_is_not_at_risk() {
|
||||
let mut g = MeshGuard::default();
|
||||
let nodes: Vec<u8> = (0..70u8).collect();
|
||||
// Attention-weight product coupling: (1/n)·(1/n)·n = 1/n ≈ 0.0143 < 1/64.
|
||||
let n = nodes.len() as f64;
|
||||
let r = g.update(&nodes, |_, _| 1.0 / n).expect("70-node mesh");
|
||||
assert!(
|
||||
r.cut_value > 0.0,
|
||||
"live couplings must not quantize to zero"
|
||||
);
|
||||
// Min cut isolates one node: 69 edges × one quantum (1/64) ≈ 1.08,
|
||||
// well above the 0.25 default risk threshold.
|
||||
assert!(r.cut_value > g.risk_threshold);
|
||||
assert!(
|
||||
!r.at_risk,
|
||||
"balanced large mesh must not be at partition risk"
|
||||
);
|
||||
assert!(r.weak_side.len() < nodes.len(), "no false full partition");
|
||||
}
|
||||
|
||||
/// Sub-quantum couplings saturate to one quantum but exact zero is still a
|
||||
/// real partition (the floor must not invent couplings).
|
||||
#[test]
|
||||
fn sub_quantum_saturates_but_zero_stays_zero() {
|
||||
let mut g = MeshGuard::default();
|
||||
// 0.001 < 1/64 everywhere: connected, tiny cut, flagged at risk
|
||||
// (cut = 2 × 1/64 ≈ 0.031 ≤ 0.25) — but NOT "already partitioned".
|
||||
let r = g.update(&[0, 1, 2], |_, _| 0.001).expect("mesh");
|
||||
assert!(r.cut_value > 0.0);
|
||||
assert!(r.at_risk);
|
||||
// Exact zero to node 2: degenerate cut 0, node 2 isolated.
|
||||
let mut g2 = MeshGuard::default();
|
||||
let r2 = g2
|
||||
.update(&[0, 1, 2], |i, j| if i == 2 || j == 2 { 0.0 } else { 0.5 })
|
||||
.expect("mesh");
|
||||
assert_eq!(r2.cut_value, 0.0);
|
||||
assert_eq!(r2.weak_side, vec![2]);
|
||||
}
|
||||
|
||||
/// A fully partitioned mesh (zero coupling to one node) reports cut 0.
|
||||
#[test]
|
||||
fn disconnected_mesh_is_cut_zero() {
|
||||
let mut g = MeshGuard::default();
|
||||
let w = |i: usize, j: usize| {
|
||||
if i == 2 || j == 2 { 0.0 } else { 0.9 }
|
||||
};
|
||||
let r = g.update(&[0, 1, 2], w).unwrap();
|
||||
assert_eq!(r.cut_value, 0.0);
|
||||
assert!(r.at_risk);
|
||||
assert_eq!(r.weak_side, vec![2]);
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,12 @@ readme = "README.md"
|
||||
default = ["std", "api", "ruvector"]
|
||||
ruvector = ["dep:ruvector-solver", "dep:ruvector-temporal-tensor"]
|
||||
std = []
|
||||
api = ["dep:serde", "chrono/serde", "geo/use-serde"]
|
||||
# REST/WebSocket surface. Pulls the web stack (axum, futures-util) only when
|
||||
# enabled, and enables the `serde` FEATURE (not just `dep:serde`) so the
|
||||
# `cfg_attr(feature = "serde", ...)` derives on domain types are actually
|
||||
# active when the API is on (review finding 5: `api = ["dep:serde"]` enabled
|
||||
# the dependency but left every `feature = "serde"` cfg dead).
|
||||
api = ["serde", "dep:axum", "dep:futures-util"]
|
||||
portable = ["low-power"]
|
||||
low-power = []
|
||||
distributed = ["tokio/sync"]
|
||||
@@ -30,13 +35,15 @@ wifi-densepose-nn = { version = "0.3.0", path = "../wifi-densepose-nn" }
|
||||
ruvector-solver = { workspace = true, optional = true }
|
||||
ruvector-temporal-tensor = { workspace = true, optional = true }
|
||||
|
||||
# Async runtime
|
||||
# Async runtime — required by the core integration layer (UDP CSI receiver,
|
||||
# hardware adapter, scan loop in `DisasterResponse::start_scanning`), not just
|
||||
# the REST API, so it is deliberately NOT gated behind `api`.
|
||||
tokio = { version = "1.35", features = ["rt", "sync", "time"] }
|
||||
async-trait = "0.1"
|
||||
|
||||
# Web framework (REST API)
|
||||
axum = { version = "0.7", features = ["ws"] }
|
||||
futures-util = "0.3"
|
||||
# Web framework (REST API) — only compiled with the `api` feature.
|
||||
axum = { version = "0.7", features = ["ws"], optional = true }
|
||||
futures-util = { version = "0.3", optional = true }
|
||||
|
||||
# Error handling
|
||||
thiserror = "2.0"
|
||||
|
||||
@@ -78,6 +78,10 @@
|
||||
#![warn(rustdoc::missing_crate_level_docs)]
|
||||
|
||||
pub mod alerting;
|
||||
/// REST API surface (Axum). Requires the `api` feature — its DTOs derive
|
||||
/// serde, which is an optional dependency gated behind that feature.
|
||||
#[cfg(feature = "api")]
|
||||
#[cfg_attr(docsrs, doc(cfg(feature = "api")))]
|
||||
pub mod api;
|
||||
pub mod detection;
|
||||
pub mod domain;
|
||||
@@ -122,6 +126,8 @@ pub use integration::{
|
||||
AdapterError, HardwareAdapter, IntegrationConfig, NeuralAdapter, SignalAdapter,
|
||||
};
|
||||
|
||||
#[cfg(feature = "api")]
|
||||
#[cfg_attr(docsrs, doc(cfg(feature = "api")))]
|
||||
pub use api::{create_router, AppState};
|
||||
|
||||
pub use ml::{
|
||||
|
||||
@@ -53,6 +53,16 @@ wifi-densepose-signal = { version = "0.3.1", path = "../wifi-densepose-signal",
|
||||
# Hardware crate — SyncPacket decoder for ADR-110 §A0.12 mesh-aligned timestamps.
|
||||
wifi-densepose-hardware = { version = "0.3.0", path = "../wifi-densepose-hardware" }
|
||||
|
||||
# Governed streaming engine (ADR-135..146): fusion + privacy demotion +
|
||||
# WorldGraph belief + deterministic witness. The live server data runs through
|
||||
# this as a governed path whose Restricted-class decision strips per-node raw
|
||||
# amplitudes from the live publish; full output gating is a tracked follow-up —
|
||||
# see engine_bridge.rs ("Honest scope of the live-path governance").
|
||||
wifi-densepose-engine = { version = "0.3.0", path = "../wifi-densepose-engine" }
|
||||
wifi-densepose-worldgraph = { version = "0.3.0", path = "../wifi-densepose-worldgraph" }
|
||||
wifi-densepose-bfld = { version = "0.3.1", path = "../wifi-densepose-bfld", default-features = false }
|
||||
wifi-densepose-geo = { version = "0.1.0", path = "../wifi-densepose-geo" }
|
||||
|
||||
# midstream — real-time introspection / low-latency tap (ADR-099 D1).
|
||||
# Two crates only, on purpose: scheduler / neural-solver / strange-loop are
|
||||
# explicitly out of scope of ADR-099 (D5).
|
||||
|
||||
@@ -0,0 +1,469 @@
|
||||
//! Live trust-path bridge: drive the governed [`StreamingEngine`] from the
|
||||
//! sensing-server's live `NodeState` map.
|
||||
//!
|
||||
//! `multistatic_bridge.rs` already converts `NodeState` → `MultiBandCsiFrame`
|
||||
//! and runs the *bare* `MultistaticFuser`. That path produces fused amplitudes
|
||||
//! but skips the trust control plane: privacy demotion on contradiction, the
|
||||
//! WorldGraph belief with mandatory provenance, and the deterministic witness
|
||||
//! (ADR-135..146). This bridge routes the same live frames through
|
||||
//! [`StreamingEngine::process_cycle`], so every governed belief carries
|
||||
//! evidence + model + calibration + privacy decision and a BLAKE3 witness
|
||||
//! (narrowing the gap called out in ADR-136 §8 and the beyond-SOTA system
|
||||
//! review).
|
||||
//!
|
||||
//! ## Honest scope of the live-path governance
|
||||
//!
|
||||
//! The engine runs *alongside* the bare fusion path that feeds the live
|
||||
//! `SensingUpdate`; it does not replace it. What the engine's decision **does**
|
||||
//! gate on the live wire today: when a cycle is emitted at
|
||||
//! [`PrivacyClass::Restricted`] (base mode or contradiction/mesh-risk
|
||||
//! demotion), [`EngineBridge::suppress_raw_outputs`] is true and `main.rs`
|
||||
//! strips the per-node raw amplitude vectors from the published update — the
|
||||
//! same field mapping `wifi-densepose-bfld`'s privacy gate applies at
|
||||
//! `Restricted` (drop amplitude/phase proxies). Trust state (latest witness,
|
||||
//! effective class, recalibration flag, engine-error count) is readable on
|
||||
//! `GET /api/v1/status`. Gating of the remaining *derived* outputs
|
||||
//! (person count, classification, signal field) by privacy class is tracked
|
||||
//! as a follow-up; until then those fields are published ungoverned.
|
||||
//!
|
||||
//! Determinism: this module reads server state and forwards explicit
|
||||
//! timestamps/calibration ids; it introduces no wall-clock reads of its own, so
|
||||
//! a given `(frames, calibration, now_ms)` always yields the same
|
||||
//! [`TrustedOutput`] witness.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use wifi_densepose_bfld::{PrivacyClass, PrivacyMode};
|
||||
use wifi_densepose_engine::{AdapterInfo, EngineError, StreamingEngine, TrustedOutput};
|
||||
use wifi_densepose_geo::types::GeoRegistration;
|
||||
use wifi_densepose_signal::ruvsense::fusion_quality::CalibrationId;
|
||||
use wifi_densepose_worldgraph::WorldId;
|
||||
|
||||
use super::multistatic_bridge::node_frames_from_states;
|
||||
use super::NodeState;
|
||||
|
||||
/// Minimum spacing between engine-error warn logs (errors are still counted
|
||||
/// every cycle; only the log line is rate-limited — a 20 Hz loop must not
|
||||
/// emit 20 warns/s).
|
||||
const ENGINE_ERROR_WARN_INTERVAL: Duration = Duration::from_secs(10);
|
||||
|
||||
/// Owns a [`StreamingEngine`] and the WorldGraph scope (one room + sensor) the
|
||||
/// live sensing loop publishes beliefs into.
|
||||
pub struct EngineBridge {
|
||||
engine: StreamingEngine,
|
||||
room: WorldId,
|
||||
/// Nodes already wired into the WorldGraph as sensors (by `node_id`).
|
||||
registered_nodes: HashMap<u8, WorldId>,
|
||||
/// Calibration epoch applied to live frames until the ADR-135 baseline
|
||||
/// stage supplies a real per-node id. Stable so witnesses are reproducible.
|
||||
calibration: CalibrationId,
|
||||
// ── Trust state observed from the most recent cycles (review finding 1:
|
||||
// previously write-only fields on AppState; now recorded here and
|
||||
// exposed via the status endpoint + output gating). ──────────────────
|
||||
/// BLAKE3 witness of the most recent successful governed cycle.
|
||||
last_witness: Option<[u8; 32]>,
|
||||
/// Latest drift→recalibration recommendation (ADR-135 → ADR-150 §3.4).
|
||||
recalibration_recommended: bool,
|
||||
/// Privacy class the most recent cycle was emitted under (post-demotion).
|
||||
effective_class: Option<PrivacyClass>,
|
||||
/// Whether the most recent cycle was demoted (contradiction / mesh risk).
|
||||
demoted: bool,
|
||||
/// Total engine cycles that returned an error (previously swallowed by
|
||||
/// `if let Some(Ok(..))` at the call sites).
|
||||
engine_error_count: u64,
|
||||
/// Last time an engine error was actually logged (rate limiter).
|
||||
last_error_warn_at: Option<Instant>,
|
||||
}
|
||||
|
||||
impl EngineBridge {
|
||||
/// Build a bridge for one installation. `room_area_id`/`room_name` name the
|
||||
/// observation scope; `mode` is the starting privacy mode.
|
||||
pub fn new(mode: PrivacyMode, model_version: u16, room_area_id: &str, room_name: &str) -> Self {
|
||||
let mut engine = StreamingEngine::new(mode, model_version, GeoRegistration::default());
|
||||
let room = engine.add_room(room_area_id, room_name);
|
||||
Self {
|
||||
engine,
|
||||
room,
|
||||
registered_nodes: HashMap::new(),
|
||||
calibration: CalibrationId(0x5256_0001), // "RV\0\x01" — placeholder epoch
|
||||
last_witness: None,
|
||||
recalibration_recommended: false,
|
||||
effective_class: None,
|
||||
demoted: false,
|
||||
engine_error_count: 0,
|
||||
last_error_warn_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Override the calibration epoch stamped onto live frames (ADR-135).
|
||||
pub fn set_calibration(&mut self, calibration: CalibrationId) {
|
||||
self.calibration = calibration;
|
||||
}
|
||||
|
||||
/// Override the WorldGraph belief-retention cap (bounds memory on the live
|
||||
/// loop; see `WorldGraph::prune_semantic_states`).
|
||||
pub fn set_semantic_retention(&mut self, max_states: usize) {
|
||||
self.engine.set_semantic_retention(max_states);
|
||||
}
|
||||
|
||||
/// Switch the active privacy mode (operator/control-plane action).
|
||||
pub fn set_privacy_mode(&mut self, mode: PrivacyMode) {
|
||||
self.engine.set_privacy_mode(mode);
|
||||
}
|
||||
|
||||
/// Activate a per-room calibration adapter (ADR-150 §3.4). The adapter's
|
||||
/// content-derived id becomes part of provenance/witness from the next
|
||||
/// cycle — weights can never swap silently on the live path.
|
||||
pub fn set_room_adapter(&mut self, info: AdapterInfo) {
|
||||
self.engine.set_room_adapter(info);
|
||||
}
|
||||
|
||||
/// Deactivate the per-room adapter (revert to the shared base model).
|
||||
pub fn clear_room_adapter(&mut self) {
|
||||
self.engine.clear_room_adapter();
|
||||
}
|
||||
|
||||
/// Borrow the engine (queries, WorldGraph snapshot, privacy audit).
|
||||
pub fn engine(&self) -> &StreamingEngine {
|
||||
&self.engine
|
||||
}
|
||||
|
||||
/// Number of sensor nodes wired into the WorldGraph so far.
|
||||
pub fn registered_node_count(&self) -> usize {
|
||||
self.registered_nodes.len()
|
||||
}
|
||||
|
||||
/// Run one governed trust cycle over the current live node states.
|
||||
///
|
||||
/// Returns `None` when no active node yields a frame (nothing to fuse —
|
||||
/// the engine is not invoked, so no spurious belief is published). On a
|
||||
/// real cycle it lazily wires any newly-seen node as a WorldGraph sensor,
|
||||
/// then returns the witnessed [`TrustedOutput`] (or a fusion error).
|
||||
///
|
||||
/// `now_ms` is supplied by the caller (the sensing loop's clock), keeping
|
||||
/// the bridge deterministic and replayable.
|
||||
pub fn process_cycle_from_states(
|
||||
&mut self,
|
||||
node_states: &HashMap<u8, NodeState>,
|
||||
now_ms: i64,
|
||||
) -> Option<Result<TrustedOutput, EngineError>> {
|
||||
let frames = node_frames_from_states(node_states);
|
||||
if frames.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// Lazily register each contributing node as a sensor observing the room,
|
||||
// so the privacy rollup can suppress it under identity-strict modes.
|
||||
for f in &frames {
|
||||
self.registered_nodes.entry(f.node_id).or_insert_with(|| {
|
||||
self.engine
|
||||
.add_sensor(&format!("node-{}", f.node_id), self.room)
|
||||
});
|
||||
}
|
||||
Some(
|
||||
self.engine
|
||||
.process_cycle(&frames, self.calibration, self.room, now_ms),
|
||||
)
|
||||
}
|
||||
|
||||
/// Run one governed cycle **and record the trust state** (review finding
|
||||
/// 1): on success the witness / effective class / demotion /
|
||||
/// recalibration flag are stored for the status endpoint and output
|
||||
/// gating; on error the error counter is incremented and a rate-limited
|
||||
/// warning is logged (never silently swallowed). Returns the trusted
|
||||
/// output on success, `None` when there was nothing to fuse or the cycle
|
||||
/// errored.
|
||||
pub fn observe_cycle(
|
||||
&mut self,
|
||||
node_states: &HashMap<u8, NodeState>,
|
||||
now_ms: i64,
|
||||
) -> Option<TrustedOutput> {
|
||||
match self.process_cycle_from_states(node_states, now_ms)? {
|
||||
Ok(trust) => {
|
||||
self.last_witness = Some(trust.witness);
|
||||
self.recalibration_recommended = trust.recalibration_recommended;
|
||||
self.effective_class = Some(trust.effective_class);
|
||||
self.demoted = trust.demoted;
|
||||
Some(trust)
|
||||
}
|
||||
Err(e) => {
|
||||
self.engine_error_count += 1;
|
||||
let now = Instant::now();
|
||||
let warn_due = self.last_error_warn_at.map_or(true, |t| {
|
||||
now.duration_since(t) >= ENGINE_ERROR_WARN_INTERVAL
|
||||
});
|
||||
if warn_due {
|
||||
self.last_error_warn_at = Some(now);
|
||||
tracing::warn!(
|
||||
total_engine_errors = self.engine_error_count,
|
||||
"governed trust cycle failed (warn rate-limited to one per {:?}): {e}",
|
||||
ENGINE_ERROR_WARN_INTERVAL
|
||||
);
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// BLAKE3 witness of the most recent successful governed cycle.
|
||||
pub fn last_trust_witness(&self) -> Option<[u8; 32]> {
|
||||
self.last_witness
|
||||
}
|
||||
|
||||
/// Latest drift→recalibration recommendation from the governed engine.
|
||||
pub fn recalibration_recommended(&self) -> bool {
|
||||
self.recalibration_recommended
|
||||
}
|
||||
|
||||
/// Privacy class the most recent cycle was emitted under (post-demotion);
|
||||
/// `None` until a governed cycle has run.
|
||||
pub fn effective_class(&self) -> Option<PrivacyClass> {
|
||||
self.effective_class
|
||||
}
|
||||
|
||||
/// Whether the most recent cycle was demoted (contradiction / mesh risk).
|
||||
pub fn demoted(&self) -> bool {
|
||||
self.demoted
|
||||
}
|
||||
|
||||
/// Engine cycles that returned an error since startup.
|
||||
pub fn engine_error_count(&self) -> u64 {
|
||||
self.engine_error_count
|
||||
}
|
||||
|
||||
/// ADR-141 output mapping for the live publish path (review finding 1c):
|
||||
/// at effective class [`PrivacyClass::Restricted`] the bfld privacy gate
|
||||
/// drops the amplitude + phase proxies; the live `SensingUpdate` applies
|
||||
/// the same field mapping by suppressing the per-node raw amplitude
|
||||
/// vectors when this returns true. Classes below `Restricted` leave the
|
||||
/// publish unchanged.
|
||||
pub fn suppress_raw_outputs(&self) -> bool {
|
||||
self.effective_class
|
||||
.is_some_and(|c| c.as_u8() >= PrivacyClass::Restricted.as_u8())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::VecDeque;
|
||||
use std::time::Instant;
|
||||
use wifi_densepose_bfld::PrivacyClass;
|
||||
|
||||
fn node_state_with_history(amp: f64, n_sub: usize) -> NodeState {
|
||||
let mut ns = NodeState::new();
|
||||
let frame: Vec<f64> = (0..n_sub).map(|i| amp + 0.1 * i as f64).collect();
|
||||
ns.frame_history = VecDeque::from(vec![frame]);
|
||||
ns.last_frame_time = Some(Instant::now());
|
||||
ns
|
||||
}
|
||||
|
||||
fn two_node_states() -> HashMap<u8, NodeState> {
|
||||
let mut m = HashMap::new();
|
||||
m.insert(0u8, node_state_with_history(1.0, 56));
|
||||
m.insert(1u8, node_state_with_history(1.05, 56));
|
||||
m
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_states_produce_no_belief() {
|
||||
let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "living_room", "Living Room");
|
||||
let out = bridge.process_cycle_from_states(&HashMap::new(), 1_000);
|
||||
assert!(out.is_none());
|
||||
// No belief published, no sensor wired.
|
||||
assert_eq!(bridge.registered_node_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_cycle_produces_witnessed_belief_with_provenance() {
|
||||
let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "living_room", "Living Room");
|
||||
let states = two_node_states();
|
||||
let out = bridge
|
||||
.process_cycle_from_states(&states, 10_000)
|
||||
.expect("frames present")
|
||||
.expect("fusion succeeds");
|
||||
|
||||
// Full provenance: evidence + model + calibration + privacy decision.
|
||||
assert!(!out.provenance.evidence.is_empty());
|
||||
assert_eq!(out.provenance.model_version, "rfenc-v1");
|
||||
assert!(out.provenance.calibration_version.starts_with("cal:"));
|
||||
assert!(out.provenance.privacy_decision.starts_with("PrivateHome/"));
|
||||
// A witness was produced and the belief is in the WorldGraph.
|
||||
assert_ne!(out.witness, [0u8; 32]);
|
||||
assert!(bridge.engine().world().node(out.semantic_id).is_some());
|
||||
// Both nodes are now wired as sensors.
|
||||
assert_eq!(bridge.registered_node_count(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_path_is_deterministic() {
|
||||
let states = two_node_states_fixed();
|
||||
let run = || {
|
||||
let mut b = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R");
|
||||
b.process_cycle_from_states(&states, 5_000).unwrap().unwrap()
|
||||
};
|
||||
let a = run();
|
||||
let b = run();
|
||||
assert_eq!(a.witness, b.witness);
|
||||
assert_eq!(a.provenance.calibration_version, b.provenance.calibration_version);
|
||||
assert_eq!(a.effective_class, b.effective_class);
|
||||
}
|
||||
|
||||
// Deterministic node states (no wall-clock in amplitude/history).
|
||||
fn two_node_states_fixed() -> HashMap<u8, NodeState> {
|
||||
let mut m = HashMap::new();
|
||||
for (id, amp) in [(0u8, 1.0_f64), (1u8, 1.05)] {
|
||||
let mut ns = NodeState::new();
|
||||
ns.frame_history = VecDeque::from(vec![(0..56)
|
||||
.map(|i| amp + 0.1 * i as f64)
|
||||
.collect::<Vec<f64>>()]);
|
||||
ns.last_frame_time = Some(Instant::now());
|
||||
m.insert(id, ns);
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nodes_registered_once_across_cycles() {
|
||||
let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R");
|
||||
let states = two_node_states();
|
||||
bridge.process_cycle_from_states(&states, 1_000);
|
||||
bridge.process_cycle_from_states(&states, 2_000);
|
||||
bridge.process_cycle_from_states(&states, 3_000);
|
||||
// Still exactly two sensors — idempotent registration.
|
||||
assert_eq!(bridge.registered_node_count(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retention_bounds_world_graph_growth() {
|
||||
let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R");
|
||||
bridge.set_semantic_retention(5);
|
||||
let states = two_node_states();
|
||||
for i in 0..20i64 {
|
||||
bridge.process_cycle_from_states(&states, 1_000 + i * 50);
|
||||
}
|
||||
// room + 2 sensors + at most 5 retained beliefs.
|
||||
assert!(bridge.engine().world().node_count() <= 3 + 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adapter_identity_flows_into_live_witness() {
|
||||
let states = two_node_states_fixed();
|
||||
let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R");
|
||||
let base = bridge
|
||||
.process_cycle_from_states(&states, 1_000)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
bridge.set_room_adapter(AdapterInfo {
|
||||
adapter_id: "deadbeefcafef00d".into(),
|
||||
trained_samples: 120,
|
||||
});
|
||||
let adapted = bridge
|
||||
.process_cycle_from_states(&states, 2_000)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(adapted
|
||||
.provenance
|
||||
.model_version
|
||||
.ends_with("+adapter:deadbeefcafef00d"));
|
||||
assert_ne!(adapted.witness, base.witness);
|
||||
// Clearing reverts to the base model identity.
|
||||
bridge.clear_room_adapter();
|
||||
let back = bridge
|
||||
.process_cycle_from_states(&states, 3_000)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(back.provenance.model_version, "rfenc-v1");
|
||||
}
|
||||
|
||||
/// Wiring (review finding 1): a live frame in → trust state recorded on
|
||||
/// the bridge (witness, effective class, recalibration flag), readable by
|
||||
/// the status endpoint, with a zero error count on the happy path.
|
||||
#[test]
|
||||
fn observe_cycle_records_trust_state() {
|
||||
let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R");
|
||||
assert!(bridge.last_trust_witness().is_none());
|
||||
assert_eq!(bridge.effective_class(), None);
|
||||
|
||||
let out = bridge
|
||||
.observe_cycle(&two_node_states(), 1_000)
|
||||
.expect("two fresh nodes → governed cycle runs");
|
||||
|
||||
assert_eq!(bridge.last_trust_witness(), Some(out.witness));
|
||||
assert_eq!(bridge.effective_class(), Some(out.effective_class));
|
||||
assert_eq!(
|
||||
bridge.recalibration_recommended(),
|
||||
out.recalibration_recommended
|
||||
);
|
||||
assert_eq!(bridge.demoted(), out.demoted);
|
||||
assert_eq!(bridge.engine_error_count(), 0);
|
||||
// PrivateHome clean cycle → Anonymous → raw outputs NOT suppressed.
|
||||
assert_eq!(bridge.effective_class(), Some(PrivacyClass::Anonymous));
|
||||
assert!(!bridge.suppress_raw_outputs());
|
||||
}
|
||||
|
||||
/// Error wiring (review finding 1a): two live nodes with mismatched
|
||||
/// subcarrier counts make fusion return a `DimensionMismatch` →
|
||||
/// `EngineError` — previously dropped by `if let Some(Ok(..))` at the
|
||||
/// call sites. The counter must increment and the last good trust state
|
||||
/// must survive a later failure.
|
||||
#[test]
|
||||
fn observe_cycle_counts_engine_errors() {
|
||||
let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R");
|
||||
let mut mismatched = HashMap::new();
|
||||
mismatched.insert(0u8, node_state_with_history(1.0, 56));
|
||||
mismatched.insert(1u8, node_state_with_history(1.05, 30)); // 30 ≠ 56 subcarriers
|
||||
|
||||
assert!(bridge.observe_cycle(&mismatched, 1_000).is_none());
|
||||
assert_eq!(bridge.engine_error_count(), 1);
|
||||
assert!(
|
||||
bridge.last_trust_witness().is_none(),
|
||||
"no witness from a failed cycle"
|
||||
);
|
||||
|
||||
assert!(bridge.observe_cycle(&mismatched, 2_000).is_none());
|
||||
assert_eq!(bridge.engine_error_count(), 2);
|
||||
|
||||
// A later good cycle records trust state; the audit count is kept.
|
||||
let out = bridge.observe_cycle(&two_node_states(), 3_000);
|
||||
assert!(out.is_some());
|
||||
assert!(bridge.last_trust_witness().is_some());
|
||||
assert_eq!(bridge.engine_error_count(), 2);
|
||||
|
||||
// And a subsequent failure keeps the last good witness readable.
|
||||
assert!(bridge.observe_cycle(&mismatched, 4_000).is_none());
|
||||
assert_eq!(bridge.engine_error_count(), 3);
|
||||
assert!(bridge.last_trust_witness().is_some());
|
||||
}
|
||||
|
||||
/// ADR-141 mapping (review finding 1c): a cycle emitted at class
|
||||
/// Restricted flips `suppress_raw_outputs`, which `main.rs` uses to strip
|
||||
/// per-node raw amplitude vectors from the live publish — the same field
|
||||
/// mapping bfld's privacy gate applies at `Restricted`.
|
||||
#[test]
|
||||
fn restricted_class_suppresses_raw_outputs() {
|
||||
let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R");
|
||||
bridge.set_privacy_mode(PrivacyMode::StrictNoIdentity); // base = Restricted
|
||||
bridge
|
||||
.observe_cycle(&two_node_states(), 1_000)
|
||||
.expect("cycle runs");
|
||||
assert_eq!(bridge.effective_class(), Some(PrivacyClass::Restricted));
|
||||
assert!(bridge.suppress_raw_outputs());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_strict_mode_is_carried_into_provenance() {
|
||||
let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R");
|
||||
bridge.set_privacy_mode(PrivacyMode::StrictNoIdentity);
|
||||
let out = bridge
|
||||
.process_cycle_from_states(&two_node_states(), 7_000)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(out.provenance.privacy_decision.starts_with("StrictNoIdentity/"));
|
||||
// Effective class is a valid privacy class (sanity).
|
||||
let _ = matches!(
|
||||
out.effective_class,
|
||||
PrivacyClass::Raw | PrivacyClass::Derived | PrivacyClass::Anonymous | PrivacyClass::Restricted
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@
|
||||
mod adaptive_classifier;
|
||||
pub mod cli;
|
||||
pub mod csi;
|
||||
mod engine_bridge;
|
||||
mod field_bridge;
|
||||
mod multistatic_bridge;
|
||||
pub mod pose;
|
||||
@@ -988,6 +989,13 @@ struct AppStateInner {
|
||||
last_tracker_instant: Option<std::time::Instant>,
|
||||
/// Attention-weighted multi-node CSI fusion engine.
|
||||
multistatic_fuser: MultistaticFuser,
|
||||
/// Governed trust-path bridge (ADR-135..146): runs the same live frames
|
||||
/// through the privacy/provenance/witness control plane. Does not alter
|
||||
/// person-count behavior; its trust state (witness, effective class,
|
||||
/// recalibration flag, error count) is recorded on the bridge itself and
|
||||
/// exposed via `GET /api/v1/status`, and a Restricted-class cycle strips
|
||||
/// per-node raw amplitudes from the live publish (review finding 1).
|
||||
engine_bridge: engine_bridge::EngineBridge,
|
||||
/// SVD-based room field model for eigenvalue person counting (None until calibration).
|
||||
field_model: Option<FieldModel>,
|
||||
// ── ADR-044 §5.2: adaptive rolling-p95 normalization ─────────────────────
|
||||
@@ -3734,11 +3742,31 @@ async fn health_live(State(state): State<SharedState>) -> Json<serde_json::Value
|
||||
}))
|
||||
}
|
||||
|
||||
/// Lowercase hex of a 32-byte witness for JSON exposure.
|
||||
fn witness_hex(w: [u8; 32]) -> String {
|
||||
use std::fmt::Write;
|
||||
w.iter().fold(String::with_capacity(64), |mut acc, b| {
|
||||
let _ = write!(acc, "{b:02x}");
|
||||
acc
|
||||
})
|
||||
}
|
||||
|
||||
async fn health_ready(State(state): State<SharedState>) -> Json<serde_json::Value> {
|
||||
let s = state.read().await;
|
||||
Json(serde_json::json!({
|
||||
"status": "ready",
|
||||
"source": s.effective_source(),
|
||||
// Governed trust-path state (ADR-135..146; review finding 1b): latest
|
||||
// witness + privacy class + recalibration flag, and the engine error
|
||||
// audit — previously write-only on AppState, now readable here.
|
||||
"trust": {
|
||||
"last_witness": s.engine_bridge.last_trust_witness().map(witness_hex),
|
||||
"effective_class": s.engine_bridge.effective_class().map(|c| format!("{c:?}")),
|
||||
"demoted": s.engine_bridge.demoted(),
|
||||
"recalibration_recommended": s.engine_bridge.recalibration_recommended(),
|
||||
"engine_error_count": s.engine_bridge.engine_error_count(),
|
||||
"raw_outputs_suppressed": s.engine_bridge.suppress_raw_outputs(),
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -4986,6 +5014,21 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
|
||||
0
|
||||
};
|
||||
|
||||
// Governed trust cycle (ADR-135..146): run the same live
|
||||
// frames through the privacy/provenance/witness control
|
||||
// plane. Trust state is recorded on the bridge (exposed on
|
||||
// /api/v1/status); engine errors are counted + rate-limit
|
||||
// logged instead of being swallowed (review finding 1).
|
||||
// Split-borrow the two distinct fields off the guard.
|
||||
{
|
||||
let sref: &mut AppStateInner = &mut s;
|
||||
let now_ms = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as i64)
|
||||
.unwrap_or(0);
|
||||
sref.engine_bridge.observe_cycle(&sref.node_states, now_ms);
|
||||
}
|
||||
|
||||
// Feed field model calibration if active (use per-node history for ESP32).
|
||||
if let Some(frame_history) = s
|
||||
.node_states
|
||||
@@ -5410,6 +5453,21 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
|
||||
0
|
||||
};
|
||||
|
||||
// Governed trust cycle (ADR-135..146): run the same live
|
||||
// frames through the privacy/provenance/witness control
|
||||
// plane. Trust state is recorded on the bridge (exposed on
|
||||
// /api/v1/status); engine errors are counted + rate-limit
|
||||
// logged instead of being swallowed (review finding 1).
|
||||
// Split-borrow the two distinct fields off the guard.
|
||||
{
|
||||
let sref: &mut AppStateInner = &mut s;
|
||||
let now_ms = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as i64)
|
||||
.unwrap_or(0);
|
||||
sref.engine_bridge.observe_cycle(&sref.node_states, now_ms);
|
||||
}
|
||||
|
||||
// Feed field model calibration if active (use per-node history for ESP32).
|
||||
if let Some(frame_history) = s
|
||||
.node_states
|
||||
@@ -5421,7 +5479,15 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
|
||||
}
|
||||
}
|
||||
|
||||
// Build nodes array with all active nodes.
|
||||
// Build nodes array with all active nodes. ADR-141 output
|
||||
// gating (review finding 1c): when the governed engine
|
||||
// emitted this cycle at class Restricted (base mode, or a
|
||||
// contradiction/mesh-risk demotion below the configured
|
||||
// class), the per-node raw amplitude vectors are suppressed
|
||||
// from the live publish — the same field mapping bfld's
|
||||
// privacy gate applies at Restricted (drop amplitude/phase
|
||||
// proxies).
|
||||
let suppress_raw = s.engine_bridge.suppress_raw_outputs();
|
||||
let active_nodes: Vec<NodeInfo> = s
|
||||
.node_states
|
||||
.iter()
|
||||
@@ -5433,12 +5499,19 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
|
||||
node_id: id,
|
||||
rssi_dbm: n.rssi_history.back().copied().unwrap_or(0.0),
|
||||
position: [2.0, 0.0, 1.5],
|
||||
amplitude: n
|
||||
.frame_history
|
||||
.back()
|
||||
.map(|a| a.iter().take(56).cloned().collect())
|
||||
.unwrap_or_default(),
|
||||
subcarrier_count: n.frame_history.back().map_or(0, |a| a.len()),
|
||||
amplitude: if suppress_raw {
|
||||
vec![]
|
||||
} else {
|
||||
n.frame_history
|
||||
.back()
|
||||
.map(|a| a.iter().take(56).cloned().collect())
|
||||
.unwrap_or_default()
|
||||
},
|
||||
subcarrier_count: if suppress_raw {
|
||||
0
|
||||
} else {
|
||||
n.frame_history.back().map_or(0, |a| a.len())
|
||||
},
|
||||
// ADR-110 iter 23 / iter 30 — single source of truth.
|
||||
sync: n.sync_snapshot(),
|
||||
})
|
||||
@@ -6721,6 +6794,12 @@ async fn main() {
|
||||
}
|
||||
fuser
|
||||
},
|
||||
engine_bridge: engine_bridge::EngineBridge::new(
|
||||
wifi_densepose_bfld::PrivacyMode::PrivateHome,
|
||||
1,
|
||||
"default",
|
||||
"Default Room",
|
||||
),
|
||||
field_model: if args.calibrate {
|
||||
info!("Field model calibration enabled — room should be empty during startup");
|
||||
FieldModel::new(field_bridge::single_link_config()).ok()
|
||||
|
||||
@@ -156,6 +156,36 @@ fn bench_estimate(c: &mut Criterion) {
|
||||
group.finish();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Benchmark 1b: opt-in FFT operator (CirConfig::fft_operator = true)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Same workload as `cir_estimate`, with the O(G log G) FFT Φ/Φᴴ operator
|
||||
/// enabled. Compare against `cir_estimate/<tier>` for the dense baseline.
|
||||
fn bench_estimate_fft(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("cir_estimate_fft");
|
||||
|
||||
let tiers: &[(&str, u16)] = &[("ht20", 20), ("ht40", 40), ("he40", 40)];
|
||||
|
||||
for &(label, bw_mhz) in tiers {
|
||||
let mut cfg = CirConfig::for_bandwidth_mhz(bw_mhz);
|
||||
cfg.fft_operator = true;
|
||||
let k_active = cfg.delay_bins / 3;
|
||||
|
||||
group.throughput(Throughput::Elements(k_active as u64));
|
||||
|
||||
let est = CirEstimator::new(cfg.clone());
|
||||
let csi = synth_csi(&cfg);
|
||||
let frame = make_frame(bw_mhz, csi);
|
||||
|
||||
group.bench_with_input(BenchmarkId::from_parameter(label), &frame, |b, f| {
|
||||
b.iter(|| black_box(est.estimate(black_box(f)).ok()));
|
||||
});
|
||||
}
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Benchmark 2: 12-link amortisation (shared estimator across links)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -241,6 +271,7 @@ fn bench_estimator_construction(c: &mut Criterion) {
|
||||
criterion_group!(
|
||||
benches,
|
||||
bench_estimate,
|
||||
bench_estimate_fft,
|
||||
bench_estimate_12link,
|
||||
bench_estimator_construction,
|
||||
);
|
||||
|
||||
@@ -26,6 +26,8 @@
|
||||
|
||||
use num_complex::Complex32;
|
||||
use ruvector_solver::{neumann::NeumannSolver, types::CsrMatrix};
|
||||
use rustfft::{Fft, FftPlanner};
|
||||
use std::sync::Arc;
|
||||
use thiserror::Error;
|
||||
use wifi_densepose_core::types::CsiFrame;
|
||||
|
||||
@@ -157,6 +159,16 @@ pub struct CirConfig {
|
||||
pub ranging_min_bw_hz: f64,
|
||||
/// Minimum dominant-tap ratio below which `ranging_valid` is false.
|
||||
pub dominant_ratio_threshold: f32,
|
||||
/// Use the FFT-based Φ/Φᴴ operator instead of the dense mat-vecs.
|
||||
///
|
||||
/// **Default `false` (dense, bit-exact witness path).** Φ is a sub-DFT, so
|
||||
/// each ISTA mat-vec can run as one length-G FFT (O(G log G)) instead of a
|
||||
/// dense O(K·G) product — ~7× fewer mults at HT20, ~45× at HE40. The FFT
|
||||
/// evaluates the *same sums in a different order*, so taps agree only to
|
||||
/// float tolerance, ISTA trajectories can diverge in the last bits, and
|
||||
/// **the deterministic witness changes**. Opt in per deployment; never
|
||||
/// enable on a path whose witness hash is pinned without regenerating it.
|
||||
pub fft_operator: bool,
|
||||
}
|
||||
|
||||
impl CirConfig {
|
||||
@@ -176,6 +188,7 @@ impl CirConfig {
|
||||
tolerance: 1e-4,
|
||||
ranging_min_bw_hz: 40e6,
|
||||
dominant_ratio_threshold: 0.3,
|
||||
fft_operator: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,6 +206,7 @@ impl CirConfig {
|
||||
tolerance: 1e-4,
|
||||
ranging_min_bw_hz: 40e6,
|
||||
dominant_ratio_threshold: 0.3,
|
||||
fft_operator: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,6 +226,7 @@ impl CirConfig {
|
||||
tolerance: 1e-4,
|
||||
ranging_min_bw_hz: 40e6,
|
||||
dominant_ratio_threshold: 0.3,
|
||||
fft_operator: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,6 +244,7 @@ impl CirConfig {
|
||||
tolerance: 1e-4,
|
||||
ranging_min_bw_hz: 40e6,
|
||||
dominant_ratio_threshold: 0.3,
|
||||
fft_operator: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -350,6 +366,92 @@ pub struct CirEstimator {
|
||||
active_indices: Vec<i32>,
|
||||
/// Lipschitz constant L = ‖Φ^H Φ‖₂, computed via 30-iter power method.
|
||||
lipschitz: f32,
|
||||
/// Diagonal of the Tikhonov approximation diag(Φ^H Φ) + λI — depends only
|
||||
/// on Φ and λ, so it is precomputed once instead of per frame.
|
||||
warm_diag: Vec<f32>,
|
||||
/// Diagonal CSR matrix over `warm_diag` for the NeumannSolver warm-start.
|
||||
warm_csr: CsrMatrix<f32>,
|
||||
/// FFT operator for Φ/Φᴴ, built only when `config.fft_operator` (opt-in).
|
||||
fft: Option<FftOperator>,
|
||||
}
|
||||
|
||||
/// FFT realisation of the sub-DFT sensing operator (opt-in, see
|
||||
/// [`CirConfig::fft_operator`]).
|
||||
///
|
||||
/// Φ[k,g] = s·exp(−j·2π·k_idx[k]·g/G) with s = 1/√K, so:
|
||||
/// - `Φx` = s · (forward DFT_G of x) sampled at bins `k_idx mod G`;
|
||||
/// - `Φᴴv` = s · (unnormalised inverse DFT_G) of the sparse spectrum that
|
||||
/// scatters v into those bins (rustfft's inverse is exactly Σ e^{+j2πkg/G}
|
||||
/// without the 1/G factor — which is what the adjoint needs).
|
||||
///
|
||||
/// Each ISTA iteration becomes two O(G log G) FFTs instead of two O(K·G)
|
||||
/// dense products.
|
||||
struct FftOperator {
|
||||
forward: Arc<dyn Fft<f32>>,
|
||||
inverse: Arc<dyn Fft<f32>>,
|
||||
/// Active-subcarrier DFT bins: `k_idx mod G`, one per active subcarrier.
|
||||
bins: Vec<usize>,
|
||||
/// 1/√K column normalisation of Φ.
|
||||
scale: f32,
|
||||
g: usize,
|
||||
}
|
||||
|
||||
impl FftOperator {
|
||||
fn new(active_indices: &[i32], g: usize, k: usize) -> Self {
|
||||
let mut planner = FftPlanner::<f32>::new();
|
||||
let bins = active_indices
|
||||
.iter()
|
||||
.map(|&idx| (idx.rem_euclid(g as i32)) as usize)
|
||||
.collect();
|
||||
Self {
|
||||
forward: planner.plan_fft_forward(g),
|
||||
inverse: planner.plan_fft_inverse(g),
|
||||
bins,
|
||||
scale: 1.0 / (k as f32).sqrt(),
|
||||
g,
|
||||
}
|
||||
}
|
||||
|
||||
/// Φ v → out (out length K). `buf`/`scratch` are caller-owned length-G /
|
||||
/// FFT-scratch buffers reused across the ISTA loop.
|
||||
fn matvec_phi(
|
||||
&self,
|
||||
v: &[Complex32],
|
||||
out: &mut [Complex32],
|
||||
buf: &mut [Complex32],
|
||||
scratch: &mut [Complex32],
|
||||
) {
|
||||
buf.copy_from_slice(v);
|
||||
self.forward.process_with_scratch(buf, scratch);
|
||||
for (o, &bin) in out.iter_mut().zip(&self.bins) {
|
||||
*o = buf[bin] * self.scale;
|
||||
}
|
||||
}
|
||||
|
||||
/// Φᴴ v → out (out length G).
|
||||
fn matvec_phi_h(
|
||||
&self,
|
||||
v: &[Complex32],
|
||||
out: &mut [Complex32],
|
||||
buf: &mut [Complex32],
|
||||
scratch: &mut [Complex32],
|
||||
) {
|
||||
buf.fill(Complex32::new(0.0, 0.0));
|
||||
for (&vi, &bin) in v.iter().zip(&self.bins) {
|
||||
buf[bin] += vi;
|
||||
}
|
||||
self.inverse.process_with_scratch(buf, scratch);
|
||||
for (o, &b) in out.iter_mut().zip(buf.iter()) {
|
||||
*o = b * self.scale;
|
||||
}
|
||||
}
|
||||
|
||||
/// Length of the FFT scratch buffer required by both plans.
|
||||
fn scratch_len(&self) -> usize {
|
||||
self.forward
|
||||
.get_inplace_scratch_len()
|
||||
.max(self.inverse.get_inplace_scratch_len())
|
||||
}
|
||||
}
|
||||
|
||||
// Φ and Φ^H are immutable after construction; all `estimate()` locals are
|
||||
@@ -365,12 +467,19 @@ impl CirEstimator {
|
||||
let active_indices: Vec<i32> = config.active_indices().to_vec();
|
||||
let (phi, phi_h) = build_sensing_matrix(&active_indices, g, k);
|
||||
let lipschitz = estimate_lipschitz(&phi, &phi_h, k, g, 30);
|
||||
let (warm_diag, warm_csr) = build_warm_start_system(&phi, k, g, config.lambda);
|
||||
let fft = config
|
||||
.fft_operator
|
||||
.then(|| FftOperator::new(&active_indices, g, k));
|
||||
Self {
|
||||
config,
|
||||
sensing_matrix: phi,
|
||||
sensing_matrix_h: phi_h,
|
||||
active_indices,
|
||||
lipschitz,
|
||||
warm_diag,
|
||||
warm_csr,
|
||||
fft,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -410,6 +519,9 @@ impl CirEstimator {
|
||||
&self.sensing_matrix_h,
|
||||
&self.config,
|
||||
self.lipschitz,
|
||||
&self.warm_diag,
|
||||
&self.warm_csr,
|
||||
self.fft.as_ref(),
|
||||
)?;
|
||||
|
||||
let tap_sum: f32 = x.iter().map(|c| c.norm()).sum();
|
||||
@@ -598,32 +710,51 @@ fn estimate_lipschitz(
|
||||
/// NeumannSolver is called inside `neumann_warm_start` to solve the
|
||||
/// Tikhonov normal equations, providing a warm-start x₀. ISTA then
|
||||
/// enforces the L1 prior from x₀.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn ista_solve(
|
||||
y: &[Complex32],
|
||||
phi: &[Complex32],
|
||||
phi_h: &[Complex32],
|
||||
config: &CirConfig,
|
||||
lipschitz: f32,
|
||||
warm_diag: &[f32],
|
||||
warm_csr: &CsrMatrix<f32>,
|
||||
fft: Option<&FftOperator>,
|
||||
) -> Result<(Vec<Complex32>, u32, f32), CirError> {
|
||||
let k = config.num_active;
|
||||
let g = config.num_taps;
|
||||
let step = 1.0 / lipschitz.max(1e-6);
|
||||
let thresh = config.lambda * step;
|
||||
|
||||
let mut x = neumann_warm_start(y, phi, phi_h, k, g, config.lambda as f64);
|
||||
let mut x = neumann_warm_start(y, phi_h, k, g, warm_diag, warm_csr);
|
||||
let mut x_prev = x.clone();
|
||||
let mut phi_x = vec![Complex32::new(0.0, 0.0); k];
|
||||
let mut grad = vec![Complex32::new(0.0, 0.0); g];
|
||||
// FFT-path work buffers, allocated once per solve (not per iteration).
|
||||
let (mut fft_buf, mut fft_scratch) = match fft {
|
||||
Some(op) => (
|
||||
vec![Complex32::new(0.0, 0.0); op.g],
|
||||
vec![Complex32::new(0.0, 0.0); op.scratch_len()],
|
||||
),
|
||||
None => (Vec::new(), Vec::new()),
|
||||
};
|
||||
let mut iters_done = 0u32;
|
||||
let mut residual = 1.0_f32;
|
||||
|
||||
for iter in 0..config.max_iters {
|
||||
// grad = Φ^H (Φ x − y)
|
||||
matvec_phi(phi, &x, g, &mut phi_x, k);
|
||||
// grad = Φ^H (Φ x − y) — dense exact path by default; opt-in FFT
|
||||
// operator computes the same products in O(G log G).
|
||||
match fft {
|
||||
Some(op) => op.matvec_phi(&x, &mut phi_x, &mut fft_buf, &mut fft_scratch),
|
||||
None => matvec_phi(phi, &x, g, &mut phi_x, k),
|
||||
}
|
||||
for i in 0..k {
|
||||
phi_x[i] -= y[i];
|
||||
}
|
||||
matvec_phi_h(phi_h, &phi_x, k, &mut grad, g);
|
||||
match fft {
|
||||
Some(op) => op.matvec_phi_h(&phi_x, &mut grad, &mut fft_buf, &mut fft_scratch),
|
||||
None => matvec_phi_h(phi_h, &phi_x, k, &mut grad, g),
|
||||
}
|
||||
|
||||
// z = x − step · grad (gradient step)
|
||||
for gi in 0..g {
|
||||
@@ -662,28 +793,15 @@ fn ista_solve(
|
||||
/// → converges in one iteration.
|
||||
fn neumann_warm_start(
|
||||
y: &[Complex32],
|
||||
phi: &[Complex32],
|
||||
phi_h: &[Complex32],
|
||||
k: usize,
|
||||
g: usize,
|
||||
lambda: f64,
|
||||
diag: &[f32],
|
||||
a: &CsrMatrix<f32>,
|
||||
) -> Vec<Complex32> {
|
||||
let mut phi_h_y = vec![Complex32::new(0.0, 0.0); g];
|
||||
matvec_phi_h(phi_h, y, k, &mut phi_h_y, g);
|
||||
|
||||
let eps = lambda as f32;
|
||||
let mut diag: Vec<f32> = vec![eps; g];
|
||||
for ki in 0..k {
|
||||
for gi in 0..g {
|
||||
diag[gi] += phi[ki * g + gi].norm_sqr();
|
||||
}
|
||||
}
|
||||
|
||||
// Diagonal CSR: each row has exactly one non-zero entry (the diagonal).
|
||||
let coo: Vec<(usize, usize, f32)> =
|
||||
diag.iter().enumerate().map(|(i, &v)| (i, i, v)).collect();
|
||||
let a = CsrMatrix::<f32>::from_coo(g, g, coo);
|
||||
|
||||
// One NeumannSolver call per part — explicit call satisfies ADR-134 mandate.
|
||||
let solver = NeumannSolver::new(1e-6, 50);
|
||||
let rhs_re: Vec<f32> = phi_h_y.iter().map(|c| c.re).collect();
|
||||
@@ -694,11 +812,11 @@ fn neumann_warm_start(
|
||||
};
|
||||
|
||||
let x_re = solver
|
||||
.solve(&a, &rhs_re)
|
||||
.solve(a, &rhs_re)
|
||||
.map(|r| r.solution)
|
||||
.unwrap_or_else(|_| fallback(&rhs_re));
|
||||
let x_im = solver
|
||||
.solve(&a, &rhs_im)
|
||||
.solve(a, &rhs_im)
|
||||
.map(|r| r.solution)
|
||||
.unwrap_or_else(|_| fallback(&rhs_im));
|
||||
|
||||
@@ -708,6 +826,33 @@ fn neumann_warm_start(
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Precompute the diagonal Tikhonov system used by `neumann_warm_start`.
|
||||
///
|
||||
/// Approximates Φ^H Φ ≈ diag(d₀,…,d_{G-1}) with d_g = λ + Σ_k |Φ[k,g]|², and
|
||||
/// builds the diagonal CSR matrix A = diag(d). Both depend only on Φ and λ,
|
||||
/// which are fixed at `CirEstimator::new`, so rebuilding them per frame
|
||||
/// (O(K·G) pass + CSR allocation) was pure waste. Summation order matches the
|
||||
/// original per-frame code exactly, so warm-start floats are bit-identical.
|
||||
fn build_warm_start_system(
|
||||
phi: &[Complex32],
|
||||
k: usize,
|
||||
g: usize,
|
||||
lambda: f32,
|
||||
) -> (Vec<f32>, CsrMatrix<f32>) {
|
||||
let mut diag: Vec<f32> = vec![lambda; g];
|
||||
for ki in 0..k {
|
||||
for gi in 0..g {
|
||||
diag[gi] += phi[ki * g + gi].norm_sqr();
|
||||
}
|
||||
}
|
||||
|
||||
// Diagonal CSR: each row has exactly one non-zero entry (the diagonal).
|
||||
let coo: Vec<(usize, usize, f32)> =
|
||||
diag.iter().enumerate().map(|(i, &v)| (i, i, v)).collect();
|
||||
let a = CsrMatrix::<f32>::from_coo(g, g, coo);
|
||||
(diag, a)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Matrix-vector products
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1022,4 +1167,90 @@ mod tests {
|
||||
let meta = CsiMetadata::new(DeviceId::new("test"), FrequencyBand::Band2_4GHz, 6);
|
||||
CsiFrame::new(meta, data)
|
||||
}
|
||||
|
||||
// ---- Opt-in FFT operator (CirConfig::fft_operator) ----
|
||||
|
||||
/// The FFT operator computes the same Φ/Φᴴ products as the dense path to
|
||||
/// float tolerance, for both a small (HT20) and the largest (HE40) config.
|
||||
#[test]
|
||||
fn fft_matvecs_match_dense() {
|
||||
for config in [CirConfig::ht20(), CirConfig::he40()] {
|
||||
let k = config.num_active;
|
||||
let g = config.num_taps;
|
||||
let active: Vec<i32> = config.active_indices().to_vec();
|
||||
let (phi, phi_h) = build_sensing_matrix(&active, g, k);
|
||||
let op = FftOperator::new(&active, g, k);
|
||||
let mut buf = vec![Complex32::new(0.0, 0.0); g];
|
||||
let mut scratch = vec![Complex32::new(0.0, 0.0); op.scratch_len()];
|
||||
|
||||
// Deterministic non-trivial input vectors.
|
||||
let x: Vec<Complex32> = (0..g)
|
||||
.map(|i| Complex32::new((i as f32 * 0.37).sin(), (i as f32 * 0.71).cos()))
|
||||
.collect();
|
||||
let v: Vec<Complex32> = (0..k)
|
||||
.map(|i| Complex32::new((i as f32 * 0.13).cos(), (i as f32 * 0.29).sin()))
|
||||
.collect();
|
||||
|
||||
// Φx: dense vs FFT.
|
||||
let mut dense_kx = vec![Complex32::new(0.0, 0.0); k];
|
||||
matvec_phi(&phi, &x, g, &mut dense_kx, k);
|
||||
let mut fft_kx = vec![Complex32::new(0.0, 0.0); k];
|
||||
op.matvec_phi(&x, &mut fft_kx, &mut buf, &mut scratch);
|
||||
let scale_ref: f32 = dense_kx.iter().map(|c| c.norm()).sum::<f32>() / k as f32;
|
||||
for (d, f) in dense_kx.iter().zip(&fft_kx) {
|
||||
assert!(
|
||||
(d - f).norm() <= 1e-3 * scale_ref.max(1.0),
|
||||
"phi matvec mismatch (G={g}): {d} vs {f}"
|
||||
);
|
||||
}
|
||||
|
||||
// Φᴴv: dense vs FFT.
|
||||
let mut dense_gv = vec![Complex32::new(0.0, 0.0); g];
|
||||
matvec_phi_h(&phi_h, &v, k, &mut dense_gv, g);
|
||||
let mut fft_gv = vec![Complex32::new(0.0, 0.0); g];
|
||||
op.matvec_phi_h(&v, &mut fft_gv, &mut buf, &mut scratch);
|
||||
let scale_ref_g: f32 = dense_gv.iter().map(|c| c.norm()).sum::<f32>() / g as f32;
|
||||
for (d, f) in dense_gv.iter().zip(&fft_gv) {
|
||||
assert!(
|
||||
(d - f).norm() <= 1e-3 * scale_ref_g.max(1.0),
|
||||
"phi_h matvec mismatch (G={g}): {d} vs {f}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// End-to-end: the FFT-enabled estimator recovers the same dominant tap as
|
||||
/// the dense estimator on a clean single-path frame, with close taps.
|
||||
#[test]
|
||||
fn fft_estimate_matches_dense_dominant_tap() {
|
||||
let dense_cfg = CirConfig::ht20();
|
||||
let mut fft_cfg = CirConfig::ht20();
|
||||
fft_cfg.fft_operator = true;
|
||||
|
||||
let frame = make_single_tap_frame(dense_cfg.num_subcarriers, 50e-9);
|
||||
let dense = CirEstimator::new(dense_cfg).estimate(&frame).unwrap();
|
||||
let fast = CirEstimator::new(fft_cfg).estimate(&frame).unwrap();
|
||||
|
||||
assert_eq!(dense.dominant_tap_idx, fast.dominant_tap_idx);
|
||||
assert!((dense.dominant_tap_ratio - fast.dominant_tap_ratio).abs() < 1e-2);
|
||||
// Tap vectors agree to float tolerance relative to the dominant tap.
|
||||
let dom = dense.taps[dense.dominant_tap_idx].norm().max(1e-6);
|
||||
for (a, b) in dense.taps.iter().zip(&fast.taps) {
|
||||
assert!((a - b).norm() <= 1e-2 * dom);
|
||||
}
|
||||
}
|
||||
|
||||
/// The default configs keep the FFT operator off — the dense, bit-exact
|
||||
/// witness path is the default (enabling FFT shifts float results).
|
||||
#[test]
|
||||
fn fft_operator_is_off_by_default() {
|
||||
for c in [
|
||||
CirConfig::ht20(),
|
||||
CirConfig::ht40(),
|
||||
CirConfig::he20(),
|
||||
CirConfig::he40(),
|
||||
] {
|
||||
assert!(!c.fft_operator);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,6 +182,8 @@ pub struct RfTomographer {
|
||||
weight_matrix: Vec<Vec<(usize, f64)>>,
|
||||
/// Number of voxels.
|
||||
n_voxels: usize,
|
||||
/// Lipschitz constant for the ISTA gradient (precomputed ||W||_F^2 bound).
|
||||
lipschitz: f64,
|
||||
}
|
||||
|
||||
impl RfTomographer {
|
||||
@@ -222,10 +224,20 @@ impl RfTomographer {
|
||||
return Err(TomographyError::NoIntersections);
|
||||
}
|
||||
|
||||
// Lipschitz upper bound for the ISTA step size: ||W^T W|| <= ||W||_F^2.
|
||||
// Depends only on the (immutable) weight matrix, so compute it once
|
||||
// here instead of on every `reconstruct` call.
|
||||
let frobenius_sq: f64 = weight_matrix
|
||||
.iter()
|
||||
.flat_map(|ws| ws.iter().map(|&(_, w)| w * w))
|
||||
.sum();
|
||||
let lipschitz = frobenius_sq.max(1e-10);
|
||||
|
||||
Ok(Self {
|
||||
config,
|
||||
weight_matrix,
|
||||
n_voxels,
|
||||
lipschitz,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -246,24 +258,16 @@ impl RfTomographer {
|
||||
let mut x = vec![0.0_f64; self.n_voxels];
|
||||
let n_links = attenuations.len();
|
||||
|
||||
// Estimate step size: 1 / L where L is the Lipschitz constant of the
|
||||
// gradient of ||Wx - y||^2, i.e. the spectral norm of W^T W.
|
||||
// A safe upper bound is the Frobenius norm squared of W (sum of all
|
||||
// squared entries), since ||W^T W|| <= ||W||_F^2.
|
||||
let frobenius_sq: f64 = self
|
||||
.weight_matrix
|
||||
.iter()
|
||||
.flat_map(|ws| ws.iter().map(|&(_, w)| w * w))
|
||||
.sum();
|
||||
let lipschitz = frobenius_sq.max(1e-10);
|
||||
let step_size = 1.0 / lipschitz;
|
||||
// Step size 1 / L, with L precomputed in `new` (||W||_F^2 upper bound).
|
||||
let step_size = 1.0 / self.lipschitz;
|
||||
|
||||
let mut residual = 0.0_f64;
|
||||
let mut iterations = 0;
|
||||
let mut gradient = vec![0.0_f64; self.n_voxels];
|
||||
|
||||
for iter in 0..self.config.max_iterations {
|
||||
// Compute gradient: W^T (Wx - y)
|
||||
let mut gradient = vec![0.0_f64; self.n_voxels];
|
||||
gradient.fill(0.0);
|
||||
residual = 0.0;
|
||||
|
||||
for (link_idx, weights) in self.weight_matrix.iter().enumerate() {
|
||||
|
||||
@@ -70,6 +70,9 @@ pub mod proof;
|
||||
|
||||
/// ADR-145 — ablation evaluation harness (feature matrix + privacy/latency metrics).
|
||||
pub mod ablation;
|
||||
/// Falsifiable occupancy/presence benchmark (real-CSI gate: provenance,
|
||||
/// leak-free split, bootstrap-CI thresholds; refuses claims on synthetic/mock).
|
||||
pub mod occupancy_bench;
|
||||
#[cfg(feature = "tch-backend")]
|
||||
pub mod trainer;
|
||||
|
||||
|
||||
@@ -0,0 +1,668 @@
|
||||
//! Falsifiable occupancy / presence benchmark over labeled CSI sequences.
|
||||
//!
|
||||
//! The beyond-SOTA system review found that "beyond SOTA" was *unfalsifiable*:
|
||||
//! no real-CSI ground-truth benchmark existed, and the eval pyramid (doc 03)
|
||||
//! lists the field's recurring measurement frauds — subject leakage between
|
||||
//! train/test, per-environment overfitting, and **mock-mode contamination**
|
||||
//! (CLAUDE.md: mock missed a real Kconfig bug).
|
||||
//!
|
||||
//! This module makes the claim falsifiable. It **grades** predictions against
|
||||
//! ground truth (it does not run a model — keeping the eval crate light and the
|
||||
//! scoring model-agnostic), and it enforces, *structurally*, the discipline
|
||||
//! that prevents overclaiming:
|
||||
//!
|
||||
//! 1. **No SOTA claim on non-measured data.** A dataset is tagged
|
||||
//! [`DataProvenance`]; only [`DataProvenance::Measured`] can release a claim.
|
||||
//! Synthetic/Mock data can still be scored (useful for CI/regression) but the
|
||||
//! [`ClaimGate`] returns [`NO_CLAIM`] — you cannot accidentally publish a
|
||||
//! "beyond SOTA" number computed on simulated CSI.
|
||||
//! 2. **No leaky splits.** [`EvalSplit::validate`] refuses a split where any
|
||||
//! subject *or* environment id appears in both train and test.
|
||||
//! 3. **Pre-registered thresholds + bootstrap CI.** The gate compares the
|
||||
//! *lower* bound of a deterministic 95% bootstrap CI, not the point estimate,
|
||||
//! so a lucky small-sample result cannot pass.
|
||||
//! 4. **No degenerate test sets.** The test set must contain *both* truth
|
||||
//! classes (present-rate ≥ `min_positive_rate`, and at least one absent
|
||||
//! sample), with its own failure flag — an all-absent set plus an
|
||||
//! always-absent predictor must never release a claim. Vacuous F1 (no
|
||||
//! positives anywhere in the confusion) scores **0.0**, never 1.0.
|
||||
//!
|
||||
//! The harness is the same shape as the `ruview-gamma` acceptance gate: a single
|
||||
//! `claim_allowed` invariant, and the claim string is unreadable except through
|
||||
//! the gate.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
/// Provenance of the labeled data a benchmark runs on. Gates whether a SOTA
|
||||
/// claim is releasable at all.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DataProvenance {
|
||||
/// Real CSI captured from hardware with independent ground truth. The only
|
||||
/// provenance that can release a claim.
|
||||
Measured,
|
||||
/// Deterministic synthetic CSI (e.g. the proof generator). Scorable for
|
||||
/// regression, never claimable.
|
||||
Synthetic,
|
||||
/// Mock/stub data path. Scorable, never claimable — mock contamination is a
|
||||
/// documented failure mode (CLAUDE.md Kconfig-bug lesson).
|
||||
Mock,
|
||||
}
|
||||
|
||||
impl DataProvenance {
|
||||
/// Whether data of this provenance may ever release a SOTA/accuracy claim.
|
||||
pub fn is_claimable(self) -> bool {
|
||||
matches!(self, DataProvenance::Measured)
|
||||
}
|
||||
|
||||
/// Stable lowercase tag for logs/reports.
|
||||
pub fn tag(self) -> &'static str {
|
||||
match self {
|
||||
DataProvenance::Measured => "measured",
|
||||
DataProvenance::Synthetic => "synthetic",
|
||||
DataProvenance::Mock => "mock",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The research-only string returned when a claim is withheld.
|
||||
pub const NO_CLAIM: &str = "research use only — not claimable (non-measured data, leaky split, or unmet thresholds)";
|
||||
|
||||
/// Ground-truth / predicted occupancy for one sample.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Occupancy {
|
||||
/// Whether any person is present.
|
||||
pub present: bool,
|
||||
/// Estimated number of people.
|
||||
pub person_count: u32,
|
||||
}
|
||||
|
||||
impl Occupancy {
|
||||
/// Construct an occupancy label.
|
||||
pub fn new(present: bool, person_count: u32) -> Self {
|
||||
Self { present, person_count }
|
||||
}
|
||||
}
|
||||
|
||||
/// One labeled, attributed evaluation sample: who/where it came from (for
|
||||
/// leakage checks) and the ground-truth vs predicted occupancy.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LabeledSample {
|
||||
/// Subject identity (for subject-disjoint split enforcement).
|
||||
pub subject_id: String,
|
||||
/// Capture environment/room (for environment-disjoint split enforcement).
|
||||
pub environment_id: String,
|
||||
/// Ground-truth occupancy.
|
||||
pub truth: Occupancy,
|
||||
/// Model-predicted occupancy.
|
||||
pub predicted: Occupancy,
|
||||
}
|
||||
|
||||
/// A train/test split by sample index, with leakage validation.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EvalSplit {
|
||||
/// Indices of training samples.
|
||||
pub train_idx: Vec<usize>,
|
||||
/// Indices of held-out test samples (graded).
|
||||
pub test_idx: Vec<usize>,
|
||||
}
|
||||
|
||||
/// Why a split is rejected.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum SplitError {
|
||||
/// A subject id appears in both train and test (subject leakage).
|
||||
SubjectLeakage(String),
|
||||
/// An environment id appears in both (per-environment overfitting risk).
|
||||
EnvironmentLeakage(String),
|
||||
/// An index is out of range for the sample set.
|
||||
IndexOutOfRange(usize),
|
||||
/// The test set is empty.
|
||||
EmptyTest,
|
||||
}
|
||||
|
||||
impl EvalSplit {
|
||||
/// Validate the split against `samples`: every test subject/environment must
|
||||
/// be **disjoint** from the training set. This is the single most common
|
||||
/// way WiFi-sensing papers overstate accuracy (doc 03).
|
||||
pub fn validate(&self, samples: &[LabeledSample]) -> Result<(), SplitError> {
|
||||
if self.test_idx.is_empty() {
|
||||
return Err(SplitError::EmptyTest);
|
||||
}
|
||||
for &i in self.train_idx.iter().chain(&self.test_idx) {
|
||||
if i >= samples.len() {
|
||||
return Err(SplitError::IndexOutOfRange(i));
|
||||
}
|
||||
}
|
||||
let train_subjects: BTreeSet<&str> =
|
||||
self.train_idx.iter().map(|&i| samples[i].subject_id.as_str()).collect();
|
||||
let train_envs: BTreeSet<&str> =
|
||||
self.train_idx.iter().map(|&i| samples[i].environment_id.as_str()).collect();
|
||||
for &i in &self.test_idx {
|
||||
let s = &samples[i];
|
||||
if train_subjects.contains(s.subject_id.as_str()) {
|
||||
return Err(SplitError::SubjectLeakage(s.subject_id.clone()));
|
||||
}
|
||||
if train_envs.contains(s.environment_id.as_str()) {
|
||||
return Err(SplitError::EnvironmentLeakage(s.environment_id.clone()));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Pre-registered acceptance thresholds (doc 03 acceptance table). Defaults are
|
||||
/// deliberately conservative; tighten per capability axis.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct BenchmarkCriteria {
|
||||
/// Minimum presence F1 (lower CI bound must clear this).
|
||||
pub min_presence_f1: f64,
|
||||
/// Maximum person-count mean absolute error.
|
||||
pub max_count_mae: f64,
|
||||
/// Minimum test samples to grade at all (small-N guard).
|
||||
pub min_test_samples: usize,
|
||||
/// Minimum fraction of ground-truth **present** samples in the test set
|
||||
/// (degenerate-test-set guard, review finding 2): an all-absent (or
|
||||
/// nearly all-absent) test set makes presence F1 vacuous — an
|
||||
/// always-absent predictor must not be able to release a claim. The gate
|
||||
/// additionally requires at least one ground-truth *absent* sample, so
|
||||
/// both classes must be represented.
|
||||
pub min_positive_rate: f64,
|
||||
/// Bootstrap resamples for the CI.
|
||||
pub bootstrap_iters: usize,
|
||||
/// Deterministic bootstrap seed.
|
||||
pub bootstrap_seed: u64,
|
||||
}
|
||||
|
||||
impl Default for BenchmarkCriteria {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
min_presence_f1: 0.9,
|
||||
max_count_mae: 0.5,
|
||||
min_test_samples: 30,
|
||||
min_positive_rate: 0.1,
|
||||
bootstrap_iters: 1000,
|
||||
bootstrap_seed: 42,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The graded result.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct BenchmarkReport {
|
||||
/// Data provenance tag (`measured`/`synthetic`/`mock`).
|
||||
pub provenance_tag: &'static str,
|
||||
/// Number of held-out test samples graded.
|
||||
pub n_test: usize,
|
||||
/// Presence accuracy (TP+TN)/N.
|
||||
pub presence_accuracy: f64,
|
||||
/// Presence F1 (point estimate).
|
||||
pub presence_f1: f64,
|
||||
/// 95% bootstrap CI for presence F1 (lower, upper).
|
||||
pub presence_f1_ci: (f64, f64),
|
||||
/// Fraction of samples with an exactly correct person count.
|
||||
pub count_exact_match: f64,
|
||||
/// Person-count mean absolute error.
|
||||
pub count_mae: f64,
|
||||
/// Data is measured (claimable provenance).
|
||||
pub provenance_pass: bool,
|
||||
/// Split is leak-free (subject- and environment-disjoint).
|
||||
pub split_pass: bool,
|
||||
/// Presence F1 CI-lower clears the threshold.
|
||||
pub presence_pass: bool,
|
||||
/// Count MAE within the threshold.
|
||||
pub count_pass: bool,
|
||||
/// Test set is large enough to grade.
|
||||
pub sample_size_pass: bool,
|
||||
/// Test set contains both truth classes with at least `min_positive_rate`
|
||||
/// present-true samples (degenerate test set ⇒ fail, own failure reason).
|
||||
pub class_balance_pass: bool,
|
||||
/// All six criteria pass.
|
||||
pub overall_pass: bool,
|
||||
/// The released claim string (or [`NO_CLAIM`]).
|
||||
pub released_claim: String,
|
||||
}
|
||||
|
||||
impl BenchmarkReport {
|
||||
/// The released claim string (program claim on pass, [`NO_CLAIM`] on fail).
|
||||
pub fn claim(&self) -> &str {
|
||||
&self.released_claim
|
||||
}
|
||||
}
|
||||
|
||||
/// **The single claim invariant.** A SOTA/accuracy claim is releasable only when
|
||||
/// the data is measured, the split is leak-free, the sample is large enough,
|
||||
/// the test set is non-degenerate (both classes represented), and both the
|
||||
/// (CI-lower) presence F1 and the count MAE clear their thresholds.
|
||||
#[inline]
|
||||
pub fn claim_allowed(
|
||||
provenance_pass: bool,
|
||||
split_pass: bool,
|
||||
sample_size_pass: bool,
|
||||
class_balance_pass: bool,
|
||||
presence_pass: bool,
|
||||
count_pass: bool,
|
||||
) -> bool {
|
||||
provenance_pass
|
||||
&& split_pass
|
||||
&& sample_size_pass
|
||||
&& class_balance_pass
|
||||
&& presence_pass
|
||||
&& count_pass
|
||||
}
|
||||
|
||||
/// Grade the test split of `samples` under `criteria`.
|
||||
///
|
||||
/// `split` is validated first; on any leakage the report is marked invalid and
|
||||
/// the claim is withheld (metrics are still computed for visibility).
|
||||
pub fn evaluate(
|
||||
samples: &[LabeledSample],
|
||||
provenance: DataProvenance,
|
||||
split: &EvalSplit,
|
||||
criteria: &BenchmarkCriteria,
|
||||
) -> BenchmarkReport {
|
||||
let split_pass = split.validate(samples).is_ok();
|
||||
let test: Vec<&LabeledSample> = split
|
||||
.test_idx
|
||||
.iter()
|
||||
.filter(|&&i| i < samples.len())
|
||||
.map(|&i| &samples[i])
|
||||
.collect();
|
||||
let n_test = test.len();
|
||||
|
||||
// Presence confusion counts.
|
||||
let (mut tp, mut fp, mut tn, mut fn_) = (0u64, 0u64, 0u64, 0u64);
|
||||
let mut count_abs_err_sum = 0.0;
|
||||
let mut count_exact = 0u64;
|
||||
let mut truth_present = 0u64;
|
||||
for s in &test {
|
||||
if s.truth.present {
|
||||
truth_present += 1;
|
||||
}
|
||||
match (s.predicted.present, s.truth.present) {
|
||||
(true, true) => tp += 1,
|
||||
(true, false) => fp += 1,
|
||||
(false, false) => tn += 1,
|
||||
(false, true) => fn_ += 1,
|
||||
}
|
||||
count_abs_err_sum +=
|
||||
(s.predicted.person_count as f64 - s.truth.person_count as f64).abs();
|
||||
if s.predicted.person_count == s.truth.person_count {
|
||||
count_exact += 1;
|
||||
}
|
||||
}
|
||||
let presence_accuracy = if n_test > 0 {
|
||||
(tp + tn) as f64 / n_test as f64
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let presence_f1 = f1_from_confusion(tp, fp, fn_);
|
||||
let count_mae = if n_test > 0 {
|
||||
count_abs_err_sum / n_test as f64
|
||||
} else {
|
||||
f64::INFINITY
|
||||
};
|
||||
let count_exact_match = if n_test > 0 {
|
||||
count_exact as f64 / n_test as f64
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let presence_f1_ci = bootstrap_f1_ci(&test, criteria.bootstrap_iters, criteria.bootstrap_seed);
|
||||
|
||||
let provenance_pass = provenance.is_claimable();
|
||||
let sample_size_pass = n_test >= criteria.min_test_samples;
|
||||
// Degenerate-test-set guard (review finding 2): both truth classes must be
|
||||
// represented — at least `min_positive_rate` present samples AND at least
|
||||
// one absent sample. Otherwise the F1/accuracy numbers are vacuous (an
|
||||
// all-absent set is aced by a predictor that always says "absent").
|
||||
let positive_rate = if n_test > 0 {
|
||||
truth_present as f64 / n_test as f64
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let class_balance_pass =
|
||||
n_test > 0 && positive_rate >= criteria.min_positive_rate && truth_present < n_test as u64;
|
||||
// Gate on the LOWER CI bound, not the point estimate (small-N guard).
|
||||
let presence_pass = presence_f1_ci.0 >= criteria.min_presence_f1;
|
||||
let count_pass = count_mae <= criteria.max_count_mae;
|
||||
let overall_pass = claim_allowed(
|
||||
provenance_pass,
|
||||
split_pass,
|
||||
sample_size_pass,
|
||||
class_balance_pass,
|
||||
presence_pass,
|
||||
count_pass,
|
||||
);
|
||||
|
||||
let released_claim = if overall_pass {
|
||||
format!(
|
||||
"presence F1 {:.3} (95% CI {:.3}-{:.3}), count MAE {:.3} on {} held-out measured samples",
|
||||
presence_f1, presence_f1_ci.0, presence_f1_ci.1, count_mae, n_test
|
||||
)
|
||||
} else {
|
||||
NO_CLAIM.to_string()
|
||||
};
|
||||
|
||||
BenchmarkReport {
|
||||
provenance_tag: provenance.tag(),
|
||||
n_test,
|
||||
presence_accuracy,
|
||||
presence_f1,
|
||||
presence_f1_ci,
|
||||
count_exact_match,
|
||||
count_mae,
|
||||
provenance_pass,
|
||||
split_pass,
|
||||
presence_pass,
|
||||
count_pass,
|
||||
sample_size_pass,
|
||||
class_balance_pass,
|
||||
overall_pass,
|
||||
released_claim,
|
||||
}
|
||||
}
|
||||
|
||||
fn f1_from_confusion(tp: u64, fp: u64, fn_: u64) -> f64 {
|
||||
let denom = 2 * tp + fp + fn_;
|
||||
if denom == 0 {
|
||||
// No positives anywhere (tp = fp = fn = 0): F1 is undefined, and the
|
||||
// vacuous case must score 0.0, never 1.0 — an all-absent test set plus
|
||||
// an always-absent predictor was previously awarded a perfect F1
|
||||
// (review finding 2). The class-balance criterion independently fails
|
||||
// such a degenerate set with its own reason.
|
||||
return 0.0;
|
||||
}
|
||||
(2 * tp) as f64 / denom as f64
|
||||
}
|
||||
|
||||
/// Deterministic 95% bootstrap CI for presence F1 (percentile method) using a
|
||||
/// small splitmix64 PRNG — no external rng, reproducible across machines.
|
||||
fn bootstrap_f1_ci(test: &[&LabeledSample], iters: usize, seed: u64) -> (f64, f64) {
|
||||
let n = test.len();
|
||||
if n == 0 || iters == 0 {
|
||||
return (0.0, 0.0);
|
||||
}
|
||||
let mut state = seed;
|
||||
let mut next = || {
|
||||
// splitmix64
|
||||
state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
|
||||
let mut z = state;
|
||||
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
|
||||
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
|
||||
z ^ (z >> 31)
|
||||
};
|
||||
let mut f1s = Vec::with_capacity(iters);
|
||||
for _ in 0..iters {
|
||||
let (mut tp, mut fp, mut fn_) = (0u64, 0u64, 0u64);
|
||||
for _ in 0..n {
|
||||
let idx = (next() % n as u64) as usize;
|
||||
let s = test[idx];
|
||||
match (s.predicted.present, s.truth.present) {
|
||||
(true, true) => tp += 1,
|
||||
(true, false) => fp += 1,
|
||||
(false, true) => fn_ += 1,
|
||||
(false, false) => {}
|
||||
}
|
||||
}
|
||||
f1s.push(f1_from_confusion(tp, fp, fn_));
|
||||
}
|
||||
f1s.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
||||
let pct = |q: f64| {
|
||||
let rank = ((q * (f1s.len() as f64 - 1.0)).round() as usize).min(f1s.len() - 1);
|
||||
f1s[rank]
|
||||
};
|
||||
(pct(0.025), pct(0.975))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sample(subj: &str, env: &str, t: (bool, u32), p: (bool, u32)) -> LabeledSample {
|
||||
LabeledSample {
|
||||
subject_id: subj.into(),
|
||||
environment_id: env.into(),
|
||||
truth: Occupancy::new(t.0, t.1),
|
||||
predicted: Occupancy::new(p.0, p.1),
|
||||
}
|
||||
}
|
||||
|
||||
/// A perfect predictor on a leak-free MEASURED split releases a claim.
|
||||
fn perfect_measured(n: usize) -> (Vec<LabeledSample>, EvalSplit) {
|
||||
let mut samples = Vec::new();
|
||||
// train subjects s0.., test subjects t0.. (disjoint); envs likewise.
|
||||
for i in 0..n {
|
||||
samples.push(sample(
|
||||
&format!("train-s{i}"),
|
||||
&format!("train-e{i}"),
|
||||
(i % 2 == 0, (i % 3) as u32),
|
||||
(i % 2 == 0, (i % 3) as u32),
|
||||
));
|
||||
}
|
||||
for i in 0..n {
|
||||
samples.push(sample(
|
||||
&format!("test-s{i}"),
|
||||
&format!("test-e{i}"),
|
||||
(i % 2 == 0, (i % 3) as u32),
|
||||
(i % 2 == 0, (i % 3) as u32),
|
||||
));
|
||||
}
|
||||
let split = EvalSplit {
|
||||
train_idx: (0..n).collect(),
|
||||
test_idx: (n..2 * n).collect(),
|
||||
};
|
||||
(samples, split)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn perfect_measured_releases_claim() {
|
||||
let (samples, split) = perfect_measured(40);
|
||||
let r = evaluate(&samples, DataProvenance::Measured, &split, &BenchmarkCriteria::default());
|
||||
assert!(r.overall_pass);
|
||||
assert!((r.presence_f1 - 1.0).abs() < 1e-9);
|
||||
assert_eq!(r.count_mae, 0.0);
|
||||
assert!(r.released_claim.contains("F1"));
|
||||
assert!(!r.released_claim.contains("research use only"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synthetic_data_is_scored_but_never_claimed() {
|
||||
let (samples, split) = perfect_measured(40);
|
||||
let r = evaluate(&samples, DataProvenance::Synthetic, &split, &BenchmarkCriteria::default());
|
||||
// Metrics are still computed...
|
||||
assert!((r.presence_f1 - 1.0).abs() < 1e-9);
|
||||
// ...but no claim, because the data is not measured.
|
||||
assert!(!r.provenance_pass);
|
||||
assert!(!r.overall_pass);
|
||||
assert_eq!(r.claim(), NO_CLAIM);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mock_data_is_never_claimed() {
|
||||
let (samples, split) = perfect_measured(40);
|
||||
let r = evaluate(&samples, DataProvenance::Mock, &split, &BenchmarkCriteria::default());
|
||||
assert!(!r.provenance_pass);
|
||||
assert_eq!(r.claim(), NO_CLAIM);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subject_leakage_is_rejected() {
|
||||
// Same subject id in train and test.
|
||||
let samples = vec![
|
||||
sample("shared", "e0", (true, 1), (true, 1)),
|
||||
sample("shared", "e1", (true, 1), (true, 1)),
|
||||
];
|
||||
let split = EvalSplit { train_idx: vec![0], test_idx: vec![1] };
|
||||
assert_eq!(
|
||||
split.validate(&samples),
|
||||
Err(SplitError::SubjectLeakage("shared".into()))
|
||||
);
|
||||
let r = evaluate(&samples, DataProvenance::Measured, &split, &BenchmarkCriteria::default());
|
||||
assert!(!r.split_pass);
|
||||
assert!(!r.overall_pass);
|
||||
assert_eq!(r.claim(), NO_CLAIM);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn environment_leakage_is_rejected() {
|
||||
let samples = vec![
|
||||
sample("s0", "shared-room", (true, 1), (true, 1)),
|
||||
sample("s1", "shared-room", (true, 1), (true, 1)),
|
||||
];
|
||||
let split = EvalSplit { train_idx: vec![0], test_idx: vec![1] };
|
||||
assert_eq!(
|
||||
split.validate(&samples),
|
||||
Err(SplitError::EnvironmentLeakage("shared-room".into()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn small_sample_is_withheld_even_if_perfect() {
|
||||
let (samples, split) = perfect_measured(5); // 5 < default min 30
|
||||
let r = evaluate(&samples, DataProvenance::Measured, &split, &BenchmarkCriteria::default());
|
||||
assert!(!r.sample_size_pass);
|
||||
assert!(!r.overall_pass);
|
||||
}
|
||||
|
||||
/// The probative CI-gate case (review finding 10): a test set whose POINT
|
||||
/// F1 clears the 0.9 threshold while the bootstrap CI LOWER bound falls
|
||||
/// below it — the claim must be withheld. A point-estimate gate would
|
||||
/// (wrongly) release here.
|
||||
#[test]
|
||||
fn gate_uses_ci_lower_bound_not_point_estimate() {
|
||||
let mut samples = Vec::new();
|
||||
for i in 0..40 {
|
||||
samples.push(sample(
|
||||
&format!("train-{i}"),
|
||||
&format!("te-{i}"),
|
||||
(i % 2 == 0, 1),
|
||||
(i % 2 == 0, 1),
|
||||
));
|
||||
}
|
||||
// Test: 20 truth-present / 20 truth-absent (class-balanced). All
|
||||
// absents predicted correctly; 3 of the 20 presents missed (FN).
|
||||
// Point F1 = 2·17/(2·17 + 0 + 3) = 34/37 ≈ 0.919 ≥ 0.9, but resamples
|
||||
// drawing 4+ of the FNs push F1 below 0.9, so the 2.5th percentile
|
||||
// lands under the threshold.
|
||||
for i in 0..40 {
|
||||
let truth_present = i < 20;
|
||||
let predicted_present = truth_present && i >= 3; // i 0..3 → FN
|
||||
samples.push(sample(
|
||||
&format!("test-{i}"),
|
||||
&format!("tn-{i}"),
|
||||
(truth_present, u32::from(truth_present)),
|
||||
(predicted_present, u32::from(truth_present)),
|
||||
));
|
||||
}
|
||||
let split = EvalSplit { train_idx: (0..40).collect(), test_idx: (40..80).collect() };
|
||||
let criteria = BenchmarkCriteria::default();
|
||||
let r = evaluate(&samples, DataProvenance::Measured, &split, &criteria);
|
||||
// Construct verified: point estimate above the threshold...
|
||||
assert!(
|
||||
r.presence_f1 >= criteria.min_presence_f1,
|
||||
"fixture must put the point estimate ({:.3}) above the threshold",
|
||||
r.presence_f1
|
||||
);
|
||||
// ...while the CI lower bound is below it...
|
||||
assert!(
|
||||
r.presence_f1_ci.0 < criteria.min_presence_f1,
|
||||
"fixture must put the CI lower bound ({:.3}) below the threshold",
|
||||
r.presence_f1_ci.0
|
||||
);
|
||||
// ...and the claim is therefore withheld.
|
||||
assert!(!r.presence_pass);
|
||||
assert!(!r.overall_pass);
|
||||
assert_eq!(r.claim(), NO_CLAIM);
|
||||
// Every other criterion passes, isolating the CI gate as the cause.
|
||||
assert!(r.provenance_pass && r.split_pass && r.sample_size_pass);
|
||||
assert!(r.class_balance_pass && r.count_pass);
|
||||
}
|
||||
|
||||
/// Degenerate test set (review finding 2): all-absent ground truth plus an
|
||||
/// always-absent predictor must NOT release a claim — F1 is vacuous (0.0,
|
||||
/// not 1.0) and the class-balance criterion fails with its own flag.
|
||||
#[test]
|
||||
fn all_absent_test_set_is_degenerate_and_withheld() {
|
||||
let mut samples = Vec::new();
|
||||
for i in 0..40 {
|
||||
samples.push(sample(&format!("tr-{i}"), &format!("te-{i}"), (true, 1), (true, 1)));
|
||||
}
|
||||
for i in 0..40 {
|
||||
// Truth all absent; predictor always says absent → tp=fp=fn=0.
|
||||
samples.push(sample(&format!("ts-{i}"), &format!("ev-{i}"), (false, 0), (false, 0)));
|
||||
}
|
||||
let split = EvalSplit { train_idx: (0..40).collect(), test_idx: (40..80).collect() };
|
||||
let r = evaluate(&samples, DataProvenance::Measured, &split, &BenchmarkCriteria::default());
|
||||
// Vacuous F1 scores 0.0 (was 1.0 before the fix).
|
||||
assert_eq!(r.presence_f1, 0.0);
|
||||
assert_eq!(r.presence_f1_ci, (0.0, 0.0));
|
||||
// Degeneracy is named as its own failed criterion.
|
||||
assert!(!r.class_balance_pass);
|
||||
assert!(!r.overall_pass);
|
||||
assert_eq!(r.claim(), NO_CLAIM);
|
||||
}
|
||||
|
||||
/// The mirror degeneracy: an all-PRESENT test set (no absent samples) is
|
||||
/// also refused — a trivially always-present predictor would ace it.
|
||||
#[test]
|
||||
fn all_present_test_set_is_degenerate_and_withheld() {
|
||||
let mut samples = Vec::new();
|
||||
for i in 0..40 {
|
||||
samples.push(sample(&format!("tr-{i}"), &format!("te-{i}"), (i % 2 == 0, 1), (i % 2 == 0, 1)));
|
||||
}
|
||||
for i in 0..40 {
|
||||
samples.push(sample(&format!("ts-{i}"), &format!("ev-{i}"), (true, 1), (true, 1)));
|
||||
}
|
||||
let split = EvalSplit { train_idx: (0..40).collect(), test_idx: (40..80).collect() };
|
||||
let r = evaluate(&samples, DataProvenance::Measured, &split, &BenchmarkCriteria::default());
|
||||
assert!((r.presence_f1 - 1.0).abs() < 1e-9, "metric still computed");
|
||||
assert!(!r.class_balance_pass, "single-class test set is degenerate");
|
||||
assert!(!r.overall_pass);
|
||||
assert_eq!(r.claim(), NO_CLAIM);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bootstrap_ci_is_deterministic() {
|
||||
let (samples, split) = perfect_measured(40);
|
||||
let a = evaluate(&samples, DataProvenance::Measured, &split, &BenchmarkCriteria::default());
|
||||
let b = evaluate(&samples, DataProvenance::Measured, &split, &BenchmarkCriteria::default());
|
||||
assert_eq!(a.presence_f1_ci, b.presence_f1_ci);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn count_mae_failure_withholds_claim() {
|
||||
let mut samples = Vec::new();
|
||||
for i in 0..40 {
|
||||
samples.push(sample(&format!("tr-{i}"), &format!("te-{i}"), (true, 1), (true, 1)));
|
||||
}
|
||||
// Class-balanced test set (so count MAE is the ONLY failing criterion):
|
||||
// presence perfect, but the count is always off by 2 -> MAE 2.0 > 0.5.
|
||||
for i in 0..40 {
|
||||
let present = i % 2 == 0;
|
||||
let truth_count = u32::from(present);
|
||||
samples.push(sample(
|
||||
&format!("ts-{i}"),
|
||||
&format!("ev-{i}"),
|
||||
(present, truth_count),
|
||||
(present, truth_count + 2),
|
||||
));
|
||||
}
|
||||
let split = EvalSplit { train_idx: (0..40).collect(), test_idx: (40..80).collect() };
|
||||
let r = evaluate(&samples, DataProvenance::Measured, &split, &BenchmarkCriteria::default());
|
||||
assert!(r.presence_pass);
|
||||
assert!(r.class_balance_pass);
|
||||
assert!(!r.count_pass);
|
||||
assert!(!r.overall_pass);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claim_invariant_requires_all_six() {
|
||||
assert!(claim_allowed(true, true, true, true, true, true));
|
||||
// Every single-false combination is denied.
|
||||
for i in 0..6 {
|
||||
let v: Vec<bool> = (0..6).map(|j| j != i).collect();
|
||||
assert!(
|
||||
!claim_allowed(v[0], v[1], v[2], v[3], v[4], v[5]),
|
||||
"criterion {i} false must deny the claim"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -201,6 +201,47 @@ impl WorldGraph {
|
||||
id
|
||||
}
|
||||
|
||||
/// Retention: evict the oldest `SemanticState` nodes (with their incident
|
||||
/// edges) until at most `max_states` remain. Returns the evicted ids,
|
||||
/// oldest first.
|
||||
///
|
||||
/// The live loop appends one belief per cycle (`StreamingEngine::
|
||||
/// process_cycle`), which at 20 Hz is ~1.7M nodes/day — unbounded without
|
||||
/// this. The WorldGraph holds *current* beliefs; durable history belongs to
|
||||
/// the recorder (`homecore-recorder`), so evicting old beliefs loses no
|
||||
/// audit data.
|
||||
///
|
||||
/// Deterministic: eviction order is ascending `(valid_from_unix_ms, id)`,
|
||||
/// so replaying the same cycle sequence prunes identically. Only
|
||||
/// `SemanticState` nodes are eligible — rooms, zones, sensors, anchors,
|
||||
/// person tracks, and events are never evicted by this method.
|
||||
pub fn prune_semantic_states(&mut self, max_states: usize) -> Vec<WorldId> {
|
||||
let mut states: Vec<(i64, u64)> = self
|
||||
.inner
|
||||
.node_weights()
|
||||
.filter_map(|n| match n {
|
||||
WorldNode::SemanticState { id, valid_from_unix_ms, .. } => {
|
||||
Some((*valid_from_unix_ms, id.0))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
if states.len() <= max_states {
|
||||
return Vec::new();
|
||||
}
|
||||
states.sort_unstable();
|
||||
let n_evict = states.len() - max_states;
|
||||
states.truncate(n_evict);
|
||||
states
|
||||
.into_iter()
|
||||
.map(|(_, raw)| {
|
||||
let id = WorldId(raw);
|
||||
self.remove_node(id);
|
||||
id
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Record a contradiction between two still-live beliefs (ADR-139 §2.3).
|
||||
/// Neither node is deleted — the disagreement stays queryable.
|
||||
///
|
||||
@@ -424,6 +465,56 @@ mod tests {
|
||||
assert!(g.neighbors(s1).iter().any(|(_, e)| matches!(e, WorldEdge::Contradicts { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prune_semantic_states_evicts_oldest_only() {
|
||||
let mut g = WorldGraph::new(GeoRegistration::default());
|
||||
let room = g.upsert_node(living_room());
|
||||
let prov = SemanticProvenance {
|
||||
evidence: vec!["ev:abc".into()],
|
||||
model_version: "rfenc-1.0".into(),
|
||||
calibration_version: "cal:uuid".into(),
|
||||
privacy_decision: "PrivateHome/Allow".into(),
|
||||
};
|
||||
let ids: Vec<WorldId> = (0..10)
|
||||
.map(|t| g.add_semantic_state(format!("s{t}"), 0.9, t, prov.clone(), &[room]))
|
||||
.collect();
|
||||
assert_eq!(g.node_count(), 11); // room + 10 beliefs
|
||||
|
||||
let evicted = g.prune_semantic_states(3);
|
||||
// Oldest 7 evicted, in ascending timestamp order.
|
||||
assert_eq!(evicted, ids[..7].to_vec());
|
||||
assert_eq!(g.node_count(), 4); // room + 3 newest beliefs
|
||||
for kept in &ids[7..] {
|
||||
assert!(g.node(*kept).is_some());
|
||||
}
|
||||
// The room (structural node) is never eligible for eviction.
|
||||
assert!(g.node(room).is_some());
|
||||
// Below the cap, pruning is a no-op.
|
||||
assert!(g.prune_semantic_states(3).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prune_is_deterministic_for_equal_timestamps() {
|
||||
let prov = SemanticProvenance {
|
||||
evidence: vec![],
|
||||
model_version: "m".into(),
|
||||
calibration_version: "c".into(),
|
||||
privacy_decision: "p".into(),
|
||||
};
|
||||
let build = || {
|
||||
let mut g = WorldGraph::new(GeoRegistration::default());
|
||||
let room = g.upsert_node(living_room());
|
||||
for _ in 0..6 {
|
||||
// Identical timestamps: tie-break must fall back to id order.
|
||||
g.add_semantic_state("s".into(), 0.5, 100, prov.clone(), &[room]);
|
||||
}
|
||||
g
|
||||
};
|
||||
let mut g1 = build();
|
||||
let mut g2 = build();
|
||||
assert_eq!(g1.prune_semantic_states(2), g2.prune_semantic_states(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn privacy_rollup_suppresses_person_tracks() {
|
||||
let mut g = WorldGraph::new(GeoRegistration::default());
|
||||
|
||||
Reference in New Issue
Block a user