diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c95ab21..347c0a84 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` 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 diff --git a/CLAUDE.md b/CLAUDE.md index f6d142ae..f84d75a5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,6 +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. | ### 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 new file mode 100644 index 00000000..78cac6f3 --- /dev/null +++ b/docs/adr/ADR-250-adaptive-gamma-entrainment.md @@ -0,0 +1,207 @@ +# ADR-250: Adaptive Gamma Entrainment Using RuVector and RuView + +| Field | Value | +|-------|-------| +| **Status** | Proposed | +| **Date** | 2026-06-09 | +| **Owner** | RuView, RuVector, RuFlo clinical systems | +| **Decision type** | Architecture, safety, research platform | +| **Scope** | Personalized noninvasive sensory stimulation and passive state sensing | +| **Codebase target** | `v2/crates/ruview-gamma` (this ADR's reference implementation) | + +> **Not medical advice:** This ADR defines a research and engineering +> architecture. It does **not** define an approved Alzheimer's treatment. The +> immediate product claim is **personalized entrainment optimization**, never +> Alzheimer's treatment. + +--- + +## 1. Context + +Recent research suggests that noninvasive 40 Hz multisensory stimulation using +light and sound can influence gamma neural activity and may activate glymphatic +clearance pathways associated with amyloid removal in Alzheimer's mouse models. +The strongest mechanistic support comes from a 2024 Nature paper showing that +multisensory gamma stimulation promoted cerebrospinal fluid influx, interstitial +fluid efflux, aquaporin-4 polarization, meningeal lymphatic dilation, VIP +interneuron signaling, and amyloid clearance in 5XFAD mice. Blocking glymphatic +clearance abolished the amyloid-clearing effect. + +Human evidence is promising but still early. A 2022 study in mild probable +Alzheimer's disease reported that 40 Hz sensory stimulation was feasible and +well tolerated, with exploratory signals around brain structure, connectivity, +sleep, and memory. A small 2025 long-term pilot reported daily 40 Hz +audiovisual stimulation over two years was safe and feasible and may slow +cognitive and biomarker progression, but the sample size was very small and not +definitive. + +The field mostly treats 40 Hz as a fixed protocol. That is a useful population +prior, but individual brains may differ by baseline gamma state, arousal, sleep +quality, sensory acuity, medication, age, disease stage, and comfort tolerance. +A 2025 PLOS One study re-evaluated gamma stimulation frequency across 36–44 Hz, +supporting the idea that frequency choice should be empirically measured rather +than assumed. + +## 2. Problem + +Fixed 40 Hz stimulation creates four engineering limits: + +1. It assumes the same frequency works for everyone. +2. It does not continuously verify entrainment. +3. It ignores physiological state (sleep, motion, breathing, restlessness, comfort). +4. It cannot safely optimize stimulation parameters over time. + +For clinical or wellness-grade deployment, the system must answer: *which +frequency, modality, intensity, timing, and session structure produces the +strongest safe entrainment for this person in this state?* + +## 3. Decision + +We build an **Adaptive Gamma Entrainment Architecture** where 40 Hz is the +initial prior, not the hard-coded answer. The system uses: + +1. **RuView** as the passive state-sensing layer. +2. **RuVector** as the personal response-modeling layer. +3. A **constrained optimizer** to select stimulation parameters. +4. **RuFlo** as the governed workflow, audit, safety, and protocol-execution layer. +5. **Clinical-mode separation** to prevent unsupported therapeutic claims. + +The system optimizes stimulation **only within a predefined safety envelope** +and separates entrainment optimization from disease-outcome claims. + +## 4. Architecture Overview + +``` +Person baseline → RuView passive sensing → optional EEG → stimulus session + → response extraction → RuVector personal response vector + → constrained optimizer → next best protocol → RuFlo audit + governance +``` + +| Component | Role | Output | +|-----------|------|--------| +| RuView | Passive sensing of body and environment | breathing, motion, posture, stillness, sleep state, adherence | +| EEG (optional) | Direct entrainment measurement | gamma power, phase locking, artifact score | +| Stimulus controller | Light + sound actuator | frequency, intensity, phase, duty cycle, duration | +| RuVector | Learns personal response surface | individual entrainment vector | +| Optimizer | Selects next safe stimulation setting | recommended protocol | +| RuFlo | Governance and audit | protocol record, safety log, reproducibility trail | + +## 5. Stimulus Search Space + +| Parameter | Default range | Notes | +|-----------|---------------|-------| +| Frequency | 36–44 Hz | published exploratory range | +| Starting prior | 40 Hz | strongest preclinical literature | +| Modality | audio, visual, combined | combined preferred (GENUS-style) | +| Brightness | bounded low–moderate | avoid unsafe flicker intensity | +| Volume | bounded low–moderate | comfort-constrained | +| Duty cycle | continuous, ramped, pulsed | start conservative | +| Phase | synchronized, offset | explore only after baseline | +| Duration | short calibration first | longer only after tolerance | +| Time of day | morning, evening, quiet wake | state-dependent | + +## 6. Personal Response Vector + +RuVector represents each person with a compact 20-field adaptive vector +(`baseline_gamma, baseline_alpha, alpha_gamma_ratio, gamma_power_gain, +phase_locking_value, breathing_rate, breathing_stability, motion_artifact, +posture_state, sleep_state, restlessness_score, stimulus_frequency, +brightness_level, sound_level, duty_cycle, phase_offset, session_duration, +comfort_score, adherence_score, adverse_event_flag`), updated after each +session: `R_{t+1} = update(R_t, stimulus_t, response_t, safety_t)`. + +## 7. Optimization Objective + +The optimizer maximizes **safe, stable entrainment**, not raw gamma power: + +``` +score = w1·gamma_power_gain + w2·phase_locking_gain + w3·breathing_stability + + w4·adherence + w5·comfort + − w6·motion_artifact − w7·adverse_event_risk − w8·overstimulation_penalty +``` + +Default weights: gamma 0.30, phase-locking 0.25, comfort 0.15, breathing 0.10, +adherence 0.10, motion penalty 0.05; **safety penalty is a hard constraint, not +negotiable**. + +## 8. Learning Method (staged loop) + +- **Phase 1 — Conservative calibration:** short sessions at 36–44 Hz (1 Hz steps). +- **Phase 2 — Bayesian optimization:** GP surrogate + Expected Improvement, + subject to `safety==true ∧ comfort≥threshold ∧ adverse_event_risk≤threshold`. +- **Phase 3 — Contextual bandit:** once enough sessions exist, LinUCB over state + context → stimulus action → safe-entrainment reward. +- **Phase 4 — Closed-loop control:** mid-session, bounded frequency nudges when + entrainment drops, intensity reduction on discomfort, scoring pause on motion + spikes, and hard terminate-and-lock on adverse events. + +## 9–12. RuView / RuVector / RuFlo roles & Safety + +RuView supplies non-camera passive context (breathing, motion, posture, +stillness, restlessness, sleep proxy, interference, adherence). RuVector +supplies adaptive memory (personal vector, session-to-session learning, +anonymized nearest-neighbor, drift detection, clustering, recommendation, edge +inference) and predicts **safe-entrainment / comfort / artifact / adherence +likelihood** — not Alzheimer's improvement. RuFlo governs (consent, +inclusion/exclusion, scheduling, safety-stop rules, parameter audit trail, ADR +linkage, model-version tracking, clinician export, trial-mode separation). +Every session is reproducible via +`session_hash = hash(protocol_version, model_version, device_version, +stimulus_parameters, sensor_summary, response_summary, safety_events)`. + +**Hard-stop conditions:** headache, dizziness, nausea, agitation, visual +discomfort, abnormal distress, seizure-like symptoms, user-stop request, sensor +confidence below threshold, protocol outside approved envelope. + +**Exclusion / clinical supervision:** epilepsy or seizure history, +photosensitivity, severe migraine sensitivity, severe psychiatric instability, +implanted neurological devices, significant sensory impairment affecting +protocol validity, medication changes affecting neural response. **The system +must never autonomously expand beyond the allowed safety envelope.** + +## 18. Acceptance Criteria + +| Criterion | Target | +|-----------|--------| +| Frequency control precision | ±0.1 Hz | +| Session audit completeness | 100% | +| Motion artifact detection | ≥90% valid/invalid classification | +| Adaptive protocol improvement | ≥20% entrainment gain vs fixed 40 Hz | +| Comfort | no worse than fixed 40 Hz | +| Safety stops | 100% logged | +| Repeatability | same optimal band within ±1 Hz across 3 sessions | +| Claim discipline | no disease-treatment claim in product UI | + +## 19. Non-Goals + +This ADR does **not** claim: RuView treats Alzheimer's; RuVector clears amyloid; +RF sensing measures amyloid directly; personalized frequency improves clinical +outcomes; consumer deployment is safe without screening; 40 Hz is always optimal. + +## 21. Implementation Roadmap → reference crate `ruview-gamma` + +| Milestone | Module(s) in `ruview-gamma` | Status in this ADR's impl | +|-----------|------------------------------|---------------------------| +| M1 Simulator | `simulator.rs` (deterministic ChaCha20 response surface) | **Implemented** | +| M2 Device harness (contract) | `stimulus.rs`, `safety.rs` (envelope + emergency stop) | **Interfaces + safety implemented** | +| M3 RuView integration (contract) | `response.rs` (`RuViewState`) | **State contract implemented** | +| M4 EEG validation (contract) | `response.rs` (`EegMeasurement`), `objective.rs` | **Optional input implemented** | +| M5 Adaptive optimizer | `optimizer.rs` (Phase 1+2), `bandit.rs` (Phase 3), closed-loop | **Implemented** | +| M6 Trial mode | `ruflo.rs` (consent, inclusion/exclusion, sham, audit, session hash) | **Implemented** | + +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. + +## 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. diff --git a/v2/Cargo.lock b/v2/Cargo.lock index 4ca88ca1..69a9f970 100644 --- a/v2/Cargo.lock +++ b/v2/Cargo.lock @@ -7456,6 +7456,20 @@ 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-swarm" version = "0.1.0" diff --git a/v2/Cargo.toml b/v2/Cargo.toml index 5e70aca1..43289531 100644 --- a/v2/Cargo.toml +++ b/v2/Cargo.toml @@ -71,6 +71,7 @@ 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) ] # ADR-040: WASM edge crate targets wasm32-unknown-unknown (no_std), # excluded from workspace to avoid breaking `cargo test --workspace`. diff --git a/v2/crates/ruview-gamma/Cargo.toml b/v2/crates/ruview-gamma/Cargo.toml new file mode 100644 index 00000000..fe414eb2 --- /dev/null +++ b/v2/crates/ruview-gamma/Cargo.toml @@ -0,0 +1,49 @@ +[package] +name = "ruview-gamma" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +description = "Adaptive Gamma Entrainment (ADR-250) — governed, deterministic, safety-constrained personalization of 40 Hz-prior sensory stimulation (RuView sensing + RuVector response modeling + RuFlo audit). Research platform, not a medical device." +repository.workspace = true +keywords = ["gamma", "entrainment", "neuromodulation", "bayesian-optimization", "research"] +categories = ["science", "simulation"] +readme = "README.md" +# Research platform. Publishing intentionally disabled: this crate touches a +# safety-critical, clinical-adjacent domain (ADR-250 §12). Flip to true only +# after safety + claim-discipline sign-off. +publish = false + +[lib] +# rlib for native workspace linking; cdylib so the deterministic core can be +# wrapped for a browser/edge dashboard later (same posture as nvsim). +crate-type = ["cdylib", "rlib"] + +# `ruview-gamma` is a standalone leaf crate with NO internal RuView deps — the +# governed software core (optimizer, safety envelope, RuVector update logic, +# session witness) must be testable and replayable bit-exactly before any +# hardware actuation or human exposure. Hardware/RF/EEG adapters land behind +# feature flags after the core ships (ADR-250 §21, Milestones 2–4). +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } + +# Deterministic ChaCha20 PRNG: same (person, state, stimulus, seed) yields +# byte-identical synthetic responses across runs and machines — the +# reproducibility commitment in ADR-250 §11. default-features off drops the +# getrandom OS-entropy path; all seeds are caller-supplied u64. +rand = { version = "0.8", default-features = false } +rand_chacha = { version = "0.3", default-features = false } + +# SHA-256 session witness: hash(protocol, model, device, stimulus, sensors, +# response, safety) — the RuFlo reproducibility trail (ADR-250 §11). +sha2 = { workspace = true } + +[dev-dependencies] +approx = "0.5" +criterion = { workspace = true } + +[[bench]] +name = "optimizer_bench" +harness = false diff --git a/v2/crates/ruview-gamma/README.md b/v2/crates/ruview-gamma/README.md new file mode 100644 index 00000000..0d7679aa --- /dev/null +++ b/v2/crates/ruview-gamma/README.md @@ -0,0 +1,100 @@ +# ruview-gamma — Adaptive Gamma Entrainment (ADR-250) + +Governed, deterministic, **safety-constrained** personalization of 40 Hz-prior +multisensory (light + sound) stimulation. Treats 40 Hz as the evidence-based +*starting prior*, then learns each person's safe entrainment response curve using +passive RuView sensing, optional EEG, a constrained optimizer, and auditable +RuFlo workflows. + +> **Not medical advice / not a medical device.** This crate is a research and +> engineering platform. The only claim it makes is **"personalized entrainment +> optimization"** (`ruview_gamma::PRODUCT_CLAIM`) — never Alzheimer's treatment, +> amyloid clearance, or any clinical outcome (ADR-250 §19). It performs **no +> hardware actuation**: real stimulus delivery, RF sensing, and EEG arrive +> through external adapters behind feature flags after this governed software +> core ships (ADR-250 §21, Milestones 2–4). + +## Why it exists + +The field mostly treats 40 Hz as a fixed protocol. But individual brains differ +by baseline gamma, arousal, sleep, sensory acuity, medication, age, and comfort +(the 2025 PLOS One 36–44 Hz re-evaluation). Fixed 40 Hz (1) assumes one +frequency fits all, (2) never verifies entrainment, (3) ignores physiological +state, and (4) cannot safely optimize over time. This crate closes that loop. + +## The safety invariant + +**No recommendation, calibration step, bandit arm, or closed-loop nudge can ever +emit a `StimulusParameters` outside the `SafetyEnvelope`.** Every emitting path +clamps to the envelope and is asserted against `SafetyEnvelope::contains` in +tests. The optimizer never widens the envelope — only an operator constructs a +wider one deliberately (ADR-250 §12). Non-finite (NaN/∞) inputs clamp toward the +conservative floor, never the cap. + +## Module map + +| Module | Role (ADR-250 §) | Highlights | +|--------|------------------|------------| +| `stimulus` | §5, §12 | `StimulusParameters`, `SafetyEnvelope` (validate / clamp / grids) | +| `safety` | §12 | exclusion screen, latched `SafetyMonitor`, hard-stop reasons | +| `response` | §6, §9, §10 | `RuViewState`, optional `EegMeasurement`, 20-field `PersonResponseVector` (RuVector memory) with sticky adverse flag | +| `objective` | §7 | safe-entrainment score; safety is a hard gate, not a weight; RF-only proxy when EEG absent | +| `simulator` | §21 M1 | deterministic ChaCha20 `frequency_response_curve(person, state, stimulus)` | +| `optimizer` | §8 | Phase-1 calibration sweep, Phase-2 GP + Expected-Improvement, Phase-4 closed-loop control | +| `bandit` | §8 P3 | LinUCB contextual bandit over envelope-safe arms | +| `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` | ~73 ns | LinUCB decision | +| `gamma_bayesian_recommend` | ~105 µs | GP + EI over the 0.1 Hz envelope grid | +| `gamma_calibration_sweep` | ~486 µs | full 9-session enroll → simulate → score → update → witness | + +## 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) ✅. Hardware +actuation, real RF sensing, and real EEG land behind feature-flagged adapters. diff --git a/v2/crates/ruview-gamma/benches/optimizer_bench.rs b/v2/crates/ruview-gamma/benches/optimizer_bench.rs new file mode 100644 index 00000000..3bbec2c4 --- /dev/null +++ b/v2/crates/ruview-gamma/benches/optimizer_bench.rs @@ -0,0 +1,90 @@ +//! 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::bandit::{BanditContext, ContextualBandit}; +use ruview_gamma::optimizer::BayesianOptimizer; +use ruview_gamma::response::RuViewState; +use ruview_gamma::ruflo::{Consent, RufloGovernor}; +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 = [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)))) + }); +} + +criterion_group!( + benches, + bench_calibration, + bench_recommend, + bench_safety_tick, + bench_bandit +); +criterion_main!(benches); diff --git a/v2/crates/ruview-gamma/src/bandit.rs b/v2/crates/ruview-gamma/src/bandit.rs new file mode 100644 index 00000000..88ccf195 --- /dev/null +++ b/v2/crates/ruview-gamma/src/bandit.rs @@ -0,0 +1,234 @@ +//! Phase-3 contextual bandit (ADR-250 §8). +//! +//! Once enough sessions exist, protocol selection becomes state-dependent: +//! `context = [sleep_quality, time_of_day, breathing_state, motion_state, +//! fatigue_proxy, prior_response]` → `action = stimulus setting` → +//! `reward = safe_entrainment_score`. We use **LinUCB** (disjoint linear model +//! per arm) — small, deterministic, explainable, and edge-deployable. +//! +//! Arms are a discrete set of *envelope-safe* stimulus settings supplied by the +//! caller; the bandit never invents an out-of-envelope action because it can +//! only ever return one of the arms it was given. + +use crate::math::clamp_safe; +use crate::stimulus::{SafetyEnvelope, StimulusParameters}; + +/// Context vector dimensionality (ADR-250 §8 Phase 3 context list). +pub const CONTEXT_DIM: usize = 6; + +/// The decision context, normalized to `[0,1]` per field. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct BanditContext { + pub sleep_quality: f64, + pub time_of_day: f64, + pub breathing_state: f64, + pub motion_state: f64, + pub fatigue_proxy: f64, + pub prior_response: f64, +} + +impl BanditContext { + /// Flat feature vector in the documented field order. + pub fn features(&self) -> [f64; CONTEXT_DIM] { + [ + clamp_safe(self.sleep_quality, 0.0, 1.0), + clamp_safe(self.time_of_day, 0.0, 1.0), + clamp_safe(self.breathing_state, 0.0, 1.0), + clamp_safe(self.motion_state, 0.0, 1.0), + clamp_safe(self.fatigue_proxy, 0.0, 1.0), + clamp_safe(self.prior_response, 0.0, 1.0), + ] + } +} + +/// One LinUCB arm: a fixed safe stimulus plus its online linear model. The +/// per-arm `A⁻¹` is maintained incrementally via the Sherman–Morrison update, +/// so no matrix inversion runs at decision time. +#[derive(Debug, Clone)] +struct Arm { + stimulus: StimulusParameters, + /// A⁻¹ (d×d, row-major), initialized to I. + a_inv: [f64; CONTEXT_DIM * CONTEXT_DIM], + /// b (d), initialized to 0. + b: [f64; CONTEXT_DIM], +} + +impl Arm { + fn new(stimulus: StimulusParameters) -> Self { + let mut a_inv = [0.0; CONTEXT_DIM * CONTEXT_DIM]; + for i in 0..CONTEXT_DIM { + a_inv[i * CONTEXT_DIM + i] = 1.0; + } + Self { + stimulus, + a_inv, + b: [0.0; CONTEXT_DIM], + } + } + + /// theta = A⁻¹ b + fn theta(&self) -> [f64; CONTEXT_DIM] { + mat_vec(&self.a_inv, &self.b) + } + + /// UCB score: μ + α·√(xᵀ A⁻¹ x). + fn ucb(&self, x: &[f64; CONTEXT_DIM], alpha: f64) -> f64 { + let theta = self.theta(); + let mean: f64 = theta.iter().zip(x).map(|(t, xi)| t * xi).sum(); + let ainv_x = mat_vec(&self.a_inv, x); + let var: f64 = x.iter().zip(&ainv_x).map(|(xi, v)| xi * v).sum(); + mean + alpha * var.max(0.0).sqrt() + } + + /// Online update with observed `(x, reward)` via Sherman–Morrison: + /// A ← A + x xᵀ ⇒ A⁻¹ ← A⁻¹ − (A⁻¹ x xᵀ A⁻¹)/(1 + xᵀ A⁻¹ x). + fn update(&mut self, x: &[f64; CONTEXT_DIM], reward: f64) { + let ainv_x = mat_vec(&self.a_inv, x); // A⁻¹ x (d) + let denom = 1.0 + x.iter().zip(&ainv_x).map(|(xi, v)| xi * v).sum::(); + // 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, + /// 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 { + 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 { + [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)); + } +} diff --git a/v2/crates/ruview-gamma/src/lib.rs b/v2/crates/ruview-gamma/src/lib.rs new file mode 100644 index 00000000..6362cb2c --- /dev/null +++ b/v2/crates/ruview-gamma/src/lib.rs @@ -0,0 +1,177 @@ +//! # ruview-gamma — Adaptive Gamma Entrainment (ADR-250) +//! +//! Governed, deterministic, safety-constrained personalization of 40 Hz-prior +//! multisensory stimulation. Treats 40 Hz as the evidence-based **starting +//! prior**, then learns each person's safe entrainment response curve using +//! passive [RuView] sensing, optional EEG, a constrained optimizer, and +//! auditable [RuFlo] workflows. +//! +//! > **Not medical advice / not a medical device.** This crate is a research +//! > and engineering platform. The only product claim it makes is +//! > [`ruflo::PRODUCT_CLAIM`] — *"personalized entrainment optimization"* — and +//! > never Alzheimer's treatment, amyloid clearance, or any clinical outcome +//! > (ADR-250 §19 Non-Goals). It performs no hardware actuation: real stimulus +//! > delivery, RF sensing, and EEG arrive through external adapters behind +//! > feature flags after this governed software core ships (ADR-250 §21). +//! +//! ## Design discipline +//! +//! A deterministic, dependency-light **leaf crate** (no internal RuView deps; +//! ChaCha20 PRNG; SHA-256 witness) — the same posture as `nvsim`. The +//! optimizer, safety envelope, RuVector update logic, and session witness are +//! all testable and replayable bit-exactly *before any human exposure*. See +//! [`proof::Proof`] for the deterministic bundle proof. +//! +//! ## Pipeline (ADR-250 §4) +//! +//! ```text +//! enroll (consent + exclusion screen) ── ruflo +//! → calibration sweep 36–44 Hz ── optimizer::CalibrationPlan +//! → simulated/observed response ── simulator (M1) / external (M2-4) +//! → safety monitor (hard stop, latched) ── safety +//! → safe-entrainment score ── objective +//! → personal response vector update ── response (RuVector) +//! → Bayesian / bandit recommendation ── optimizer / bandit +//! → witnessed audit record ── session + ruflo +//! ``` +//! +//! ## The safety invariant +//! +//! **No recommendation, calibration step, bandit arm, or closed-loop nudge can +//! ever emit a [`stimulus::StimulusParameters`] outside the +//! [`stimulus::SafetyEnvelope`].** Every emitting path clamps to the envelope +//! and is asserted against [`stimulus::SafetyEnvelope::contains`] in tests. The +//! optimizer never widens the envelope — only an operator constructs a wider +//! one deliberately (ADR-250 §12). +//! +//! ## Quick start +//! +//! ``` +//! use ruview_gamma::{ +//! ruflo::{Consent, RufloGovernor}, +//! response::RuViewState, +//! simulator::{LatentPerson, ResponseSimulator}, +//! stimulus::{SafetyEnvelope, StimulusParameters}, +//! }; +//! +//! let envelope = SafetyEnvelope::conservative(); +//! let mut gov = RufloGovernor::enroll("subject-001", envelope, &[], Consent::Granted) +//! .expect("cleared to participate"); +//! +//! // Milestone 1: drive the governed loop with the deterministic simulator. +//! let sim = ResponseSimulator::new(42); +//! let latent = LatentPerson::from_id("subject-001"); +//! let state = RuViewState::calm_baseline(); +//! gov.run_calibration(&sim, &latent, &state, 5.0, 0).unwrap(); +//! +//! let rec = gov.recommend(&StimulusParameters::prior()); +//! assert!(envelope.contains(&rec.stimulus)); // always inside the envelope +//! ``` + +pub mod bandit; +pub mod math; +pub mod objective; +pub mod optimizer; +pub mod proof; +pub mod response; +pub mod ruflo; +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::simulator::{LatentPerson, ResponseSimulator}; + use crate::stimulus::{SafetyEnvelope, StimulusParameters}; + + /// 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::() + / 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); + } +} diff --git a/v2/crates/ruview-gamma/src/math.rs b/v2/crates/ruview-gamma/src/math.rs new file mode 100644 index 00000000..0ac78266 --- /dev/null +++ b/v2/crates/ruview-gamma/src/math.rs @@ -0,0 +1,161 @@ +//! Small, dependency-light deterministic numerics shared across modules. +//! +//! All functions are pure `f64` and deterministic — no global state, no +//! randomness. Kept intentionally tiny so the Gaussian-process surrogate +//! (`optimizer.rs`) and the LinUCB bandit (`bandit.rs`) do not pull in a +//! linear-algebra crate that could perturb the witness with its own float +//! reassociation choices. + +/// Standard-normal PDF φ(z). +#[inline] +pub fn normal_pdf(z: f64) -> f64 { + const INV_SQRT_2PI: f64 = 0.398_942_280_401_432_7; + INV_SQRT_2PI * (-0.5 * z * z).exp() +} + +/// Error function via Abramowitz & Stegun 7.1.26 (max abs error ≈ 1.5e-7). +/// +/// Deterministic and branch-stable, so the witness is portable across targets. +#[inline] +pub fn erf(x: f64) -> f64 { + let sign = if x < 0.0 { -1.0 } else { 1.0 }; + let x = x.abs(); + let t = 1.0 / (1.0 + 0.327_591_1 * x); + let y = 1.0 + - (((((1.061_405_429 * t - 1.453_152_027) * t) + 1.421_413_741) * t - 0.284_496_736) * t + + 0.254_829_592) + * t + * (-x * x).exp(); + sign * y +} + +/// Standard-normal CDF Φ(z). +#[inline] +pub fn normal_cdf(z: f64) -> f64 { + 0.5 * (1.0 + erf(z / std::f64::consts::SQRT_2)) +} + +/// Dot product. Panics if lengths differ (caller invariant). +#[inline] +pub fn dot(a: &[f64], b: &[f64]) -> f64 { + debug_assert_eq!(a.len(), b.len()); + a.iter().zip(b).map(|(x, y)| x * y).sum() +} + +/// Squared-exponential (RBF) kernel k(a,b) = σ_f² · exp(−‖a−b‖²/(2ℓ²)). +#[inline] +pub fn rbf_kernel(a: &[f64], b: &[f64], length_scale: f64, signal_var: f64) -> f64 { + let d2: f64 = a.iter().zip(b).map(|(x, y)| (x - y) * (x - y)).sum(); + signal_var * (-0.5 * d2 / (length_scale * length_scale)).exp() +} + +/// Cholesky factor L (lower-triangular) of a symmetric positive-definite +/// matrix `a` stored row-major `n×n`. Returns `None` if not SPD (a non-positive +/// pivot), which the caller treats as "fall back to the prior mean". +pub fn cholesky(a: &[f64], n: usize) -> Option> { + 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 { + 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 { + 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); + } +} diff --git a/v2/crates/ruview-gamma/src/objective.rs b/v2/crates/ruview-gamma/src/objective.rs new file mode 100644 index 00000000..e7c30fd0 --- /dev/null +++ b/v2/crates/ruview-gamma/src/objective.rs @@ -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()); + } +} diff --git a/v2/crates/ruview-gamma/src/optimizer.rs b/v2/crates/ruview-gamma/src/optimizer.rs new file mode 100644 index 00000000..3be50618 --- /dev/null +++ b/v2/crates/ruview-gamma/src/optimizer.rs @@ -0,0 +1,372 @@ +//! 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, + 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 { + 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). +#[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, + obs_y: Vec, +} + +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(), + } + } +} + +impl BayesianOptimizer { + /// Record a calibration/optimization result. + pub fn observe(&mut self, frequency_hz: f64, score: f64) { + self.obs_x.push(frequency_hz); + self.obs_y.push(score); + } + + /// Number of observations so far. + pub fn n_obs(&self) -> usize { + self.obs_x.len() + } + + /// Best observed score, or `None` if no observations. + pub fn best(&self) -> Option<(f64, f64)> { + let mut best: Option<(f64, f64)> = None; + for (&x, &y) in self.obs_x.iter().zip(&self.obs_y) { + if best.map(|(_, by)| y > by).unwrap_or(true) { + best = Some((x, y)); + } + } + best + } + + /// 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) { + let n = self.obs_x.len(); + if n == 0 { + return (0.0, self.signal_var); + } + // K = RBF(X,X) + noise·I + let mut k = vec![0.0f64; n * n]; + for i in 0..n { + for j in 0..n { + let mut v = rbf_kernel( + &[self.obs_x[i]], + &[self.obs_x[j]], + self.length_scale, + self.signal_var, + ); + if i == j { + v += self.noise_var; + } + k[i * n + j] = v; + } + } + let l = match cholesky(&k, n) { + Some(l) => l, + None => return (0.0, self.signal_var), + }; + // k* = RBF(X, x) + let kstar: Vec = (0..n) + .map(|i| rbf_kernel(&[self.obs_x[i]], &[x], self.length_scale, self.signal_var)) + .collect(); + // alpha = K^-1 y (solve L Lᵀ alpha = y) + let y1 = forward_subst(&l, &self.obs_y, n); + let alpha = back_subst_transpose(&l, &y1, n); + let mean = dot(&kstar, &alpha); + // var = k(x,x) - v·v, where L v = k* + let v = forward_subst(&l, &kstar, n); + let var = (self.signal_var - dot(&v, &v)).max(0.0); + (mean, var) + } + + /// Expected Improvement (for maximization) at `x`. + pub fn expected_improvement(&self, x: f64) -> f64 { + let best = match self.best() { + Some((_, by)) => by, + None => return self.signal_var.sqrt(), // pure exploration + }; + let (mu, var) = self.predict(x); + let sigma = var.sqrt(); + if sigma <= 1e-12 { + return 0.0; + } + let imp = mu - best - self.xi; + let z = imp / sigma; + imp * normal_cdf(z) + sigma * normal_pdf(z) + } + + /// 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. + pub fn recommend( + &self, + envelope: &SafetyEnvelope, + base: &StimulusParameters, + ) -> Recommendation { + if self.obs_x.is_empty() { + let s = envelope.clamp(*base); + return Recommendation { + stimulus: s, + expected_improvement: 0.0, + predicted_score: 0.0, + confidence: 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 = self.expected_improvement(f); + if ei > best_ei { + best_ei = ei; + best_f = f; + } + } + let (mu, var) = self.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 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 { + 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); + } +} diff --git a/v2/crates/ruview-gamma/src/proof.rs b/v2/crates/ruview-gamma/src/proof.rs new file mode 100644 index 00000000..4d7b52d0 --- /dev/null +++ b/v2/crates/ruview-gamma/src/proof.rs @@ -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::ruflo::{Consent, RufloGovernor}; +use crate::response::RuViewState; +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); + } +} diff --git a/v2/crates/ruview-gamma/src/response.rs b/v2/crates/ruview-gamma/src/response.rs new file mode 100644 index 00000000..061d01db --- /dev/null +++ b/v2/crates/ruview-gamma/src/response.rs @@ -0,0 +1,346 @@ +//! 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, + 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); + } +} diff --git a/v2/crates/ruview-gamma/src/ruflo.rs b/v2/crates/ruview-gamma/src/ruflo.rs new file mode 100644 index 00000000..978c2eb9 --- /dev/null +++ b/v2/crates/ruview-gamma/src/ruflo.rs @@ -0,0 +1,415 @@ +//! 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::response::{PersonResponseVector, RuViewState, SessionObservation, SubjectiveReport}; +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), + #[error("clinical supervision required for: {0:?}")] + SupervisionRequired(Vec), + #[error("consent not granted (or withdrawn)")] + NoConsent, + #[error("requested stimulus is outside the approved safety envelope")] + OutsideEnvelope, +} + +/// 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, + pub mean_entrainment: f64, + pub adverse_event_recorded: bool, + pub claim: &'static str, +} + +/// 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, + next_index: u64, +} + +impl RufloGovernor { + /// Enroll a participant. Fails closed on exclusion or missing consent. + pub fn enroll( + person_id: impl Into, + envelope: SafetyEnvelope, + conditions: &[ExclusionCondition], + consent: Consent, + ) -> Result { + 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, + }) + } + + /// 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) { + self.run_session(sim, latent, state, &stim, base_timestamp_ms)?; + } + 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 { + if self.consent != Consent::Granted { + return Err(GovernanceError::NoConsent); + } + if !self.envelope.contains(stimulus) { + return Err(GovernanceError::OutsideEnvelope); + } + + let idx = self.next_index; + self.next_index += 1; + + // --- Observe (simulated) response. --- + 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; + } + + // --- Safety monitor over the (single-summary) tick. --- + let mut monitor = SafetyMonitor::new(self.confidence_floor); + let mut safety_events = Vec::new(); + let adverse = if resp.adverse_event { + Some(crate::safety::AdverseEvent::AbnormalDistress) + } else { + None + }; + if let Some(stop) = monitor.evaluate(SafetyTick { + adverse, + sensor_confidence: resp.ruview.sensor_confidence, + stimulus_in_envelope: true, + }) { + safety_events.push(stop); + } + let safety_pass = !safety_events.iter().any(StopReason::is_safety_stop); + + // --- 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 resp.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. --- + self.response.update(&SessionObservation { + stimulus: *stimulus, + ruview: resp.ruview, + eeg: Some(resp.eeg), + subjective, + safety_pass, + adverse_event: resp.adverse_event, + }); + + // --- 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, + *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) + } + + /// 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::() + / 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, + claim: PRODUCT_CLAIM, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + 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")); + } +} diff --git a/v2/crates/ruview-gamma/src/safety.rs b/v2/crates/ruview-gamma/src/safety.rs new file mode 100644 index 00000000..61fe46e9 --- /dev/null +++ b/v2/crates/ruview-gamma/src/safety.rs @@ -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), + /// Hard-excluded (records the conditions). + Excluded(Vec), +} + +/// 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 = 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, + /// 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, +} + +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 { + 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()); + } +} diff --git a/v2/crates/ruview-gamma/src/session.rs b/v2/crates/ruview-gamma/src/session.rs new file mode 100644 index 00000000..b0e566b7 --- /dev/null +++ b/v2/crates/ruview-gamma/src/session.rs @@ -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, + pub subjective: SubjectiveReport, + pub outcome: Outcome, + /// Every safety event raised during the session (ADR-250 §18: 100% logged). + pub safety_events: Vec, + /// 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, + subjective: SubjectiveReport, + outcome: Outcome, + safety_events: Vec, +} + +impl SessionBuilder { + #[allow(clippy::too_many_arguments)] + pub fn new( + person_id: impl Into, + 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) -> 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 { + 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); + } +} diff --git a/v2/crates/ruview-gamma/src/simulator.rs b/v2/crates/ruview-gamma/src/simulator.rs new file mode 100644 index 00000000..72b2afd3 --- /dev/null +++ b/v2/crates/ruview-gamma/src/simulator.rs @@ -0,0 +1,274 @@ +//! 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::stimulus::StimulusParameters; + +/// A person's hidden response physiology. Real people are not 40 Hz-identical; +/// each has a latent best frequency the optimizer must *discover* (ADR-250 §1, +/// the 2025 PLOS One 36–44 Hz finding). +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct LatentPerson { + /// The individual's true peak entrainment frequency in Hz. + pub peak_hz: f64, + /// Tuning-curve width in Hz (smaller = sharper, harder to find). + pub width_hz: f64, + /// Maximum achievable gamma gain at the peak `[0,1]`. + pub max_gain: f64, + /// How quickly comfort falls as intensity rises `[0,1]`. + pub intensity_sensitivity: f64, + /// Intrinsic measurement noise level `[0,1]`. + pub noise: f64, +} + +impl LatentPerson { + /// Derive a stable latent person from a pseudonymous id. Deterministic: the + /// same id always yields the same physiology, so multi-session studies are + /// reproducible. + pub fn from_id(person_id: &str) -> Self { + let h = stable_hash(&[b"latent-person", person_id.as_bytes()]); + // Map hash bytes to parameter ranges. + let u = |i: usize| (h[i] as f64) / 255.0; + Self { + peak_hz: 37.0 + u(0) * 6.0, // 37..43 Hz + width_hz: 1.5 + u(1) * 2.5, // 1.5..4.0 Hz + max_gain: 0.45 + u(2) * 0.45, // 0.45..0.90 + intensity_sensitivity: 0.2 + u(3) * 0.6, // 0.2..0.8 + noise: 0.02 + u(4) * 0.08, // 0.02..0.10 + } + } +} + +/// One simulated session outcome. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct SimulatedResponse { + /// Synthetic EEG entrainment measurement. + pub eeg: EegMeasurement, + /// Updated RuView state for this session (motion/comfort feedback). + pub ruview: RuViewState, + /// Subjective comfort `[0,1]`. + pub comfort: f64, + /// Whether an adverse event occurred (rises with overstimulation). + pub adverse_event: bool, +} + +/// Deterministic response simulator. +#[derive(Debug, Clone)] +pub struct ResponseSimulator { + seed: u64, +} + +impl ResponseSimulator { + pub fn new(seed: u64) -> Self { + Self { seed } + } + + /// 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::() - 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::() < (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); + } +} diff --git a/v2/crates/ruview-gamma/src/stimulus.rs b/v2/crates/ruview-gamma/src/stimulus.rs new file mode 100644 index 00000000..f0bbfcf5 --- /dev/null +++ b/v2/crates/ruview-gamma/src/stimulus.rs @@ -0,0 +1,284 @@ +//! Stimulus parameters and the safety envelope (ADR-250 §5, §12). +//! +//! The [`SafetyEnvelope`] is the load-bearing safety primitive of the whole +//! crate: **no recommendation, calibration step, or closed-loop nudge may ever +//! produce a [`StimulusParameters`] that fails [`SafetyEnvelope::validate`].** +//! Every code path that emits a stimulus setting routes through +//! [`SafetyEnvelope::clamp`] (best-effort coercion) and is asserted against +//! [`SafetyEnvelope::contains`] in tests. + +use serde::{Deserialize, Serialize}; + +use crate::math::clamp_safe; + +/// Stimulation modality (ADR-250 §5). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Modality { + /// Sound only. + Audio, + /// Light only. + Visual, + /// Combined audio-visual — GENUS-style, the preferred protocol. + AudioVisual, +} + +impl Modality { + /// Canonical lowercase tag used in the session witness. + pub fn tag(self) -> &'static str { + match self { + Modality::Audio => "audio", + Modality::Visual => "visual", + Modality::AudioVisual => "audio_visual", + } + } +} + +/// Duty-cycle shape (ADR-250 §5). Conservative ordering: `Continuous` first. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DutyCycle { + /// Steady stimulation for the whole session. + Continuous, + /// Amplitude ramps up/down — gentlest onset. + Ramped, + /// On/off pulsing — explored only after tolerance is established. + Pulsed, +} + +impl DutyCycle { + pub fn tag(self) -> &'static str { + match self { + DutyCycle::Continuous => "continuous", + DutyCycle::Ramped => "ramped", + DutyCycle::Pulsed => "pulsed", + } + } +} + +/// A concrete stimulation setting. All intensity fields are normalized `[0,1]`. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub struct StimulusParameters { + /// Carrier/flicker frequency in Hz (search band 36–44, prior 40). + pub frequency_hz: f64, + /// Stimulation modality. + pub modality: Modality, + /// Visual brightness in `[0,1]` (capped well below unsafe flicker intensity). + pub brightness_level: f64, + /// Audio volume in `[0,1]` (comfort-bounded). + pub volume_level: f64, + /// Duty-cycle shape. + pub duty_cycle: DutyCycle, + /// Inter-modality phase offset in milliseconds. + pub phase_offset_ms: f64, + /// Session duration in minutes. + pub duration_minutes: f64, +} + +impl StimulusParameters { + /// The evidence-based starting prior: 40 Hz combined audio-visual, gentle + /// intensities, continuous, short (ADR-250 §5 "Starting prior"). + pub fn prior() -> Self { + Self { + frequency_hz: 40.0, + modality: Modality::AudioVisual, + brightness_level: 0.30, + volume_level: 0.28, + duty_cycle: DutyCycle::Continuous, + phase_offset_ms: 0.0, + duration_minutes: 10.0, + } + } +} + +/// Reasons a [`StimulusParameters`] is rejected by the envelope. Each variant +/// is logged verbatim into the RuFlo safety trail. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case", tag = "kind", content = "detail")] +pub enum EnvelopeViolation { + /// Frequency outside `[min_hz, max_hz]`. + Frequency { value: f64, min: f64, max: f64 }, + /// Brightness above the hard cap. + Brightness { value: f64, cap: f64 }, + /// Volume above the hard cap. + Volume { value: f64, cap: f64 }, + /// Duration above the per-stage maximum. + Duration { value: f64, max: f64 }, + /// A non-finite (NaN/Inf) field was supplied. + NonFinite { field: &'static str }, +} + +/// 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. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub struct SafetyEnvelope { + pub min_hz: f64, + pub max_hz: f64, + /// Hard brightness cap — flicker-exposure conservatism (ADR-250 §12, OQ 7). + pub brightness_cap: f64, + /// Hard volume cap — comfort conservatism. + pub volume_cap: f64, + /// Maximum session duration for the current stage. + pub max_duration_minutes: f64, + /// Maximum absolute inter-modality phase offset. + pub max_phase_offset_ms: f64, +} + +impl SafetyEnvelope { + /// Conservative research-grade default envelope (ADR-250 §5 default ranges, + /// "safety_profile: conservative"). + pub fn conservative() -> Self { + Self { + min_hz: 36.0, + max_hz: 44.0, + brightness_cap: 0.40, + volume_cap: 0.40, + max_duration_minutes: 15.0, + max_phase_offset_ms: 5.0, + } + } + + /// `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> { + 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 { + 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)] +mod tests { + 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); + } + + #[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); + } +}