diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e9d8004..616c76f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **`ruview-gamma` claim-gate invariant + hardware-in-the-loop contract.** Centralized the claim release rule into a single `acceptance::claim_allowed(entrainment, safety, adherence, repeatability)` (strict AND of all four) used by every path, with a test proving every 3-of-4 subset is denied — no path can weaken the gate. New `hil` module: `verify_hil` grades a captured actuator bench measurement against fixed targets (LED frequency ±0.1 Hz, audio-visual sync < 5 ms, stop-signal→actuator-off < 100 ms, session-hash reproducibility 100%, EEG entrainment lift ≥ 20% over fixed 40 Hz) — the next acceptance milestone for a real LED+speaker (e.g. ESP32) actuator; all failure modes fail closed (missing stop measurement, no replay, any hash mismatch). README gains the benchmark table and the "governed personalization engine that refuses to overpromise" positioning. 9 new tests; crate now 97 + 1 doctest; pinned witness preserved. - **`ruview-gamma` generalized to an adaptive sensory neuromodulation platform (ADR-250 §23).** 40 Hz is now one prior in one program, not the product. New `program` module: `NeuroProgram` catalog of 7 use cases (Alzheimer's research, post-stroke cognition, sleep optimization, attention/working-memory, mood/arousal, home wellness, drug+device trial infrastructure), each with its own `SafetyEnvelope`, starting prior, `ObjectiveWeights`, physiological-state gating (sleep permits `Asleep` + near-dark brightness cap; attention requires wakefulness), `EvidenceLevel`, and a single non-disease claim. New `acceptance` module makes the acceptance sentence executable: `AcceptanceHarness` grades a program over ≥3 repeats on entrainment gain, safety-stop rate, adherence, and optimal-frequency repeatability, exposing a `ClaimGate` that returns the program's claim **only if all four criteria pass** — the marketing claim is otherwise unreadable (`NO_CLAIM`). Governor wiring: `enroll_program` (per-program envelope/objective; `enroll` stays the bare Alzheimer's-defaults path so the pinned witness `13cb164c…` is preserved), `program()`, `prior()`, `state_eligible()`. 13 new module tests + 2 platform integration tests (per-program envelope enforced end-to-end — a stimulus valid for Alzheimer's is refused by the sleep program; acceptance gates every catalog program's claim); crate now 88 tests + 1 doctest. Bench: full 3-repeat program grading ~425 µs. - **`ruview-gamma` RuVector self-learning layer (ADR-250 §10 items 3–6).** New `ruvector` module: anonymized `ProfileStore` (one-way SHA-256 hashed tags, never `person_id`; safe-session scores only), deterministic exact kNN (fixed-range normalization, index tie-break), **cohort warm-start** — a new person's optimizer is seeded from the k nearest responders as down-weighted GP pseudo-observations (`BayesianOptimizer::observe_prior`, ≥25× real-observation noise, excluded from the EI incumbent / audit / clinician report), **physiological drift detection** (Welford centroid with stimulus-input fields masked out of the distance; `Drifted` recommends re-calibration), and deterministic k-means response clustering (farthest-point seeding, no RNG). Wired into `RufloGovernor` (`seed_from_cohort`, `export_anonymized_profile`, per-session `drift_status`). The GP gains per-observation noise (real path unchanged — pinned witness `13cb164c…` preserved). 11 new module tests + 2 integration tests (cohort warm-start beats the cold 40 Hz prior for a detuned subject; collapsed physiology flags drift); crate now 75 tests + 1 doctest. Benches: kNN over 500 profiles ~15 µs, full warm-start ~16 µs; no regression on existing paths. - **`ruview-gamma` crate (ADR-250) — Adaptive Gamma Entrainment.** Governed, deterministic, safety-constrained personalization of 40 Hz-prior light+sound stimulation, treating 40 Hz as the evidence-based *starting prior* and learning each person's safe entrainment response curve. Eleven modules: `stimulus` (params + `SafetyEnvelope` validate/clamp), `safety` (exclusion screen + latched `SafetyMonitor` with hard-stop reasons), `response` (`RuViewState`, optional `EegMeasurement`, 20-field `PersonResponseVector` with sticky adverse flag), `objective` (safe-entrainment score; safety is a hard gate, not a weight), `simulator` (deterministic ChaCha20 `frequency_response_curve`), `optimizer` (Phase-1 calibration sweep + Phase-2 GP/Expected-Improvement + Phase-4 closed-loop control), `bandit` (Phase-3 LinUCB over envelope-safe arms), `session` (reproducible SHA-256 `session_hash`), `ruflo` (consent→exclusion→envelope→run→monitor→score→update→witnessed audit, trial/sham mode, clinician export, claim discipline), `proof` (deterministic bundle witness), `math` (dependency-light numerics). **Safety invariant** (asserted in tests): no recommendation, calibration step, bandit arm, or closed-loop nudge can ever emit a stimulus outside the `SafetyEnvelope`; non-finite inputs clamp to the conservative floor. **Claim discipline**: the only product claim is `PRODUCT_CLAIM` = "personalized entrainment optimization" — never Alzheimer's treatment (ADR-250 §19). Standalone leaf crate (no internal RuView deps), `publish = false` pending safety sign-off. 64 unit/integration tests + 1 doctest pass; deterministic witness pinned (`13cb164c…`); criterion benches (safety-stop tick ~9.3 ns vs the ADR §17 500 ms bound, Bayesian recommend ~105 µs, full 9-session governed sweep ~486 µs). See [ADR-250](docs/adr/ADR-250-adaptive-gamma-entrainment.md). diff --git a/v2/crates/ruview-gamma/README.md b/v2/crates/ruview-gamma/README.md index fda2783f..b57084b7 100644 --- a/v2/crates/ruview-gamma/README.md +++ b/v2/crates/ruview-gamma/README.md @@ -1,4 +1,65 @@ -# ruview-gamma — Adaptive Gamma Entrainment (ADR-250) +# 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 | +| Audio-visual sync drift | < 5 ms | +| Stop signal → actuator off | < 100 ms | +| Session-hash reproducibility | 100% | +| EEG entrainment lift vs fixed 40 Hz | ≥ 20% | + +--- Governed, deterministic, **safety-constrained** personalization of 40 Hz-prior multisensory (light + sound) stimulation. Treats 40 Hz as the evidence-based @@ -44,7 +105,8 @@ conservative floor, never the cap. | `bandit` | §8 P3 | LinUCB contextual bandit over envelope-safe arms | | `ruvector` | §10 items 3–6 | anonymized `ProfileStore` (one-way hashed tags), deterministic kNN, cohort warm-start priors (down-weighted pseudo-observations), `DriftDetector` over the physiological sub-vector, deterministic k-means clustering | | `program` | §23 | `NeuroProgram` catalog (7 use cases) — per-program envelope, prior, objective, state-gating, evidence level, and gated claim | -| `acceptance` | §18/§23.1 | `AcceptanceHarness` + `ClaimGate` — entrainment/safety/adherence/repeatability gate; a program's claim is unreadable until all four pass | +| `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`) | diff --git a/v2/crates/ruview-gamma/src/acceptance.rs b/v2/crates/ruview-gamma/src/acceptance.rs index 7bd9dba2..e505e2b0 100644 --- a/v2/crates/ruview-gamma/src/acceptance.rs +++ b/v2/crates/ruview-gamma/src/acceptance.rs @@ -20,6 +20,26 @@ use crate::stimulus::StimulusParameters; /// 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)] @@ -183,7 +203,8 @@ impl AcceptanceHarness { 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 = entrainment_pass && safety_pass && adherence_pass && repeatability_pass; + let overall_pass = + claim_allowed(entrainment_pass, safety_pass, adherence_pass, repeatability_pass); AcceptanceReport { program_id: program.id.to_string(), @@ -243,6 +264,24 @@ 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. diff --git a/v2/crates/ruview-gamma/src/hil.rs b/v2/crates/ruview-gamma/src/hil.rs new file mode 100644 index 00000000..6b0230ee --- /dev/null +++ b/v2/crates/ruview-gamma/src/hil.rs @@ -0,0 +1,203 @@ +//! 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 | +//! | Audio-visual sync drift | < 5 ms | +//! | Stop signal → actuator off | < 100 ms | +//! | Session-hash reproducibility | 100% | +//! | EEG entrainment lift vs fixed 40 Hz | ≥ 20% | + +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, +} + +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, + } + } +} + +/// 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). + pub measured_frequency_hz: 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, + 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`]. +pub fn verify_hil(m: &HilMeasurement, t: &HilTargets) -> HilReport { + let frequency_error_hz = (m.measured_frequency_hz - m.commanded_frequency_hz).abs(); + let frequency_pass = frequency_error_hz <= t.max_frequency_error_hz; + let av_sync_pass = 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; + let hash_reproducibility = if m.hashes_total > 0 { + m.hashes_reproduced as f64 / m.hashes_total as f64 + } else { + 0.0 // nothing replayed ⇒ unproven ⇒ fail closed + }; + let hash_pass = hash_reproducibility >= t.min_hash_reproducibility; + let baseline = m.eeg_entrainment_fixed_40hz.max(1e-6); + let eeg_lift = (m.eeg_entrainment_adaptive - baseline) / baseline; + let eeg_lift_pass = eeg_lift >= t.min_eeg_lift; + + let overall_pass = + frequency_pass && av_sync_pass && stop_latency_pass && hash_pass && eeg_lift_pass; + + HilReport { + frequency_error_hz, + frequency_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 + 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); + } +} diff --git a/v2/crates/ruview-gamma/src/lib.rs b/v2/crates/ruview-gamma/src/lib.rs index a50a1290..62e30f43 100644 --- a/v2/crates/ruview-gamma/src/lib.rs +++ b/v2/crates/ruview-gamma/src/lib.rs @@ -70,6 +70,7 @@ pub mod acceptance; pub mod bandit; +pub mod hil; pub mod math; pub mod objective; pub mod optimizer;