Compare commits

...

26 Commits

Author SHA1 Message Date
ruv c3b16b70d0 fix: public-PR review findings — privacy-path honesty, gate holes, mesh-guard cliff
- sensing-server: engine errors logged+counted (no silent swallow), trust
  state exposed via status surface, privacy-demotion claims aligned with
  the actual parallel-audit-path behavior
- occupancy_bench: vacuous-F1 hole closed (degenerate test sets fail with
  their own criterion); CI-lower-bound test made probative
- mesh_guard: quantization scaled to observed coupling range — >=65-node
  balanced meshes no longer permanently at_risk (regression test)
- engine: both wiring tests made probative (same-topology witness compare,
  deterministic risk-crossing fixture)
- mat: axum/tokio optional behind api; real serde feature (api enables it)
- core: canonical decoder strict (non-zero reserved bytes and nil UUID
  rejected — injective on accepted domain, forged-bytes tests)
- CHANGELOG: un-spliced the FFT/adapter bullet mangle

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-11 15:26:18 -04:00
ruv 235d3b4115 fix(firmware): gamma-stim e-stop ISR safety + dead-man + unlock guard (fw 0.1.1)
- gate-GPIO architecture: envelope delivered by direct-register GPIO writes
  in the ISR (gpio_ll); LEDC programmed only from task context — e-stop's
  first instruction is gate-low (microseconds, ISR-legal); gate wiring +
  pulldown documented as REQUIRED
- independent 250ms dead-man: latches FAULT and forces gate low if the
  half-period ISR stalls while RUNNING
- UNLOCK refuses while the e-stop button is physically held
- START TOCTOU closed (gptimer_start under the mux); witness records
  deduped by session_seq; serial overflow discards until newline
- HIL contract: explicit is_finite, hash-count sanity, worst-case-over-window
  frequency error + half-period jitter criteria
Host C tests 15/15 (ruvultra gcc); ESP-IDF v5.5.2 esp32s3 build clean.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-11 15:26:17 -04:00
ruv 192b4c6b4a fix(ruview-gamma): safety becomes a property of construction (safety review, 2 critical + 3 major)
- safety stop is now control flow: run_calibration terminates the sweep on
  any stop (partial calibration recorded)
- cross-session terminate-and-lock: persisted latched ParticipantSafetyState;
  locked participants refuse sessions across governor instances until
  unlock_with_acknowledgment writes an audit record; documented class->lock map
- SafetyEnvelope safe-by-construction: private fields, validated try_new/with_*
  constructors, serde routed through the validator, compiled-in absolute bounds
  (floor >=30Hz keeps the whole band above the 15-25Hz photosensitive zone;
  caps <=0.6; <=30min) — an 18-22Hz config fails to deserialize
- dose governance: <=4 sittings/24h + >=60min cooldown from the persisted ledger
- per-tick latched SafetyMonitor wired into run_session (simulator emits ticks;
  mid-session latch truncates delivered duration); clean-session witness
  byte-identical (pinned proof unchanged)
- cohort warm-start k-floor (>=3 profiles)
ADR-250 + crate README updated. 125+13 tests green.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-11 15:26:17 -04:00
Claude 246b5ba4af feat(engine): mesh partition risk demotes privacy + enters the witness (ADR-032)
Completes the mesh-guard integration: its at_risk signal was advisory-only
(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; mesh_guard_mut
exposes risk-threshold tuning.

Test: a forced-risk 3-node cycle demotes PrivateHome Anonymous->Restricted
and shifts the witness vs a clean baseline. Engine 25 tests; workspace gate
2,947 passed / 0 failed.

https://claude.ai/code/session_01MjBucx95K4BuUxZi8NWwRH
2026-06-11 01:39:32 +00:00
Claude 3044108295 feat(engine): dynamic min-cut mesh partition guard (ruvector-mincut)
Maintains an exact min-cut over the live mesh coupling graph — nodes are
sensing nodes, coupling is the product of fusion attention weights — and
surfaces per cycle, as TrustedOutput::mesh:

- cut value: the global "how close is the array to partitioning" number,
  a structural measure per-node heuristics miss;
- weak side: which specific nodes would split off (failure/jamming triage,
  feeds ADR-032 posture);
- at-risk flag: counts as a structural event for the drift->recalibration
  advisor (alongside ADR-142 change-points).

Degenerate cases fail toward risk: a node with zero coupling is reported as
already partitioned (cut 0, that node as the weak side).

Measured cost policy (criterion, 12-node mesh — the honest part):
- weights quantized (1/64) + change-gated: steady-state cycles do ZERO graph
  work and reuse the cached cut (~7.3 us, ~23x cheaper than building);
- on any real change a full exact rebuild (~171 us) is used, because ONE
  DynamicMinCut delete+insert measured ~240 us — the subpolynomial machinery
  amortizes on much larger graphs, so rebuild-on-change is the measured
  optimum at mesh scale (one-edge case -28% after switching policy);
- full process_cycle with the guard: ~33 us for 4 nodes vs the 50 ms budget.

9 mesh_guard tests (weak-node detection, steady-state zero updates,
sub-quantum gating, join/drop rebuild, determinism, disconnection) + an
engine-level wiring test (down-weighted node -> weak side -> recalibration).
Engine 24 tests; workspace gate 2,946 passed / 0 failed.

https://claude.ai/code/session_01MjBucx95K4BuUxZi8NWwRH
2026-06-11 01:09:41 +00:00
Claude b809ca8abd feat(core): canonical frame decoder — capture-to-claim replay (ADR-136)
The encode half of the ADR-136 frame contract existed (ComplexSample,
to_canonical_bytes, witness_hash) but there was no decoder: a captured
canonical frame could be witnessed but never reconstructed, blocking
replay-from-capture.

CsiFrame::from_canonical_bytes is the exact inverse: same id, metadata,
complex payload, and witness hash (tested as the round-trip law AC7 — the
replayed frame re-encodes byte-identically). Amplitude/phase are recomputed
from the payload (projections, not independent state). Every malformed-input
class fails closed (AC8): header truncation -> Truncated, payload truncation
-> PayloadMismatch, unknown discriminants, non-UTF-8 device id, trailing
bytes. Nil calibration uuid decodes as None per the documented encoding.

Core: 36 tests pass. Workspace gate: 2,937 passed / 0 failed.

https://claude.ai/code/session_01MjBucx95K4BuUxZi8NWwRH
2026-06-11 00:51:14 +00:00
Claude 41d52311bd feat(clinic): ADR-251 clinical dashboard + hash-chained RuVector store
Read-only research/clinical instrumentation over the ADR-250 adaptive-gamma
platform, closing two operational gaps: no durable cohort memory, no
clinician surface.

Store (store.rs): append-only JSONL holding anonymized profiles, witnessed
session summaries, and acceptance verdicts. Every line is hash-chained
(entry_hash = SHA-256(prev_hash || raw record bytes)), so any retroactive
edit, deletion, or reorder breaks the chain — the store fails closed (refuses
to open tampered data) and rebuilds the RuVector kNN/clustering layer on open
so cohort warm-start survives restarts. The chain hashes the exact on-disk
bytes via serde_json RawValue, because serde_json's default float parse is
lossy and re-serialization is not byte-stable (this bit the first cut: a
9-session ingest self-corrupted on reopen).

Dashboard (server.rs + embedded dependency-free dashboard.html): Axum surface
with GET routes for participants, per-participant frequency-response map +
session trend with 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 (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). Pseudonymity asserted: the
person_id never reaches disk.

20 tests (13 store/lib + 7 server) + 1 doctest; live binary smoke-tested.
Workspace gate: 2,935 passed / 0 failed.

https://claude.ai/code/session_01MjBucx95K4BuUxZi8NWwRH
2026-06-11 00:32:38 +00:00
Claude b273fac719 perf(signal): opt-in FFT operator for the CIR ISTA solver (8-14x measured)
Phi 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 — the dominant-latency-hazard
finding from the beyond-SOTA optimization roadmap.

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 in the last bits and requires
regenerating any pinned witness — strictly opt-in per deployment.

FftOperator (rustfft, planned once at CirEstimator::new, scratch buffers
reused across the ISTA loop) dispatches inside ista_solve:
  Phi x   = scale * forward-FFT(x) sampled at bins (k_idx mod G)
  Phi^H v = scale * unnormalised inverse-FFT of v scattered into those bins
Warm-start and Lipschitz estimation stay dense at construction.

Measured (criterion, same run, same machine):
  ht20: 2.22 ms -> 265 us  (8.4x)
  ht40: 10.26 ms -> 717 us (14.3x)
The real HE40 grid (K=484, G=1452) scales further per the O(K*G)/O(G log G)
ratio.

3 new tests: FFT<->dense matvec equivalence to float tolerance on ht20 and
he40 grids; end-to-end dominant-tap agreement on a single-path frame; all
default configs keep FFT off. New cir_estimate_fft bench group.

Workspace gate: 2,921 passed / 0 failed (default path bit-exact, witnesses
unchanged).

https://claude.ai/code/session_01MjBucx95K4BuUxZi8NWwRH
2026-06-11 00:10:40 +00:00
Claude 22dfd0a407 fix(mat): gate api module behind its feature — standalone no-default-features builds
pub mod api was unconditional while its only dependency, serde, is optional
behind the 'api' feature, so any build without default features failed with
101 unresolved-serde errors (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 combos compile: bare --no-default-features (was 101 errors, now 0),
--no-default-features --features api, and full default (177 tests pass).
Workspace gate: 2,918 passed / 0 failed.

https://claude.ai/code/session_01MjBucx95K4BuUxZi8NWwRH
2026-06-10 21:56:58 +00:00
Claude 6953944255 feat(engine): per-room adapter provenance + drift-to-recalibration advisor
Closes the trust-chain gap where an ~11 KB per-room LoRA adapter (ADR-150
section 3.4) could silently change inference without the witness noticing:
provenance carried only "rfenc-v<N>" with no notion of adapter identity.

- 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.
- RecalibrationAdvisor: recommends re-running the ADR-135 empty-room baseline
  / refitting the room 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, stored on the
  sensing-server AppState alongside the witness at both live fusion sites.
- Bridge plumbing: EngineBridge::{set_room_adapter, clear_room_adapter} +
  live-path test that the adapter id flows into the live witness.

Scope note (honest): 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/); a trained
RF-encoder checkpoint still does not exist in-tree.

Engine 15 tests, bridge 7 tests. Workspace gate: 2,918 passed / 0 failed.

https://claude.ai/code/session_01MjBucx95K4BuUxZi8NWwRH
2026-06-10 21:26:56 +00:00
Claude d925538773 feat(train): falsifiable occupancy benchmark with anti-overfitting gate
Makes the presence/person-count "beyond SOTA" claim falsifiable in code
instead of aspirational (the unfalsifiability gap from the beyond-SOTA system
review). occupancy_bench grades predictions vs ground truth and gates a SOTA
claim behind one claim_allowed invariant requiring ALL of:

- DataProvenance::Measured — synthetic/mock data is scorable for regression
  but never claimable (anti-mock-contamination; the CLAUDE.md Kconfig-bug
  lesson made structural).
- A leak-free EvalSplit — validate() refuses any split where a subject OR
  environment id appears in both train and test (subject leakage /
  per-environment overfitting).
- n_test >= min_test_samples (small-N guard).
- Presence F1 whose bootstrap-CI lower bound (deterministic seeded splitmix64)
  clears the threshold — not the point estimate.
- 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.

Lives in wifi-densepose-train (the eval bounded context, alongside ablation/
eval/metrics). 10 tests cover each refusal path; warning-clean under the
crate's missing_docs lint. Workspace gate 2,914 passed / 0 failed. Doc 03
updated.

https://claude.ai/code/session_01MjBucx95K4BuUxZi8NWwRH
2026-06-10 20:43:42 +00:00
Claude a8291d2535 feat(sensing-server): route live frames through the governed StreamingEngine
Closes the live-trust-path gap (ADR-136 section 8, beyond-SOTA system review):
the running server fused live CSI with the bare MultistaticFuser, while the
privacy/provenance/witness control plane (ADR-135..146) only ever ran on
synthetic in-test frames. The privacy control plane was therefore bypassable
on the real path.

New engine_bridge module drives StreamingEngine::process_cycle from the
server's live NodeState map, reusing the existing NodeState -> MultiBandCsiFrame
conversion. It lazily wires each contributing node as a WorldGraph sensor
(idempotent), bounds belief growth via the retention cap, and forwards explicit
timestamps/calibration ids so the path stays deterministic and replayable.

Wired additively into both live ESP32/WiFi fusion sites in main.rs via a
split-borrow off the write guard, so person-count behavior is unchanged; the
latest BLAKE3 witness is stored on AppState. Every published belief now carries
evidence + model + calibration + privacy decision and a deterministic witness.

Adds wifi-densepose-engine/-worldgraph/-bfld/-geo deps. 6 new bridge tests
(witnessed belief with full provenance, cross-run determinism, idempotent node
registration, retention bound, privacy-mode propagation). sensing-server suite
430+128 green; workspace gate 2,904 passed / 0 failed.

https://claude.ai/code/session_01MjBucx95K4BuUxZi8NWwRH
2026-06-10 15:59:47 +00:00
Claude d0cf36a0ba docs(gamma): add publication-ready secret-gist writeup (RuView + RuVector)
SEO-optimized, plain-English gist for RuView Gamma referencing the RuView and
RuVector projects and branch claude/ruview-beyond-sota-xgv8aq: intro, how it
works, supporting research with honest limits, usage (Rust + ESP32), cautions,
advanced usages (trials/sham/cohort/HIL), credits, and an SEO FAQ. Includes
the gh/curl commands to publish it as a SECRET gist with the user's own token
(no token committed).

https://claude.ai/code/session_01MjBucx95K4BuUxZi8NWwRH
2026-06-10 04:41:07 +00:00
Claude 9744d367a2 feat(firmware): ESP32 gamma stimulation actuator (ADR-250 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. This is the
actuator the hil::verify_hil contract grades.

Split for testability:
- main/stim_core.{h,c}: pure C safety core, no ESP-IDF deps. Envelope
  validation mirroring SafetyEnvelope::conservative(), a latched
  START/STOP/e-stop state machine (a session can never silently resume after
  an e-stop), exact integer timing in millihertz (the +/-0.1 Hz HIL target is
  exact: 40.0 Hz = 40000 mHz -> 12500 us half-period), and a fail-closed line
  parser. 15 host tests pass under gcc, no hardware needed.
- main/main.c: ESP-IDF binding. GPTimer ISR at 1 MHz crystal ticks, LEDC PWM
  for LED (19.5 kHz carrier) and audio tone, sync-out GPIO for logic-analyzer
  capture, e-stop GPIO ISR that turns outputs off in the ISR (microseconds,
  vs the 100 ms HIL budget) then latches, USB-CDC line console.

Defense in depth: the device re-enforces the safety envelope independently of
the Rust host, so a buggy or compromised host cannot command an
out-of-envelope output. Emits a canonical integer SESSION {...} record per run
for witness-hash reproduction (HIL 100% reproducibility target).

Kconfig pins, 4 MB single-app, radio-off deterministic actuator profile.
Maps 1:1 to the five hil::verify_hil targets.

https://claude.ai/code/session_01MjBucx95K4BuUxZi8NWwRH
2026-06-10 04:39:24 +00:00
Claude 46b4d63dad feat(ruview-gamma): centralize claim-gate invariant + add HIL acceptance contract
Two of the "what to optimize next" items, as enforced code:

1. Hard claim gate: extract the release rule into a single
   acceptance::claim_allowed(entrainment, safety, adherence, repeatability)
   = strict AND of all four, used by every path. A test proves every 3-of-4
   subset is denied, so no path can silently weaken the gate to an OR/subset.

2. Hardware-in-the-loop contract (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. Every failure mode fails closed (missing/NaN stop measurement, no
   replay, any hash mismatch). The crate stays a deterministic leaf: firmware
   records the measurements, this module grades them.

README: benchmark table (safety tick ~8 ns, recommend ~15 us, cohort kNN/500
~15 us, calibration ~115 us, acceptance grading ~425 us enrollment-only) and
the positioning line — "a governed personalization engine that refuses to
overpromise."

9 new tests; crate now 97 + 1 doctest; pinned witness 13cb164c... preserved.
Workspace gate: 2,898 passed / 0 failed.

https://claude.ai/code/session_01MjBucx95K4BuUxZi8NWwRH
2026-06-10 04:31:28 +00:00
Claude d55e3659be feat(ruview-gamma): generalize to adaptive sensory neuromodulation platform
40 Hz becomes one prior in one program, not the product. The engine is a
personal neural-rhythm optimization platform: RuView is the feedback signal,
RuVector the personal response map, the device the actuator, RuFlo the
governed/auditable loop (ADR-250 section 23).

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, 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 "measurable entrainment, safety, adherence,
repeatability before any disease claim" executable. AcceptanceHarness grades
a program over >=3 repeats; ClaimGate releases the program's claim ONLY when
all four pass, else the research-only NO_CLAIM string. The marketing claim is
unreadable except through the gate.

Governor: enroll_program (per-program envelope/objective), program(), prior(),
state_eligible(). The bare enroll() path is unchanged, so the pinned witness
13cb164c... is preserved.

88 crate tests + 1 doctest; workspace gate 2,889 passed / 0 failed. Benches:
program grading ~425us; hot paths unchanged (recommend ~15us, calibration
~115us, kNN/500 ~15us).

https://claude.ai/code/session_01MjBucx95K4BuUxZi8NWwRH
2026-06-10 04:19:45 +00:00
Claude 2aac160067 feat(ruview-gamma): RuVector self-learning layer (ADR-250 §10 items 3-6)
New ruvector module: anonymized ProfileStore (one-way SHA-256 hashed tags,
safe-session scores only), deterministic exact kNN, cohort warm-start (a new
person's optimizer seeded from the k nearest responders as down-weighted GP
pseudo-observations), physiological drift detection (Welford centroid with
stimulus-input fields masked out of the distance), and deterministic k-means
response clustering.

Honesty guarantees, asserted in tests: cohort priors carry >=25x the
real-observation noise, are excluded from the EI incumbent, the audit log,
and the clinician report — borrowed expectations never masquerade as this
person's measured response. The GP gains per-observation noise; the real
path is arithmetically unchanged (pinned witness 13cb164c... preserved).

Governor wiring: seed_from_cohort, export_anonymized_profile, per-session
drift_status. Integration tests: cohort warm-start beats the cold 40 Hz
prior for a detuned subject; collapsed physiology flags Drifted.

Crate: 75 tests + 1 doctest. Workspace gate: 2,876 passed, 0 failed.
Benches: kNN/500 profiles ~15us, warm-start ~16us; no regression on
existing paths (recommend ~15us, calibration sweep ~111us).

https://claude.ai/code/session_01MjBucx95K4BuUxZi8NWwRH
2026-06-10 04:08:47 +00:00
Claude d3c1082c03 fix(worldgraph): bound SemanticState growth with deterministic retention
StreamingEngine::process_cycle appended one SemanticState belief per cycle
with no eviction — ~1.7M nodes/day at 20 Hz (beyond-SOTA roadmap finding #6).

Add 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) are never eligible. Wire it into the
engine after each belief append (DEFAULT_SEMANTIC_RETENTION = 7,200, ~6 min
at 20 Hz; set_semantic_retention to tune). The WorldGraph holds current
beliefs; durable history is the recorder's job, so no audit data is lost.

3 new tests: end-to-end bounded growth, oldest-only eviction, deterministic
equal-timestamp tie-break. Workspace gate: 2,865 passed, 0 failed.

https://claude.ai/code/session_01MjBucx95K4BuUxZi8NWwRH
2026-06-10 03:58:00 +00:00
Claude 3413a74228 perf(ruview-gamma): factorize GP once per recommendation (-81%)
BayesianOptimizer::recommend evaluated Expected Improvement at every 0.1 Hz
grid candidate, and each predict() rebuilt the kernel matrix and re-ran
Cholesky — ~82 factorizations per call — though K and alpha=K^-1 y depend
only on the observations, not the query point. Fit the GP once (GpFit:
cached L + alpha, lower-triangle-only K build) and reuse it across the grid.

Bit-identical arithmetic: the pinned deterministic witness and all 64 tests
are unchanged; pure work elimination. Measured (criterion, paired):
gamma_bayesian_recommend 105us -> 19us (-81%); gamma_calibration_sweep
466us -> 135us (-71%).

https://claude.ai/code/session_01MjBucx95K4BuUxZi8NWwRH
2026-06-10 03:36:07 +00:00
Claude 3408883f79 feat(ruview-gamma): implement ADR-250 Adaptive Gamma Entrainment crate
Governed, deterministic, safety-constrained personalization of 40 Hz-prior
light+sound stimulation (RuView sensing + RuVector response modeling + RuFlo
audit). 40 Hz is the starting prior, not the hard-coded answer.

11 modules: stimulus/SafetyEnvelope, safety (exclusion screen + latched
monitor), response (20-field PersonResponseVector + optional EEG), objective
(safe-entrainment score, safety as hard gate), simulator (deterministic
ChaCha20 frequency_response_curve), optimizer (calibration sweep + GP/EI +
closed-loop), bandit (LinUCB), session (reproducible SHA-256 witness), ruflo
(consent/exclusion/envelope/audit/trial-sham/clinician-export/claim-discipline),
proof (deterministic bundle witness), math.

Safety invariant asserted in tests: no emitted stimulus can ever leave the
SafetyEnvelope; non-finite inputs clamp to the conservative floor. Claim
discipline: only 'personalized entrainment optimization', never Alzheimer's
treatment (ADR-250 §19). Standalone leaf, publish=false pending safety sign-off.

Validation: 64 unit/integration + 1 doctest pass; full workspace gate green
(2,862 passed, 0 failed); deterministic witness pinned; criterion benches
(safety-stop tick ~9.3 ns vs the ADR §17 500 ms bound).

Adds ADR-250 doc, registers the crate, updates CLAUDE.md crate table and
CHANGELOG.

https://claude.ai/code/session_01MjBucx95K4BuUxZi8NWwRH
2026-06-10 03:30:28 +00:00
Claude 9161fa7156 perf(signal): precompute CIR warm-start system; hoist tomography solver allocs
Exact, determinism-safe optimizations (bit-identical float results):

- cir.rs: diag(PhiH Phi)+lambda*I and its CSR matrix depend only on Phi
  and lambda (fixed at CirEstimator::new) but were rebuilt every frame
  (O(K*G) pass + CSR allocation). Now built once in new() via
  build_warm_start_system; summation order unchanged.
- tomography.rs: ISTA gradient buffer hoisted out of the 100-iteration
  loop (fill(0.0) reset) and the Frobenius Lipschitz bound moved from
  per-reconstruct to construction.

Verified: signal 456 tests green; engine 11/11 green including
cycle_is_deterministic and witness-stability tests. Criterion paired
pre/post: cir_estimate/he40 -3.9% (p<0.01), multiband -1.2/-1.4%.

https://claude.ai/code/session_01MjBucx95K4BuUxZi8NWwRH
2026-06-09 21:27:57 +00:00
Claude 09960d405f docs(research): add beyond-SOTA series index with validation results; changelog
README index ties the 5 research docs together with the session's
measured validation evidence: 2,797 workspace tests / 0 failed, Python
proof PASS (bit-exact), and paired pre/post criterion CIR benchmarks.

https://claude.ai/code/session_01MjBucx95K4BuUxZi8NWwRH
2026-06-09 21:27:35 +00:00
Claude 205b12032b docs(research): add benchmark/validation methodology snapshot (03)
https://claude.ai/code/session_01MjBucx95K4BuUxZi8NWwRH
2026-06-09 21:04:15 +00:00
Claude 662820e2d1 docs(research): finalize beyond-SOTA architecture (02)
https://claude.ai/code/session_01MjBucx95K4BuUxZi8NWwRH
2026-06-09 21:03:25 +00:00
Claude 7bf8680da2 docs(research): add beyond-SOTA architecture design (02, in progress)
https://claude.ai/code/session_01MjBucx95K4BuUxZi8NWwRH
2026-06-09 21:03:15 +00:00
Claude 1deb9df2d4 docs(research): add RuView beyond-SOTA system review (00)
First document of the beyond-SOTA research series: capability audit of
the current RuView engine with role-to-crate maturity matrix, ruvsense
module inventory, gap analysis, and risk register.

https://claude.ai/code/session_01MjBucx95K4BuUxZi8NWwRH
2026-06-09 21:02:26 +00:00
69 changed files with 14293 additions and 53 deletions
+25
View File
@@ -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 (814× 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 3644 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 36).** 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 ~1319 Hz — BPM scales as `assumed/actual × true`, so 87 read ~45 and the reading swung as CSI yield fluctuated; (2) the zero-crossing estimator locked onto a breathing harmonic (a 0.25 Hz breathing fundamental puts its 3rd harmonic at ~0.74 Hz ≈ 44 BPM inside the HR band). Fix: measure the real sample rate from inter-frame timestamps (used for BPM conversion + biquad re-tuning on >15% drift); replace the HR zero-crossing with an autocorrelation estimator that rejects breathing harmonics (driven by a robust autocorr breathing period); median-13 smooth the output. Hardware A/B (fixed vs unmodified control board, both `edge_tier=2`): control pegged 4049 BPM; fixed reaches the true 8891 BPM (vs 87 GT) and holds a stable physiological value (spread 59→0 for a steady subject). Known limitation: heavy subject motion still degrades the estimate (motion gating is a follow-up).
- **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.
+2
View File
@@ -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 3644 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 | 3644 Hz | published exploratory range |
| Starting prior | 40 Hz | strongest preclinical literature |
| Modality | audio, visual, combined | combined preferred (GENUS-style) |
| Brightness | bounded lowmoderate | avoid unsafe flicker intensity |
| Volume | bounded lowmoderate | 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 3644 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.
## 912. 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 1525 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 1822 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-118123/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 (R1R20), 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-136146 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-118123, 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 (200500 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 14 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-125133 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-105109 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.786.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 ≈ 90100 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 ~6365% 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 20252026 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 (5200 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 (05 occupants, lab) | 20202024 | typically 9097% 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 14 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) | 20232026 | HR MAE typically 13 bpm at 13 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, 13 m WiFi HR is ~0.81.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 | 12 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 | 20152021 | 0.41 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: 3060 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.47.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 | 20102013 | Through-wall tracking via RSS networks, ~0.51 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 20252026 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) | 202425 | 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 17.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-105109 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 20252026 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.53.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; 6365% 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, 100200 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 $615 ESP32 mesh, bench-validated | All placement numbers are synthetic; Tier-2.3 bench validation outstanding |
| Person count | ~9097% lab, ≤5 people **[K]** | cog ships uncertainty intervals **[I]** | Exact count 16 people ≥95% with honest intervals, multistatic, real bench | Multi-person CSI superposition; no public multi-occupancy benchmark |
| Vital signs HR | 0.81.2 bpm median, single subject, LoS, 13 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.43.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** — $615/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-105109) 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.23.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.22.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 I1I3).
**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érRao 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 816); 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.52.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 / 5458.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 3340 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.22.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 4049 BPM; fixed 8891 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: `‖predgt‖ ≤ 0.2·‖R-shoulderL-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%** @100200 samples (3 seeds); cross-env **10.6→73.1%** @200, 60.1% @5 (ADR-150 §3.53.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 **LowMedium**, 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·(AUC0.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 | 8891 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 1620 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 113 (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 5113152395) 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 ~840× 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 (242484) | 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 (48 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 F1F12, 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 110)
- 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 1035)
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 3570)
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 7090)
- 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 (F1F12), 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 (3644 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 3644 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** (3644 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 3644 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 ≈34 % 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
≈14 % — 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 (840× 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.
+9
View File
@@ -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})
+130
View File
@@ -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
3644 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
+551
View File
@@ -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;
}
}
}
}
+254
View File
@@ -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";
}
}
+164
View File
@@ -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 (0100), 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;
}
+1
View File
@@ -0,0 +1 @@
0.1.0
Generated
+34
View File
@@ -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]]
+2
View File
@@ -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`.
+29
View File
@@ -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>
+128
View File
@@ -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);
}
}
+33
View File
@@ -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(())
}
+373
View File
@@ -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()
);
}
}
}
+466
View File
@@ -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);
}
}
+49
View File
@@ -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 24).
[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
+220
View File
@@ -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 24).
## 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 3644 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 36 | 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);
+396
View File
@@ -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);
}
}
}
}
+238
View File
@@ -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 ShermanMorrison 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 ShermanMorrison:
/// 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));
}
}
+341
View File
@@ -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);
}
}
+359
View File
@@ -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 3644 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);
}
}
+161
View File
@@ -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(−‖ab‖²/(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);
}
}
+228
View File
@@ -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());
}
}
+480
View File
@@ -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);
}
}
+410
View File
@@ -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());
}
}
+374
View File
@@ -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());
}
}
+75
View File
@@ -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);
}
}
+345
View File
@@ -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);
}
}
+489
View File
@@ -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,
}
}
}
+338
View File
@@ -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);
}
+551
View File
@@ -0,0 +1,551 @@
//! RuVector self-learning layer (ADR-250 §10, items 36).
//!
//! 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 1116) — 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);
}
}
+284
View File
@@ -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());
}
}
+279
View File
@@ -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);
}
}
+361
View File
@@ -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 3644 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);
}
}
+453
View File
@@ -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 3644, 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
/// 1525 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 3644 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 1525 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 1525 Hz photosensitive zone.
assert!(e.min_hz() > 25.0);
}
#[test]
fn try_new_rejects_photosensitive_band() {
// An 1822 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);
}
+343 -1
View File
@@ -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);
+370 -6
View File
@@ -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 ≤ (n1)/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];
// 01 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]);
}
}
+12 -5
View File
@@ -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"
+6
View File
@@ -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());