diff --git a/CHANGELOG.md b/CHANGELOG.md index d0aa6031..0e9d8004 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` generalized to an adaptive sensory neuromodulation platform (ADR-250 §23).** 40 Hz is now one prior in one program, not the product. New `program` module: `NeuroProgram` catalog of 7 use cases (Alzheimer's research, post-stroke cognition, sleep optimization, attention/working-memory, mood/arousal, home wellness, drug+device trial infrastructure), each with its own `SafetyEnvelope`, starting prior, `ObjectiveWeights`, physiological-state gating (sleep permits `Asleep` + near-dark brightness cap; attention requires wakefulness), `EvidenceLevel`, and a single non-disease claim. New `acceptance` module makes the acceptance sentence executable: `AcceptanceHarness` grades a program over ≥3 repeats on entrainment gain, safety-stop rate, adherence, and optimal-frequency repeatability, exposing a `ClaimGate` that returns the program's claim **only if all four criteria pass** — the marketing claim is otherwise unreadable (`NO_CLAIM`). Governor wiring: `enroll_program` (per-program envelope/objective; `enroll` stays the bare Alzheimer's-defaults path so the pinned witness `13cb164c…` is preserved), `program()`, `prior()`, `state_eligible()`. 13 new module tests + 2 platform integration tests (per-program envelope enforced end-to-end — a stimulus valid for Alzheimer's is refused by the sleep program; acceptance gates every catalog program's claim); crate now 88 tests + 1 doctest. Bench: full 3-repeat program grading ~425 µs. - **`ruview-gamma` RuVector self-learning layer (ADR-250 §10 items 3–6).** New `ruvector` module: anonymized `ProfileStore` (one-way SHA-256 hashed tags, never `person_id`; safe-session scores only), deterministic exact kNN (fixed-range normalization, index tie-break), **cohort warm-start** — a new person's optimizer is seeded from the k nearest responders as down-weighted GP pseudo-observations (`BayesianOptimizer::observe_prior`, ≥25× real-observation noise, excluded from the EI incumbent / audit / clinician report), **physiological drift detection** (Welford centroid with stimulus-input fields masked out of the distance; `Drifted` recommends re-calibration), and deterministic k-means response clustering (farthest-point seeding, no RNG). Wired into `RufloGovernor` (`seed_from_cohort`, `export_anonymized_profile`, per-session `drift_status`). The GP gains per-observation noise (real path unchanged — pinned witness `13cb164c…` preserved). 11 new module tests + 2 integration tests (cohort warm-start beats the cold 40 Hz prior for a detuned subject; collapsed physiology flags drift); crate now 75 tests + 1 doctest. Benches: kNN over 500 profiles ~15 µs, full warm-start ~16 µs; no regression on existing paths. - **`ruview-gamma` crate (ADR-250) — Adaptive Gamma Entrainment.** Governed, deterministic, safety-constrained personalization of 40 Hz-prior light+sound stimulation, treating 40 Hz as the evidence-based *starting prior* and learning each person's safe entrainment response curve. Eleven modules: `stimulus` (params + `SafetyEnvelope` validate/clamp), `safety` (exclusion screen + latched `SafetyMonitor` with hard-stop reasons), `response` (`RuViewState`, optional `EegMeasurement`, 20-field `PersonResponseVector` with sticky adverse flag), `objective` (safe-entrainment score; safety is a hard gate, not a weight), `simulator` (deterministic ChaCha20 `frequency_response_curve`), `optimizer` (Phase-1 calibration sweep + Phase-2 GP/Expected-Improvement + Phase-4 closed-loop control), `bandit` (Phase-3 LinUCB over envelope-safe arms), `session` (reproducible SHA-256 `session_hash`), `ruflo` (consent→exclusion→envelope→run→monitor→score→update→witnessed audit, trial/sham mode, clinician export, claim discipline), `proof` (deterministic bundle witness), `math` (dependency-light numerics). **Safety invariant** (asserted in tests): no recommendation, calibration step, bandit arm, or closed-loop nudge can ever emit a stimulus outside the `SafetyEnvelope`; non-finite inputs clamp to the conservative floor. **Claim discipline**: the only product claim is `PRODUCT_CLAIM` = "personalized entrainment optimization" — never Alzheimer's treatment (ADR-250 §19). Standalone leaf crate (no internal RuView deps), `publish = false` pending safety sign-off. 64 unit/integration tests + 1 doctest pass; deterministic witness pinned (`13cb164c…`); criterion benches (safety-stop tick ~9.3 ns vs the ADR §17 500 ms bound, Bayesian recommend ~105 µs, full 9-session governed sweep ~486 µs). See [ADR-250](docs/adr/ADR-250-adaptive-gamma-entrainment.md). - **RuView beyond-SOTA research series** (`docs/research/ruview-beyond-sota/`, 6 docs) — research-swarm output defining the beyond-SOTA bar and the path to it: system capability audit (role→crate maturity matrix, gap analysis, risk register), web-verified 2026 SOTA landscape per capability axis (incl. ratified IEEE 802.11bf-2025), 8-pillar target architecture on the ADR-136 contract spine (no rewrite), 6-layer benchmark/validation methodology (all 15 criterion bench targets inventoried; ADR-149 statistical protocol), and a determinism-safe optimization roadmap. Includes session validation evidence: 2,797 workspace tests / 0 failed, Python proof PASS (bit-exact), paired pre/post criterion runs. diff --git a/CLAUDE.md b/CLAUDE.md index f84d75a5..7eac3df9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,7 +22,7 @@ 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 Gamma Entrainment (ADR-250) — governed, deterministic, safety-constrained personalization of 40 Hz-prior light+sound stimulation (RuView sensing + RuVector response modeling + RuFlo audit). Standalone leaf, ChaCha20 + SHA-256 witness. Research platform, not a medical device. | +| `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 when passed. Standalone leaf, ChaCha20 + SHA-256 witness. Research platform, not a medical device. | ### RuvSense Modules (`signal/src/ruvsense/`) | Module | Purpose | diff --git a/docs/adr/ADR-250-adaptive-gamma-entrainment.md b/docs/adr/ADR-250-adaptive-gamma-entrainment.md index ab5dd45b..b117ecb0 100644 --- a/docs/adr/ADR-250-adaptive-gamma-entrainment.md +++ b/docs/adr/ADR-250-adaptive-gamma-entrainment.md @@ -197,6 +197,47 @@ 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 diff --git a/v2/crates/ruview-gamma/README.md b/v2/crates/ruview-gamma/README.md index 4b7af1bd..fda2783f 100644 --- a/v2/crates/ruview-gamma/README.md +++ b/v2/crates/ruview-gamma/README.md @@ -43,6 +43,8 @@ conservative floor, never the cap. | `optimizer` | §8 | Phase-1 calibration sweep, Phase-2 GP + Expected-Improvement, Phase-4 closed-loop control | | `bandit` | §8 P3 | LinUCB contextual bandit over envelope-safe arms | | `ruvector` | §10 items 3–6 | anonymized `ProfileStore` (one-way hashed tags), deterministic kNN, cohort warm-start priors (down-weighted pseudo-observations), `DriftDetector` over the physiological sub-vector, deterministic k-means clustering | +| `program` | §23 | `NeuroProgram` catalog (7 use cases) — per-program envelope, prior, objective, state-gating, evidence level, and gated claim | +| `acceptance` | §18/§23.1 | `AcceptanceHarness` + `ClaimGate` — entrainment/safety/adherence/repeatability gate; a program's claim is unreadable until all four pass | | `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`) | @@ -94,6 +96,39 @@ the optimizer, simulator, response update, or session hashing. | `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) diff --git a/v2/crates/ruview-gamma/benches/optimizer_bench.rs b/v2/crates/ruview-gamma/benches/optimizer_bench.rs index 9b8a2d58..f9ac1fb4 100644 --- a/v2/crates/ruview-gamma/benches/optimizer_bench.rs +++ b/v2/crates/ruview-gamma/benches/optimizer_bench.rs @@ -7,8 +7,10 @@ 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}; @@ -109,12 +111,23 @@ fn bench_cohort_knn(c: &mut Criterion) { }); } +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_cohort_knn, + bench_acceptance ); criterion_main!(benches); diff --git a/v2/crates/ruview-gamma/src/acceptance.rs b/v2/crates/ruview-gamma/src/acceptance.rs new file mode 100644 index 00000000..7bd9dba2 --- /dev/null +++ b/v2/crates/ruview-gamma/src/acceptance.rs @@ -0,0 +1,337 @@ +//! 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"; + +/// 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::() + / 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 = 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::() / 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}; + + 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); + } + } + } +} diff --git a/v2/crates/ruview-gamma/src/lib.rs b/v2/crates/ruview-gamma/src/lib.rs index 04c2ae8f..a50a1290 100644 --- a/v2/crates/ruview-gamma/src/lib.rs +++ b/v2/crates/ruview-gamma/src/lib.rs @@ -68,10 +68,12 @@ //! assert!(envelope.contains(&rec.stimulus)); // always inside the envelope //! ``` +pub mod acceptance; pub mod bandit; pub mod math; pub mod objective; pub mod optimizer; +pub mod program; pub mod proof; pub mod response; pub mod ruflo; @@ -165,6 +167,78 @@ mod integration_tests { 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. diff --git a/v2/crates/ruview-gamma/src/program.rs b/v2/crates/ruview-gamma/src/program.rs new file mode 100644 index 00000000..b737bf38 --- /dev/null +++ b/v2/crates/ruview-gamma/src/program.rs @@ -0,0 +1,372 @@ +//! 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 { + brightness_cap: 0.32, + volume_cap: 0.32, + max_duration_minutes: 12.0, + ..SafetyEnvelope::conservative() + }, + 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 { + brightness_cap: 0.10, // near-dark + volume_cap: 0.25, + max_duration_minutes: 30.0, + ..SafetyEnvelope::conservative() + }, + 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 { + brightness_cap: 0.30, + volume_cap: 0.30, + ..SafetyEnvelope::conservative() + }, + 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 { + brightness_cap: 0.28, + volume_cap: 0.28, + max_duration_minutes: 10.0, + ..SafetyEnvelope::conservative() + }, + 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 { + 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()); + } +} diff --git a/v2/crates/ruview-gamma/src/ruflo.rs b/v2/crates/ruview-gamma/src/ruflo.rs index daa4d887..25de9b86 100644 --- a/v2/crates/ruview-gamma/src/ruflo.rs +++ b/v2/crates/ruview-gamma/src/ruflo.rs @@ -9,7 +9,8 @@ use crate::objective::{SafeEntrainmentObjective, ScoreInputs}; use crate::optimizer::{BayesianOptimizer, CalibrationPlan, Recommendation}; -use crate::response::{PersonResponseVector, RuViewState, SessionObservation, SubjectiveReport}; +use crate::program::NeuroProgram; +use crate::response::{PersonResponseVector, RuViewState, SessionObservation, SleepState, SubjectiveReport}; use crate::ruvector::{AnonymizedProfile, DriftDetector, DriftStatus, ProfileStore}; use crate::safety::{ ExclusionCondition, ExclusionScreen, SafetyMonitor, SafetyTick, ScreenOutcome, StopReason, @@ -80,6 +81,10 @@ pub struct RufloGovernor { // ADR-250 §10 item 4: per-person drift detection over the response vector. drift: DriftDetector, drift_status: DriftStatus, + // 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, } impl RufloGovernor { @@ -115,9 +120,51 @@ impl RufloGovernor { next_index: 0, drift: DriftDetector::default(), drift_status: DriftStatus::Warmup, + program: None, }) } + /// 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, + program: NeuroProgram, + conditions: &[ExclusionCondition], + consent: Consent, + ) -> Result { + 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 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