feat(ruview-gamma): implement ADR-250 Adaptive Gamma Entrainment crate

Governed, deterministic, safety-constrained personalization of 40 Hz-prior
light+sound stimulation (RuView sensing + RuVector response modeling + RuFlo
audit). 40 Hz is the starting prior, not the hard-coded answer.

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

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

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

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

https://claude.ai/code/session_01MjBucx95K4BuUxZi8NWwRH
This commit is contained in:
Claude
2026-06-10 03:30:28 +00:00
parent 9161fa7156
commit 3408883f79
20 changed files with 3592 additions and 0 deletions
Generated
+14
View File
@@ -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"
+1
View File
@@ -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`.
+49
View File
@@ -0,0 +1,49 @@
[package]
name = "ruview-gamma"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
description = "Adaptive Gamma Entrainment (ADR-250) — governed, deterministic, safety-constrained personalization of 40 Hz-prior sensory stimulation (RuView sensing + RuVector response modeling + RuFlo audit). Research platform, not a medical device."
repository.workspace = true
keywords = ["gamma", "entrainment", "neuromodulation", "bayesian-optimization", "research"]
categories = ["science", "simulation"]
readme = "README.md"
# Research platform. Publishing intentionally disabled: this crate touches a
# safety-critical, clinical-adjacent domain (ADR-250 §12). Flip to true only
# after safety + claim-discipline sign-off.
publish = false
[lib]
# rlib for native workspace linking; cdylib so the deterministic core can be
# wrapped for a browser/edge dashboard later (same posture as nvsim).
crate-type = ["cdylib", "rlib"]
# `ruview-gamma` is a standalone leaf crate with NO internal RuView deps — the
# governed software core (optimizer, safety envelope, RuVector update logic,
# session witness) must be testable and replayable bit-exactly before any
# hardware actuation or human exposure. Hardware/RF/EEG adapters land behind
# feature flags after the core ships (ADR-250 §21, Milestones 24).
[dependencies]
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
# Deterministic ChaCha20 PRNG: same (person, state, stimulus, seed) yields
# byte-identical synthetic responses across runs and machines — the
# reproducibility commitment in ADR-250 §11. default-features off drops the
# getrandom OS-entropy path; all seeds are caller-supplied u64.
rand = { version = "0.8", default-features = false }
rand_chacha = { version = "0.3", default-features = false }
# SHA-256 session witness: hash(protocol, model, device, stimulus, sensors,
# response, safety) — the RuFlo reproducibility trail (ADR-250 §11).
sha2 = { workspace = true }
[dev-dependencies]
approx = "0.5"
criterion = { workspace = true }
[[bench]]
name = "optimizer_bench"
harness = false
+100
View File
@@ -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 24).
## Why it exists
The field mostly treats 40 Hz as a fixed protocol. But individual brains differ
by baseline gamma, arousal, sleep, sensory acuity, medication, age, and comfort
(the 2025 PLOS One 3644 Hz re-evaluation). Fixed 40 Hz (1) assumes one
frequency fits all, (2) never verifies entrainment, (3) ignores physiological
state, and (4) cannot safely optimize over time. This crate closes that loop.
## The safety invariant
**No recommendation, calibration step, bandit arm, or closed-loop nudge can ever
emit a `StimulusParameters` outside the `SafetyEnvelope`.** Every emitting path
clamps to the envelope and is asserted against `SafetyEnvelope::contains` in
tests. The optimizer never widens the envelope — only an operator constructs a
wider one deliberately (ADR-250 §12). Non-finite (NaN/∞) inputs clamp toward the
conservative floor, never the cap.
## Module map
| Module | Role (ADR-250 §) | Highlights |
|--------|------------------|------------|
| `stimulus` | §5, §12 | `StimulusParameters`, `SafetyEnvelope` (validate / clamp / grids) |
| `safety` | §12 | exclusion screen, latched `SafetyMonitor`, hard-stop reasons |
| `response` | §6, §9, §10 | `RuViewState`, optional `EegMeasurement`, 20-field `PersonResponseVector` (RuVector memory) with sticky adverse flag |
| `objective` | §7 | safe-entrainment score; safety is a hard gate, not a weight; RF-only proxy when EEG absent |
| `simulator` | §21 M1 | deterministic ChaCha20 `frequency_response_curve(person, state, stimulus)` |
| `optimizer` | §8 | Phase-1 calibration sweep, Phase-2 GP + Expected-Improvement, Phase-4 closed-loop control |
| `bandit` | §8 P3 | LinUCB contextual bandit over envelope-safe arms |
| `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.
@@ -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<StimulusParameters> = [38.0, 40.0, 42.0]
.iter()
.map(|&f| {
let mut s = StimulusParameters::prior();
s.frequency_hz = f;
s
})
.collect();
let bandit = ContextualBandit::new(&env, &candidates, 1.0).unwrap();
let ctx = BanditContext {
sleep_quality: 0.7,
time_of_day: 0.5,
breathing_state: 0.8,
motion_state: 0.1,
fatigue_proxy: 0.2,
prior_response: 0.6,
};
c.bench_function("gamma_bandit_select", |b| {
b.iter(|| black_box(bandit.select(black_box(&ctx))))
});
}
criterion_group!(
benches,
bench_calibration,
bench_recommend,
bench_safety_tick,
bench_bandit
);
criterion_main!(benches);
+234
View File
@@ -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 ShermanMorrison update,
/// so no matrix inversion runs at decision time.
#[derive(Debug, Clone)]
struct Arm {
stimulus: StimulusParameters,
/// A⁻¹ (d×d, row-major), initialized to I.
a_inv: [f64; CONTEXT_DIM * CONTEXT_DIM],
/// b (d), initialized to 0.
b: [f64; CONTEXT_DIM],
}
impl Arm {
fn new(stimulus: StimulusParameters) -> Self {
let mut a_inv = [0.0; CONTEXT_DIM * CONTEXT_DIM];
for i in 0..CONTEXT_DIM {
a_inv[i * CONTEXT_DIM + i] = 1.0;
}
Self {
stimulus,
a_inv,
b: [0.0; CONTEXT_DIM],
}
}
/// theta = A⁻¹ b
fn theta(&self) -> [f64; CONTEXT_DIM] {
mat_vec(&self.a_inv, &self.b)
}
/// UCB score: μ + α·√(xᵀ A⁻¹ x).
fn ucb(&self, x: &[f64; CONTEXT_DIM], alpha: f64) -> f64 {
let theta = self.theta();
let mean: f64 = theta.iter().zip(x).map(|(t, xi)| t * xi).sum();
let ainv_x = mat_vec(&self.a_inv, x);
let var: f64 = x.iter().zip(&ainv_x).map(|(xi, v)| xi * v).sum();
mean + alpha * var.max(0.0).sqrt()
}
/// Online update with observed `(x, reward)` via ShermanMorrison:
/// A ← A + x xᵀ ⇒ A⁻¹ ← A⁻¹ − (A⁻¹ x xᵀ A⁻¹)/(1 + xᵀ A⁻¹ x).
fn update(&mut self, x: &[f64; CONTEXT_DIM], reward: f64) {
let ainv_x = mat_vec(&self.a_inv, x); // A⁻¹ x (d)
let denom = 1.0 + x.iter().zip(&ainv_x).map(|(xi, v)| xi * v).sum::<f64>();
// A⁻¹ ← A⁻¹ (ainv_x)(ainv_x)ᵀ / denom (since A symmetric ⇒ xᵀA⁻¹ = (A⁻¹x)ᵀ)
for i in 0..CONTEXT_DIM {
for j in 0..CONTEXT_DIM {
self.a_inv[i * CONTEXT_DIM + j] -= ainv_x[i] * ainv_x[j] / denom;
}
}
for i in 0..CONTEXT_DIM {
self.b[i] += reward * x[i];
}
}
}
/// LinUCB contextual bandit over a fixed set of envelope-safe arms.
#[derive(Debug, Clone)]
pub struct ContextualBandit {
arms: Vec<Arm>,
/// Exploration coefficient α.
pub alpha: f64,
}
impl ContextualBandit {
/// Build a bandit from candidate stimuli. Each candidate is **clamped into
/// the envelope** on the way in, so no arm can ever be unsafe (ADR-250 §12).
/// Returns `None` if no candidates were supplied.
pub fn new(envelope: &SafetyEnvelope, candidates: &[StimulusParameters], alpha: f64) -> Option<Self> {
if candidates.is_empty() {
return None;
}
let arms = candidates
.iter()
.map(|s| Arm::new(envelope.clamp(*s)))
.collect();
Some(Self { arms, alpha })
}
/// Number of arms.
pub fn n_arms(&self) -> usize {
self.arms.len()
}
/// Select the arm index with the highest UCB for `ctx`. Deterministic
/// tie-break: lowest index wins.
pub fn select(&self, ctx: &BanditContext) -> usize {
let x = ctx.features();
let mut best_i = 0;
let mut best = f64::NEG_INFINITY;
for (i, arm) in self.arms.iter().enumerate() {
let u = arm.ucb(&x, self.alpha);
if u > best {
best = u;
best_i = i;
}
}
best_i
}
/// The stimulus for an arm index (always envelope-safe).
pub fn stimulus(&self, arm: usize) -> StimulusParameters {
self.arms[arm].stimulus
}
/// Record the reward for a chosen arm under context `ctx`.
pub fn update(&mut self, arm: usize, ctx: &BanditContext, reward: f64) {
let x = ctx.features();
self.arms[arm].update(&x, reward);
}
}
/// y = M x for row-major `d×d` M and length-`d` x.
fn mat_vec(m: &[f64; CONTEXT_DIM * CONTEXT_DIM], x: &[f64; CONTEXT_DIM]) -> [f64; CONTEXT_DIM] {
let mut y = [0.0; CONTEXT_DIM];
for i in 0..CONTEXT_DIM {
let mut s = 0.0;
for j in 0..CONTEXT_DIM {
s += m[i * CONTEXT_DIM + j] * x[j];
}
y[i] = s;
}
y
}
#[cfg(test)]
mod tests {
use super::*;
use crate::stimulus::StimulusParameters;
fn candidates() -> Vec<StimulusParameters> {
[38.0, 40.0, 42.0]
.iter()
.map(|&f| {
let mut s = StimulusParameters::prior();
s.frequency_hz = f;
s
})
.collect()
}
fn ctx(sleep: f64) -> BanditContext {
BanditContext {
sleep_quality: sleep,
time_of_day: 0.5,
breathing_state: 0.8,
motion_state: 0.1,
fatigue_proxy: 0.2,
prior_response: 0.6,
}
}
#[test]
fn rejects_empty_candidates() {
let env = SafetyEnvelope::conservative();
assert!(ContextualBandit::new(&env, &[], 1.0).is_none());
}
#[test]
fn all_arms_are_envelope_safe_even_if_candidate_is_not() {
let env = SafetyEnvelope::conservative();
let mut bad = StimulusParameters::prior();
bad.frequency_hz = 100.0;
bad.brightness_level = 9.0;
let b = ContextualBandit::new(&env, &[bad], 1.0).unwrap();
assert!(env.contains(&b.stimulus(0)));
}
#[test]
fn learns_to_prefer_rewarded_arm() {
let env = SafetyEnvelope::conservative();
let mut b = ContextualBandit::new(&env, &candidates(), 0.1).unwrap();
let c = ctx(0.9);
// Arm 2 (42 Hz) is consistently best in this context.
for _ in 0..50 {
for arm in 0..b.n_arms() {
let reward = if arm == 2 { 1.0 } else { 0.1 };
b.update(arm, &c, reward);
}
}
assert_eq!(b.select(&c), 2);
}
#[test]
fn selection_is_deterministic() {
let env = SafetyEnvelope::conservative();
let b = ContextualBandit::new(&env, &candidates(), 1.0).unwrap();
let c = ctx(0.5);
assert_eq!(b.select(&c), b.select(&c));
}
}
+177
View File
@@ -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 3644 Hz ── optimizer::CalibrationPlan
//! → simulated/observed response ── simulator (M1) / external (M2-4)
//! → safety monitor (hard stop, latched) ── safety
//! → safe-entrainment score ── objective
//! → personal response vector update ── response (RuVector)
//! → Bayesian / bandit recommendation ── optimizer / bandit
//! → witnessed audit record ── session + ruflo
//! ```
//!
//! ## The safety invariant
//!
//! **No recommendation, calibration step, bandit arm, or closed-loop nudge can
//! ever emit a [`stimulus::StimulusParameters`] outside the
//! [`stimulus::SafetyEnvelope`].** Every emitting path clamps to the envelope
//! and is asserted against [`stimulus::SafetyEnvelope::contains`] in tests. The
//! optimizer never widens the envelope — only an operator constructs a wider
//! one deliberately (ADR-250 §12).
//!
//! ## Quick start
//!
//! ```
//! use ruview_gamma::{
//! ruflo::{Consent, RufloGovernor},
//! response::RuViewState,
//! simulator::{LatentPerson, ResponseSimulator},
//! stimulus::{SafetyEnvelope, StimulusParameters},
//! };
//!
//! let envelope = SafetyEnvelope::conservative();
//! let mut gov = RufloGovernor::enroll("subject-001", envelope, &[], Consent::Granted)
//! .expect("cleared to participate");
//!
//! // Milestone 1: drive the governed loop with the deterministic simulator.
//! let sim = ResponseSimulator::new(42);
//! let latent = LatentPerson::from_id("subject-001");
//! let state = RuViewState::calm_baseline();
//! gov.run_calibration(&sim, &latent, &state, 5.0, 0).unwrap();
//!
//! let rec = gov.recommend(&StimulusParameters::prior());
//! assert!(envelope.contains(&rec.stimulus)); // always inside the envelope
//! ```
pub mod 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::<f64>()
/ 20.0
};
assert!(env.contains(&rec.stimulus));
assert!(mean(&rec.stimulus) > mean(&fixed));
}
/// End-to-end: a blinded sham arm yields lower mean entrainment than the
/// open arm across a small cohort — the controlled-trial primitive.
#[test]
fn sham_arm_shows_no_entrainment_across_cohort() {
let env = SafetyEnvelope::conservative();
let sim = ResponseSimulator::new(7);
let state = RuViewState::calm_baseline();
let mut open_sum = 0.0;
let mut sham_sum = 0.0;
for n in 0..8 {
let id = format!("cohort-{n}");
let latent = LatentPerson::from_id(&id);
let mut stim = StimulusParameters::prior();
stim.frequency_hz = (latent.peak_hz * 10.0).round() / 10.0;
stim.frequency_hz = stim.frequency_hz.clamp(36.0, 44.0);
let mut open = RufloGovernor::enroll(&id, env, &[], Consent::Granted).unwrap();
let o = open.run_session(&sim, &latent, &state, &stim, 0).unwrap();
open_sum += o.outcome.entrainment_score;
let mut sham = RufloGovernor::enroll(&id, env, &[], Consent::Granted).unwrap();
sham.set_mode(TrialMode::Sham);
let s = sham.run_session(&sim, &latent, &state, &stim, 0).unwrap();
sham_sum += s.outcome.entrainment_score;
}
assert!(open_sum > sham_sum);
}
}
+161
View File
@@ -0,0 +1,161 @@
//! Small, dependency-light deterministic numerics shared across modules.
//!
//! All functions are pure `f64` and deterministic — no global state, no
//! randomness. Kept intentionally tiny so the Gaussian-process surrogate
//! (`optimizer.rs`) and the LinUCB bandit (`bandit.rs`) do not pull in a
//! linear-algebra crate that could perturb the witness with its own float
//! reassociation choices.
/// Standard-normal PDF φ(z).
#[inline]
pub fn normal_pdf(z: f64) -> f64 {
const INV_SQRT_2PI: f64 = 0.398_942_280_401_432_7;
INV_SQRT_2PI * (-0.5 * z * z).exp()
}
/// Error function via Abramowitz & Stegun 7.1.26 (max abs error ≈ 1.5e-7).
///
/// Deterministic and branch-stable, so the witness is portable across targets.
#[inline]
pub fn erf(x: f64) -> f64 {
let sign = if x < 0.0 { -1.0 } else { 1.0 };
let x = x.abs();
let t = 1.0 / (1.0 + 0.327_591_1 * x);
let y = 1.0
- (((((1.061_405_429 * t - 1.453_152_027) * t) + 1.421_413_741) * t - 0.284_496_736) * t
+ 0.254_829_592)
* t
* (-x * x).exp();
sign * y
}
/// Standard-normal CDF Φ(z).
#[inline]
pub fn normal_cdf(z: f64) -> f64 {
0.5 * (1.0 + erf(z / std::f64::consts::SQRT_2))
}
/// Dot product. Panics if lengths differ (caller invariant).
#[inline]
pub fn dot(a: &[f64], b: &[f64]) -> f64 {
debug_assert_eq!(a.len(), b.len());
a.iter().zip(b).map(|(x, y)| x * y).sum()
}
/// Squared-exponential (RBF) kernel k(a,b) = σ_f² · exp(−‖ab‖²/(2ℓ²)).
#[inline]
pub fn rbf_kernel(a: &[f64], b: &[f64], length_scale: f64, signal_var: f64) -> f64 {
let d2: f64 = a.iter().zip(b).map(|(x, y)| (x - y) * (x - y)).sum();
signal_var * (-0.5 * d2 / (length_scale * length_scale)).exp()
}
/// Cholesky factor L (lower-triangular) of a symmetric positive-definite
/// matrix `a` stored row-major `n×n`. Returns `None` if not SPD (a non-positive
/// pivot), which the caller treats as "fall back to the prior mean".
pub fn cholesky(a: &[f64], n: usize) -> Option<Vec<f64>> {
let mut l = vec![0.0f64; n * n];
for i in 0..n {
for j in 0..=i {
let mut sum = a[i * n + j];
for k in 0..j {
sum -= l[i * n + k] * l[j * n + k];
}
if i == j {
if sum <= 0.0 {
return None;
}
l[i * n + j] = sum.sqrt();
} else {
l[i * n + j] = sum / l[j * n + j];
}
}
}
Some(l)
}
/// Solve `L y = b` (forward substitution) for lower-triangular `L` (`n×n`).
pub fn forward_subst(l: &[f64], b: &[f64], n: usize) -> Vec<f64> {
let mut y = vec![0.0f64; n];
for i in 0..n {
let mut sum = b[i];
for k in 0..i {
sum -= l[i * n + k] * y[k];
}
y[i] = sum / l[i * n + i];
}
y
}
/// Solve `Lᵀ x = y` (back substitution) for lower-triangular `L` (`n×n`).
pub fn back_subst_transpose(l: &[f64], y: &[f64], n: usize) -> Vec<f64> {
let mut x = vec![0.0f64; n];
for i in (0..n).rev() {
let mut sum = y[i];
for k in (i + 1)..n {
sum -= l[k * n + i] * x[k];
}
x[i] = sum / l[i * n + i];
}
x
}
/// Clamp helper that is explicit about non-finite handling: any non-finite
/// input (NaN **or** ±∞) maps to `lo`, so a degenerate value can never escape
/// the safety envelope upward toward `hi`.
#[inline]
pub fn clamp_safe(v: f64, lo: f64, hi: f64) -> f64 {
if !v.is_finite() {
lo
} else {
v.max(lo).min(hi)
}
}
#[cfg(test)]
mod tests {
use super::*;
use approx::assert_abs_diff_eq;
#[test]
fn erf_known_values() {
assert_abs_diff_eq!(erf(0.0), 0.0, epsilon = 1e-9);
assert_abs_diff_eq!(erf(1.0), 0.842_700_79, epsilon = 1e-6);
assert_abs_diff_eq!(erf(-1.0), -0.842_700_79, epsilon = 1e-6);
}
#[test]
fn normal_cdf_symmetry() {
assert_abs_diff_eq!(normal_cdf(0.0), 0.5, epsilon = 1e-9);
assert_abs_diff_eq!(normal_cdf(1.96), 0.975, epsilon = 1e-3);
assert_abs_diff_eq!(normal_cdf(-1.96), 0.025, epsilon = 1e-3);
}
#[test]
fn cholesky_solves_spd_system() {
// A = [[4,2],[2,3]], b=[1,1] -> x = A^-1 b
let a = vec![4.0, 2.0, 2.0, 3.0];
let l = cholesky(&a, 2).expect("SPD");
let b = vec![1.0, 1.0];
let y = forward_subst(&l, &b, 2);
let x = back_subst_transpose(&l, &y, 2);
// Verify A x = b
assert_abs_diff_eq!(4.0 * x[0] + 2.0 * x[1], 1.0, epsilon = 1e-9);
assert_abs_diff_eq!(2.0 * x[0] + 3.0 * x[1], 1.0, epsilon = 1e-9);
}
#[test]
fn cholesky_rejects_non_spd() {
// Indefinite matrix
let a = vec![1.0, 2.0, 2.0, 1.0];
assert!(cholesky(&a, 2).is_none());
}
#[test]
fn clamp_safe_maps_non_finite_to_low() {
assert_eq!(clamp_safe(f64::NAN, 0.1, 0.9), 0.1);
assert_eq!(clamp_safe(f64::INFINITY, 0.1, 0.9), 0.1);
assert_eq!(clamp_safe(f64::NEG_INFINITY, 0.1, 0.9), 0.1);
assert_eq!(clamp_safe(1.5, 0.1, 0.9), 0.9);
assert_eq!(clamp_safe(-1.0, 0.1, 0.9), 0.1);
}
}
+228
View File
@@ -0,0 +1,228 @@
//! The safe-entrainment objective (ADR-250 §7).
//!
//! `score = w1·gamma + w2·phase_locking + w3·breathing_stability + w4·adherence
//! + w5·comfort w6·motion_artifact w7·adverse_risk w8·overstim`
//!
//! Safety is **not** a soft term here: it is a hard gate applied *before*
//! scoring (`SafetyEnvelope`, `SafetyMonitor`). The `adverse_event_risk` and
//! `overstimulation_penalty` terms only shape preference *within* the already
//! safe region.
use serde::{Deserialize, Serialize};
use crate::response::{EegMeasurement, RuViewState, SubjectiveReport};
use crate::stimulus::{SafetyEnvelope, StimulusParameters};
/// Objective weights (ADR-250 §7 default weighting).
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct ObjectiveWeights {
pub gamma_gain: f64,
pub phase_locking: f64,
pub breathing_stability: f64,
pub adherence: f64,
pub comfort: f64,
pub motion_artifact: f64,
pub adverse_event_risk: f64,
pub overstimulation: f64,
}
impl Default for ObjectiveWeights {
/// ADR-250 §7 default weighting table.
fn default() -> Self {
Self {
gamma_gain: 0.30,
phase_locking: 0.25,
breathing_stability: 0.10,
adherence: 0.10,
comfort: 0.15,
motion_artifact: 0.05,
adverse_event_risk: 0.20,
overstimulation: 0.10,
}
}
}
/// Inputs to the score, gathered from one session's observations.
#[derive(Debug, Clone, Copy)]
pub struct ScoreInputs<'a> {
pub stimulus: &'a StimulusParameters,
pub ruview: &'a RuViewState,
pub eeg: Option<&'a EegMeasurement>,
pub subjective: &'a SubjectiveReport,
/// Per-session adverse-event risk estimate `[0,1]` (model-provided).
pub adverse_event_risk: f64,
}
/// The safe-entrainment objective.
#[derive(Debug, Clone, Copy)]
pub struct SafeEntrainmentObjective {
pub weights: ObjectiveWeights,
pub envelope: SafetyEnvelope,
}
impl SafeEntrainmentObjective {
pub fn new(weights: ObjectiveWeights, envelope: SafetyEnvelope) -> Self {
Self { weights, envelope }
}
/// Score a session in `[~1, ~1]`. When EEG is absent, the gamma and
/// phase-locking terms fall back to RF-derived proxies (stillness and
/// breathing stability) at reduced weight — never claiming verified neural
/// entrainment (ADR-250 §16 consequence 3).
pub fn score(&self, inp: &ScoreInputs) -> f64 {
let w = &self.weights;
let (gamma, plv) = match inp.eeg {
Some(e) => (e.gamma_power_gain, e.phase_locking_value),
// RF-only proxy: down-weighted, derived from calm/still physiology.
None => (
0.5 * inp.ruview.stillness_score,
0.5 * inp.ruview.breathing_stability,
),
};
let overstim = self.overstimulation_penalty(inp.stimulus);
w.gamma_gain * gamma
+ w.phase_locking * plv
+ w.breathing_stability * inp.ruview.breathing_stability
+ w.adherence * inp.ruview.adherence
+ w.comfort * inp.subjective.comfort
- w.motion_artifact * inp.ruview.motion_artifact
- w.adverse_event_risk * inp.adverse_event_risk
- w.overstimulation * overstim
}
/// Overstimulation penalty `[0,1]`: how close intensity sits to the caps,
/// plus a duration term. Penalizes pushing brightness/volume toward the
/// envelope edges and running long sessions before tolerance is shown.
pub fn overstimulation_penalty(&self, s: &StimulusParameters) -> f64 {
let b = if self.envelope.brightness_cap > 0.0 {
s.brightness_level / self.envelope.brightness_cap
} else {
0.0
};
let v = if self.envelope.volume_cap > 0.0 {
s.volume_level / self.envelope.volume_cap
} else {
0.0
};
let d = if self.envelope.max_duration_minutes > 0.0 {
s.duration_minutes / self.envelope.max_duration_minutes
} else {
0.0
};
// Mean of the three normalized loads, clamped to [0,1].
((b + v + d) / 3.0).clamp(0.0, 1.0)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::response::SubjectiveReport;
fn objective() -> SafeEntrainmentObjective {
SafeEntrainmentObjective::new(ObjectiveWeights::default(), SafetyEnvelope::conservative())
}
#[test]
fn weights_sum_to_documented_totals() {
let w = ObjectiveWeights::default();
// Positive entrainment+experience terms sum to 0.90 (ADR-250 §7).
let pos = w.gamma_gain + w.phase_locking + w.breathing_stability + w.adherence + w.comfort;
assert!((pos - 0.90).abs() < 1e-9);
}
#[test]
fn strong_entrainment_scores_higher_than_weak() {
let obj = objective();
let stim = StimulusParameters::prior();
let ruview = RuViewState::calm_baseline();
let subj = SubjectiveReport::default();
let strong = EegMeasurement {
gamma_power_gain: 0.8,
phase_locking_value: 0.8,
artifact_score: 0.02,
};
let weak = EegMeasurement {
gamma_power_gain: 0.1,
phase_locking_value: 0.1,
artifact_score: 0.02,
};
let s_strong = obj.score(&ScoreInputs {
stimulus: &stim,
ruview: &ruview,
eeg: Some(&strong),
subjective: &subj,
adverse_event_risk: 0.0,
});
let s_weak = obj.score(&ScoreInputs {
stimulus: &stim,
ruview: &ruview,
eeg: Some(&weak),
subjective: &subj,
adverse_event_risk: 0.0,
});
assert!(s_strong > s_weak);
}
#[test]
fn adverse_risk_reduces_score() {
let obj = objective();
let stim = StimulusParameters::prior();
let ruview = RuViewState::calm_baseline();
let subj = SubjectiveReport::default();
let eeg = EegMeasurement {
gamma_power_gain: 0.5,
phase_locking_value: 0.5,
artifact_score: 0.02,
};
let safe = obj.score(&ScoreInputs {
stimulus: &stim,
ruview: &ruview,
eeg: Some(&eeg),
subjective: &subj,
adverse_event_risk: 0.0,
});
let risky = obj.score(&ScoreInputs {
stimulus: &stim,
ruview: &ruview,
eeg: Some(&eeg),
subjective: &subj,
adverse_event_risk: 0.9,
});
assert!(risky < safe);
}
#[test]
fn overstimulation_penalty_grows_with_intensity() {
let obj = objective();
let mut low = StimulusParameters::prior();
low.brightness_level = 0.05;
low.volume_level = 0.05;
low.duration_minutes = 5.0;
let mut high = StimulusParameters::prior();
high.brightness_level = 0.40;
high.volume_level = 0.40;
high.duration_minutes = 15.0;
assert!(obj.overstimulation_penalty(&high) > obj.overstimulation_penalty(&low));
}
#[test]
fn no_eeg_falls_back_to_rf_proxy() {
let obj = objective();
let stim = StimulusParameters::prior();
let ruview = RuViewState::calm_baseline();
let subj = SubjectiveReport::default();
// Should produce a finite, sane score without EEG.
let s = obj.score(&ScoreInputs {
stimulus: &stim,
ruview: &ruview,
eeg: None,
subjective: &subj,
adverse_event_risk: 0.0,
});
assert!(s.is_finite());
}
}
+372
View File
@@ -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<f64>,
next: usize,
}
impl CalibrationPlan {
pub fn new(envelope: &SafetyEnvelope) -> Self {
Self {
frequencies: envelope.calibration_frequencies(),
next: 0,
}
}
/// Number of calibration sessions still pending.
pub fn remaining(&self) -> usize {
self.frequencies.len().saturating_sub(self.next)
}
/// The next calibration stimulus (short duration, conservative intensity),
/// or `None` when the sweep is complete. Always inside the envelope.
pub fn next_stimulus(
&mut self,
envelope: &SafetyEnvelope,
session_minutes: f64,
) -> Option<StimulusParameters> {
let f = *self.frequencies.get(self.next)?;
self.next += 1;
let mut s = StimulusParameters::prior();
s.frequency_hz = f;
s.duration_minutes = session_minutes;
Some(envelope.clamp(s))
}
}
/// Gaussian-process surrogate over the 1-D frequency axis with an
/// Expected-Improvement acquisition (ADR-250 §8 Phase 2).
#[derive(Debug, Clone)]
pub struct BayesianOptimizer {
/// RBF length scale in Hz.
pub length_scale: f64,
/// GP signal variance.
pub signal_var: f64,
/// Observation noise variance (jitter; also keeps K SPD).
pub noise_var: f64,
/// EI exploration margin.
pub xi: f64,
/// Observed `(frequency_hz, score)` pairs.
obs_x: Vec<f64>,
obs_y: Vec<f64>,
}
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<f64> = (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<f64> {
let lo = (envelope.min_hz * 10.0).round() as i64;
let hi = (envelope.max_hz * 10.0).round() as i64;
(lo..=hi).map(|i| i as f64 / 10.0).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn calibration_sweep_covers_band_and_stays_safe() {
let env = SafetyEnvelope::conservative();
let mut plan = CalibrationPlan::new(&env);
let mut seen = Vec::new();
while let Some(s) = plan.next_stimulus(&env, 5.0) {
assert!(env.contains(&s));
seen.push(s.frequency_hz);
}
assert_eq!(seen.first(), Some(&36.0));
assert_eq!(seen.last(), Some(&44.0));
assert_eq!(plan.remaining(), 0);
}
#[test]
fn gp_recovers_a_quadratic_peak() {
// Synthetic score surface peaked at 39.5 Hz.
let env = SafetyEnvelope::conservative();
let mut bo = BayesianOptimizer::default();
let truth = |f: f64| 1.0 - 0.05 * (f - 39.5).powi(2);
for f in env.calibration_frequencies() {
bo.observe(f, truth(f));
}
let rec = bo.recommend(&env, &StimulusParameters::prior());
assert!(env.contains(&rec.stimulus));
// Should land within ±1 Hz of the true peak (ADR-250 §18 repeatability).
assert!((rec.stimulus.frequency_hz - 39.5).abs() <= 1.0);
}
#[test]
fn recommendation_is_always_in_envelope() {
let env = SafetyEnvelope::conservative();
let mut bo = BayesianOptimizer::default();
// Adversarial: pretend the band edge is best.
for f in env.calibration_frequencies() {
bo.observe(f, if f >= 44.0 { 10.0 } else { 0.0 });
}
let rec = bo.recommend(&env, &StimulusParameters::prior());
assert!(env.contains(&rec.stimulus));
assert!(rec.stimulus.frequency_hz <= env.max_hz);
}
#[test]
fn no_observations_returns_prior() {
let env = SafetyEnvelope::conservative();
let bo = BayesianOptimizer::default();
let rec = bo.recommend(&env, &StimulusParameters::prior());
assert_eq!(rec.stimulus.frequency_hz, 40.0);
}
#[test]
fn closed_loop_reduces_intensity_on_discomfort() {
let env = SafetyEnvelope::conservative();
let ctl = ClosedLoopController::default();
let cur = StimulusParameters::prior();
match ctl.step(&env, &cur, 0.6, 0.2, 0.0) {
LoopAction::Adjust(s) => {
assert!(s.brightness_level < cur.brightness_level);
assert!(env.contains(&s));
}
LoopAction::Hold => panic!("should have adjusted intensity"),
}
}
#[test]
fn closed_loop_nudge_stays_in_envelope() {
let env = SafetyEnvelope::conservative();
let ctl = ClosedLoopController::default();
let mut cur = StimulusParameters::prior();
cur.frequency_hz = 43.8; // near the upper edge
match ctl.step(&env, &cur, 0.1, 0.9, 1.0) {
LoopAction::Adjust(s) => assert!(env.contains(&s)),
LoopAction::Hold => {}
}
}
#[test]
fn closed_loop_holds_when_healthy() {
let env = SafetyEnvelope::conservative();
let ctl = ClosedLoopController::default();
let cur = StimulusParameters::prior();
assert_eq!(ctl.step(&env, &cur, 0.8, 0.9, 0.0), LoopAction::Hold);
}
}
+75
View File
@@ -0,0 +1,75 @@
//! Deterministic proof bundle (mirrors the `nvsim` / `verify.py` pattern).
//!
//! Runs a fixed reference participant through the full governed pipeline
//! (enroll → calibration sweep → witnessed audit) and hashes the resulting
//! session witnesses into a single bundle digest. If the digest matches
//! [`Proof::EXPECTED_WITNESS`], the optimizer math, simulator physics, response
//! update, and session-hashing code paths are all byte-identical to the
//! published reference. Any silent drift in any of them shifts the digest and
//! the test fails loudly.
use crate::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);
}
}
+346
View File
@@ -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<EegMeasurement>,
pub subjective: SubjectiveReport,
/// `false` if any safety stop fired this session.
pub safety_pass: bool,
/// `true` if an adverse event was recorded (sticky into the vector).
pub adverse_event: bool,
}
/// The 20-field adaptive personal response vector (ADR-250 §6). Stored as named
/// fields for clarity; [`as_array`](Self::as_array) gives the flat ordered
/// representation used for nearest-neighbor and clustering in RuVector.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct PersonResponseVector {
pub baseline_gamma: f64,
pub baseline_alpha: f64,
pub alpha_gamma_ratio: f64,
pub gamma_power_gain: f64,
pub phase_locking_value: f64,
pub breathing_rate: f64,
pub breathing_stability: f64,
pub motion_artifact: f64,
/// Posture encoded as an ordinal `[0,1]` for vector math.
pub posture_state: f64,
/// Sleep state encoded as an ordinal `[0,1]`.
pub sleep_state: f64,
pub restlessness_score: f64,
pub stimulus_frequency: f64,
pub brightness_level: f64,
pub sound_level: f64,
pub duty_cycle: f64,
pub phase_offset: f64,
pub session_duration: f64,
pub comfort_score: f64,
pub adherence_score: f64,
/// 1.0 once any adverse event has ever occurred for this person (sticky).
pub adverse_event_flag: f64,
}
impl PersonResponseVector {
/// Exponential-moving-average weight for session-to-session updates. Small
/// so a single noisy session cannot dominate (ADR-250 §16 risk
/// "Over-optimization", mitigation "conservative priors").
pub const EMA_ALPHA: f64 = 0.3;
/// Initialize from a baseline reading before any stimulation.
pub fn baseline(baseline_gamma: f64, baseline_alpha: f64, ruview: &RuViewState) -> Self {
let ratio = if baseline_gamma > 1e-9 {
baseline_alpha / baseline_gamma
} else {
0.0
};
Self {
baseline_gamma,
baseline_alpha,
alpha_gamma_ratio: ratio,
gamma_power_gain: 0.0,
phase_locking_value: 0.0,
breathing_rate: ruview.breathing_rate,
breathing_stability: ruview.breathing_stability,
motion_artifact: ruview.motion_artifact,
posture_state: posture_ordinal(ruview.posture),
sleep_state: sleep_ordinal(ruview.sleep_state),
restlessness_score: ruview.restlessness_score,
stimulus_frequency: 40.0,
brightness_level: 0.0,
sound_level: 0.0,
duty_cycle: 0.0,
phase_offset: 0.0,
session_duration: 0.0,
comfort_score: 0.85,
adherence_score: ruview.adherence,
adverse_event_flag: 0.0,
}
}
/// Flat ordered array (ADR-250 §6 field order) for RuVector similarity ops.
pub fn as_array(&self) -> [f64; 20] {
[
self.baseline_gamma,
self.baseline_alpha,
self.alpha_gamma_ratio,
self.gamma_power_gain,
self.phase_locking_value,
self.breathing_rate,
self.breathing_stability,
self.motion_artifact,
self.posture_state,
self.sleep_state,
self.restlessness_score,
self.stimulus_frequency,
self.brightness_level,
self.sound_level,
self.duty_cycle,
self.phase_offset,
self.session_duration,
self.comfort_score,
self.adherence_score,
self.adverse_event_flag,
]
}
/// `R_{t+1} = update(R_t, stimulus_t, response_t, safety_t)` (ADR-250 §6).
///
/// EMA-blends the continuous response fields toward the latest observation;
/// the adverse-event flag is *sticky* (monotonic 0→1) so a person's safety
/// history can never be smoothed away.
pub fn update(&mut self, obs: &SessionObservation) {
let a = Self::EMA_ALPHA;
let blend = |old: f64, new: f64| old + a * (new - old);
if let Some(eeg) = obs.eeg {
self.gamma_power_gain = blend(self.gamma_power_gain, eeg.gamma_power_gain);
self.phase_locking_value = blend(self.phase_locking_value, eeg.phase_locking_value);
}
self.breathing_rate = blend(self.breathing_rate, obs.ruview.breathing_rate);
self.breathing_stability =
blend(self.breathing_stability, obs.ruview.breathing_stability);
self.motion_artifact = blend(self.motion_artifact, obs.ruview.motion_artifact);
self.posture_state = posture_ordinal(obs.ruview.posture);
self.sleep_state = sleep_ordinal(obs.ruview.sleep_state);
self.restlessness_score = blend(self.restlessness_score, obs.ruview.restlessness_score);
self.stimulus_frequency = obs.stimulus.frequency_hz;
self.brightness_level = obs.stimulus.brightness_level;
self.sound_level = obs.stimulus.volume_level;
self.duty_cycle = duty_ordinal(obs.stimulus.duty_cycle);
self.phase_offset = obs.stimulus.phase_offset_ms;
self.session_duration = obs.stimulus.duration_minutes;
self.comfort_score = blend(self.comfort_score, obs.subjective.comfort);
self.adherence_score = blend(self.adherence_score, obs.ruview.adherence);
if obs.adverse_event {
self.adverse_event_flag = 1.0; // sticky
}
// Keep all `[0,1]` fields well-formed regardless of upstream noise.
self.clamp_unit_fields();
}
fn clamp_unit_fields(&mut self) {
for f in [
&mut self.gamma_power_gain,
&mut self.phase_locking_value,
&mut self.breathing_stability,
&mut self.motion_artifact,
&mut self.restlessness_score,
&mut self.comfort_score,
&mut self.adherence_score,
] {
*f = clamp_safe(*f, 0.0, 1.0);
}
}
}
fn posture_ordinal(p: Posture) -> f64 {
match p {
Posture::Standing => 0.0,
Posture::Seated => 0.33,
Posture::Reclined => 0.66,
Posture::Supine => 1.0,
Posture::Unknown => 0.5,
}
}
fn sleep_ordinal(s: SleepState) -> f64 {
match s {
SleepState::Active => 0.0,
SleepState::QuietWake => 0.33,
SleepState::Drowsy => 0.66,
SleepState::Asleep => 1.0,
SleepState::Unknown => 0.5,
}
}
fn duty_ordinal(d: crate::stimulus::DutyCycle) -> f64 {
match d {
crate::stimulus::DutyCycle::Continuous => 0.0,
crate::stimulus::DutyCycle::Ramped => 0.5,
crate::stimulus::DutyCycle::Pulsed => 1.0,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::stimulus::StimulusParameters;
fn obs_with_adverse(adverse: bool) -> SessionObservation {
SessionObservation {
stimulus: StimulusParameters::prior(),
ruview: RuViewState::calm_baseline(),
eeg: Some(EegMeasurement {
gamma_power_gain: 0.4,
phase_locking_value: 0.6,
artifact_score: 0.05,
}),
subjective: SubjectiveReport::default(),
safety_pass: !adverse,
adverse_event: adverse,
}
}
#[test]
fn vector_has_twenty_fields() {
let v = PersonResponseVector::baseline(0.2, 0.5, &RuViewState::calm_baseline());
assert_eq!(v.as_array().len(), 20);
}
#[test]
fn update_moves_gamma_toward_observation() {
let mut v = PersonResponseVector::baseline(0.2, 0.5, &RuViewState::calm_baseline());
let before = v.gamma_power_gain;
v.update(&obs_with_adverse(false));
assert!(v.gamma_power_gain > before);
assert!(v.gamma_power_gain < 0.4); // EMA, not a jump to the target
}
#[test]
fn adverse_flag_is_sticky() {
let mut v = PersonResponseVector::baseline(0.2, 0.5, &RuViewState::calm_baseline());
v.update(&obs_with_adverse(true));
assert_eq!(v.adverse_event_flag, 1.0);
// A subsequent clean session must NOT clear the flag.
v.update(&obs_with_adverse(false));
assert_eq!(v.adverse_event_flag, 1.0);
}
#[test]
fn unit_fields_stay_bounded_under_noise() {
let mut v = PersonResponseVector::baseline(0.2, 0.5, &RuViewState::calm_baseline());
let mut obs = obs_with_adverse(false);
obs.eeg = Some(EegMeasurement {
gamma_power_gain: 99.0,
phase_locking_value: -5.0,
artifact_score: 0.0,
});
v.update(&obs);
assert!(v.gamma_power_gain <= 1.0);
assert!(v.phase_locking_value >= 0.0);
}
}
+415
View File
@@ -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<ExclusionCondition>),
#[error("clinical supervision required for: {0:?}")]
SupervisionRequired(Vec<ExclusionCondition>),
#[error("consent not granted (or withdrawn)")]
NoConsent,
#[error("requested stimulus is outside the approved safety envelope")]
OutsideEnvelope,
}
/// Clinician-facing export summary (ADR-250 §11 responsibility 9, §17).
#[derive(Debug, Clone, PartialEq)]
pub struct ClinicianReport {
pub person_id: String,
pub n_sessions: usize,
pub n_safety_stops: usize,
pub best_frequency_hz: Option<f64>,
pub mean_entrainment: f64,
pub adverse_event_recorded: bool,
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<SessionRecord>,
next_index: u64,
}
impl RufloGovernor {
/// Enroll a participant. Fails closed on exclusion or missing consent.
pub fn enroll(
person_id: impl Into<String>,
envelope: SafetyEnvelope,
conditions: &[ExclusionCondition],
consent: Consent,
) -> Result<Self, GovernanceError> {
if consent != Consent::Granted {
return Err(GovernanceError::NoConsent);
}
match ExclusionScreen.evaluate(conditions) {
ScreenOutcome::Excluded(c) => return Err(GovernanceError::Excluded(c)),
ScreenOutcome::RequiresClinicalSupervision(c) => {
return Err(GovernanceError::SupervisionRequired(c))
}
ScreenOutcome::Cleared => {}
}
let baseline_ruview = RuViewState::calm_baseline();
Ok(Self {
person_id: person_id.into(),
objective: SafeEntrainmentObjective::new(Default::default(), envelope),
optimizer: BayesianOptimizer::default(),
response: PersonResponseVector::baseline(0.2, 0.5, &baseline_ruview),
versions: VersionTriple::default(),
consent,
mode: TrialMode::Open,
confidence_floor: 0.5,
envelope,
audit: Vec::new(),
next_index: 0,
})
}
/// 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<SessionRecord, GovernanceError> {
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::<f64>()
/ n as f64
} else {
0.0
};
ClinicianReport {
person_id: self.person_id.clone(),
n_sessions: n,
n_safety_stops: n_stops,
best_frequency_hz: self.optimizer.best().map(|(f, _)| f),
mean_entrainment: mean,
adverse_event_recorded: self.response.adverse_event_flag >= 1.0,
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"));
}
}
+284
View File
@@ -0,0 +1,284 @@
//! Safety stop conditions, exclusion screening, and the in-session monitor
//! (ADR-250 §12).
//!
//! Safety is a **hard constraint, not a weighted term** (ADR-250 §7). This
//! module owns the two non-negotiable gates:
//! 1. [`ExclusionScreen`] — who may participate at all.
//! 2. [`SafetyMonitor`] — when an in-progress session must stop.
use serde::{Deserialize, Serialize};
/// Adverse symptoms / events that force an immediate hard stop (ADR-250 §12).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AdverseEvent {
Headache,
Dizziness,
Nausea,
Agitation,
VisualDiscomfort,
AbnormalDistress,
SeizureLikeSymptom,
/// The participant pressed stop.
UserStopRequest,
}
impl AdverseEvent {
pub fn tag(self) -> &'static str {
match self {
AdverseEvent::Headache => "headache",
AdverseEvent::Dizziness => "dizziness",
AdverseEvent::Nausea => "nausea",
AdverseEvent::Agitation => "agitation",
AdverseEvent::VisualDiscomfort => "visual_discomfort",
AdverseEvent::AbnormalDistress => "abnormal_distress",
AdverseEvent::SeizureLikeSymptom => "seizure_like_symptom",
AdverseEvent::UserStopRequest => "user_stop_request",
}
}
}
/// Conditions requiring exclusion or explicit clinical supervision
/// (ADR-250 §12).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ExclusionCondition {
EpilepsyOrSeizureHistory,
Photosensitivity,
SevereMigraineSensitivity,
SeverePsychiatricInstability,
ImplantedNeurologicalDevice,
SignificantSensoryImpairment,
RecentMedicationChange,
}
/// Result of pre-enrollment screening.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum ScreenOutcome {
/// Cleared for unsupervised research participation.
Cleared,
/// May proceed only under clinician supervision (records the conditions).
RequiresClinicalSupervision(Vec<ExclusionCondition>),
/// Hard-excluded (records the conditions).
Excluded(Vec<ExclusionCondition>),
}
/// Inclusion/exclusion screen (ADR-250 §11 RuFlo responsibility 3, §12).
#[derive(Debug, Clone, Default)]
pub struct ExclusionScreen;
impl ExclusionScreen {
/// Apply the screening policy. Epilepsy/seizure and photosensitivity are
/// hard exclusions for unsupervised use because the protocol is, by
/// construction, flicker stimulation; the rest require supervision.
pub fn evaluate(&self, conditions: &[ExclusionCondition]) -> ScreenOutcome {
if conditions.is_empty() {
return ScreenOutcome::Cleared;
}
let hard: Vec<ExclusionCondition> = conditions
.iter()
.copied()
.filter(|c| {
matches!(
c,
ExclusionCondition::EpilepsyOrSeizureHistory
| ExclusionCondition::Photosensitivity
)
})
.collect();
if !hard.is_empty() {
return ScreenOutcome::Excluded(hard);
}
ScreenOutcome::RequiresClinicalSupervision(conditions.to_vec())
}
}
/// Why a session was stopped. `Completed` is the only non-stop terminal state.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", tag = "kind", content = "detail")]
pub enum StopReason {
/// Ran to planned completion — not a safety stop.
Completed,
/// An adverse event was reported.
AdverseEvent(AdverseEvent),
/// Sensor confidence dropped below the floor (entrainment unverifiable).
SensorConfidenceBelowFloor { value: f64, floor: f64 },
/// A requested parameter fell outside the approved envelope.
ProtocolOutsideEnvelope,
}
impl StopReason {
/// `true` for every reason that is a *safety* stop (everything but
/// `Completed`). Used by the audit layer to assert "100% safety stops
/// logged" (ADR-250 §18).
pub fn is_safety_stop(&self) -> bool {
!matches!(self, StopReason::Completed)
}
}
/// Live per-tick session telemetry the monitor evaluates.
#[derive(Debug, Clone, Copy)]
pub struct SafetyTick {
/// Reported adverse event this tick, if any.
pub adverse: Option<AdverseEvent>,
/// Aggregate sensor confidence `[0,1]` (RuView + optional EEG).
pub sensor_confidence: f64,
/// Whether the currently-applied stimulus is inside the envelope.
pub stimulus_in_envelope: bool,
}
/// In-session safety monitor with a configurable confidence floor and a
/// bounded stop latency contract (ADR-250 §17: safety-stop latency < 500 ms).
#[derive(Debug, Clone)]
pub struct SafetyMonitor {
/// Minimum acceptable sensor confidence (ADR-250 §12 condition 9).
pub confidence_floor: f64,
/// Declared worst-case evaluation latency in milliseconds; the monitor is
/// O(1) per tick so the real figure is far below this, but the contract is
/// asserted in tests against ADR-250 §17's 500 ms bound.
pub max_eval_latency_ms: u32,
triggered: Option<StopReason>,
}
impl Default for SafetyMonitor {
fn default() -> Self {
Self {
confidence_floor: 0.5,
max_eval_latency_ms: 50,
triggered: None,
}
}
}
impl SafetyMonitor {
pub fn new(confidence_floor: f64) -> Self {
Self {
confidence_floor,
..Default::default()
}
}
/// Evaluate one tick. Once a stop has fired the monitor is *latched* — it
/// keeps returning the original stop reason so a session can never silently
/// resume after a safety event (closed-loop "terminate and lock",
/// ADR-250 §8 Phase 4).
pub fn evaluate(&mut self, tick: SafetyTick) -> Option<StopReason> {
if let Some(reason) = &self.triggered {
return Some(reason.clone());
}
let reason = if let Some(ev) = tick.adverse {
Some(StopReason::AdverseEvent(ev))
} else if !tick.stimulus_in_envelope {
Some(StopReason::ProtocolOutsideEnvelope)
} else if tick.sensor_confidence < self.confidence_floor {
Some(StopReason::SensorConfidenceBelowFloor {
value: tick.sensor_confidence,
floor: self.confidence_floor,
})
} else {
None
};
if let Some(r) = &reason {
self.triggered = Some(r.clone());
}
reason
}
/// Whether the monitor has latched a stop.
pub fn is_stopped(&self) -> bool {
self.triggered.is_some()
}
/// The latched stop reason, if any.
pub fn stop_reason(&self) -> Option<&StopReason> {
self.triggered.as_ref()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_history_clears() {
assert_eq!(ExclusionScreen.evaluate(&[]), ScreenOutcome::Cleared);
}
#[test]
fn epilepsy_is_hard_excluded() {
let out = ExclusionScreen.evaluate(&[ExclusionCondition::EpilepsyOrSeizureHistory]);
assert!(matches!(out, ScreenOutcome::Excluded(_)));
}
#[test]
fn photosensitivity_is_hard_excluded() {
let out = ExclusionScreen.evaluate(&[ExclusionCondition::Photosensitivity]);
assert!(matches!(out, ScreenOutcome::Excluded(_)));
}
#[test]
fn migraine_requires_supervision() {
let out = ExclusionScreen.evaluate(&[ExclusionCondition::SevereMigraineSensitivity]);
assert!(matches!(out, ScreenOutcome::RequiresClinicalSupervision(_)));
}
#[test]
fn adverse_event_triggers_stop() {
let mut m = SafetyMonitor::default();
let r = m.evaluate(SafetyTick {
adverse: Some(AdverseEvent::Dizziness),
sensor_confidence: 0.9,
stimulus_in_envelope: true,
});
assert_eq!(r, Some(StopReason::AdverseEvent(AdverseEvent::Dizziness)));
assert!(r.unwrap().is_safety_stop());
}
#[test]
fn low_confidence_triggers_stop() {
let mut m = SafetyMonitor::new(0.6);
let r = m.evaluate(SafetyTick {
adverse: None,
sensor_confidence: 0.3,
stimulus_in_envelope: true,
});
assert!(matches!(
r,
Some(StopReason::SensorConfidenceBelowFloor { .. })
));
}
#[test]
fn out_of_envelope_triggers_stop() {
let mut m = SafetyMonitor::default();
let r = m.evaluate(SafetyTick {
adverse: None,
sensor_confidence: 0.9,
stimulus_in_envelope: false,
});
assert_eq!(r, Some(StopReason::ProtocolOutsideEnvelope));
}
#[test]
fn stop_is_latched_and_cannot_resume() {
let mut m = SafetyMonitor::default();
m.evaluate(SafetyTick {
adverse: Some(AdverseEvent::Headache),
sensor_confidence: 0.9,
stimulus_in_envelope: true,
});
// A subsequent "all clear" tick must NOT clear the latch.
let r = m.evaluate(SafetyTick {
adverse: None,
sensor_confidence: 1.0,
stimulus_in_envelope: true,
});
assert_eq!(r, Some(StopReason::AdverseEvent(AdverseEvent::Headache)));
assert!(m.is_stopped());
}
#[test]
fn completed_is_not_a_safety_stop() {
assert!(!StopReason::Completed.is_safety_stop());
}
}
+279
View File
@@ -0,0 +1,279 @@
//! Session record and the reproducible session witness (ADR-250 §11, §13).
//!
//! `session_hash = hash(protocol_version, model_version, device_version,
//! stimulus_parameters, sensor_summary, response_summary, safety_events)`.
//!
//! The hash is computed over a **canonical** serialization (fixed field order,
//! quantized floats) so the same session content always yields the same digest
//! across machines — the RuFlo reproducibility trail. The [`SessionId`] is
//! *derived from* the hash, so identifiers are themselves reproducible (no
//! random UUIDs that would break replay).
use serde::{Deserialize, Serialize};
use crate::response::{EegMeasurement, RuViewState, SubjectiveReport};
use crate::safety::StopReason;
use crate::simulator::stable_hash;
use crate::stimulus::StimulusParameters;
/// Version triple identifying the exact software/hardware that ran a session.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct VersionTriple {
pub protocol_version: String,
pub model_version: String,
pub device_version: String,
}
impl Default for VersionTriple {
fn default() -> Self {
Self {
protocol_version: "adr-250-v0.1".to_string(),
model_version: "ruvector-gamma-v0.1".to_string(),
device_version: "sim-harness-v0.1".to_string(),
}
}
}
/// Per-session outcome summary (ADR-250 §13 `outcome`).
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct Outcome {
pub entrainment_score: f64,
pub safety_pass: bool,
pub recommended_next_frequency_hz: f64,
}
/// A complete, hashable session record (ADR-250 §13).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SessionRecord {
/// Derived from the content hash (see [`SessionRecord::finalize`]).
pub session_id: SessionId,
/// Pseudonymous participant id.
pub person_id: String,
pub versions: VersionTriple,
/// Caller-supplied epoch milliseconds (kept explicit for determinism — no
/// wall-clock reads inside the crate).
pub timestamp_ms: u64,
pub stimulus: StimulusParameters,
pub ruview_state: RuViewState,
pub eeg_optional: Option<EegMeasurement>,
pub subjective: SubjectiveReport,
pub outcome: Outcome,
/// Every safety event raised during the session (ADR-250 §18: 100% logged).
pub safety_events: Vec<StopReason>,
/// The session witness (hex SHA-256).
pub session_hash: String,
}
/// A reproducible session identifier: the first 16 hex chars of the witness.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SessionId(pub String);
/// Builder that gathers session inputs and finalizes them into an immutable,
/// witnessed [`SessionRecord`].
#[derive(Debug, Clone)]
pub struct SessionBuilder {
person_id: String,
versions: VersionTriple,
timestamp_ms: u64,
stimulus: StimulusParameters,
ruview: RuViewState,
eeg: Option<EegMeasurement>,
subjective: SubjectiveReport,
outcome: Outcome,
safety_events: Vec<StopReason>,
}
impl SessionBuilder {
#[allow(clippy::too_many_arguments)]
pub fn new(
person_id: impl Into<String>,
versions: VersionTriple,
timestamp_ms: u64,
stimulus: StimulusParameters,
ruview: RuViewState,
subjective: SubjectiveReport,
outcome: Outcome,
) -> Self {
Self {
person_id: person_id.into(),
versions,
timestamp_ms,
stimulus,
ruview,
eeg: None,
subjective,
outcome,
safety_events: Vec::new(),
}
}
pub fn with_eeg(mut self, eeg: EegMeasurement) -> Self {
self.eeg = Some(eeg);
self
}
pub fn with_safety_events(mut self, events: Vec<StopReason>) -> Self {
self.safety_events = events;
self
}
/// Compute the canonical witness and freeze the record.
pub fn finalize(self) -> SessionRecord {
let canon = self.canonical_bytes();
let digest = stable_hash(&[&canon]);
let hex = hex_encode(&digest);
let session_id = SessionId(hex[..16].to_string());
SessionRecord {
session_id,
person_id: self.person_id,
versions: self.versions,
timestamp_ms: self.timestamp_ms,
stimulus: self.stimulus,
ruview_state: self.ruview,
eeg_optional: self.eeg,
subjective: self.subjective,
outcome: self.outcome,
safety_events: self.safety_events,
session_hash: hex,
}
}
/// Canonical byte serialization in the ADR-250 §11 hash field order.
/// Floats are quantized so last-bit jitter never forks the witness.
fn canonical_bytes(&self) -> Vec<u8> {
let mut s = String::new();
s.push_str(&self.versions.protocol_version);
s.push('|');
s.push_str(&self.versions.model_version);
s.push('|');
s.push_str(&self.versions.device_version);
s.push('|');
// stimulus_parameters
push_q(&mut s, self.stimulus.frequency_hz, 10.0);
s.push_str(self.stimulus.modality.tag());
push_q(&mut s, self.stimulus.brightness_level, 100.0);
push_q(&mut s, self.stimulus.volume_level, 100.0);
s.push_str(self.stimulus.duty_cycle.tag());
push_q(&mut s, self.stimulus.phase_offset_ms, 10.0);
push_q(&mut s, self.stimulus.duration_minutes, 10.0);
s.push('|');
// sensor_summary (RuView + optional EEG)
push_q(&mut s, self.ruview.breathing_rate, 10.0);
push_q(&mut s, self.ruview.breathing_stability, 100.0);
push_q(&mut s, self.ruview.motion_artifact, 100.0);
push_q(&mut s, self.ruview.stillness_score, 100.0);
if let Some(e) = &self.eeg {
push_q(&mut s, e.gamma_power_gain, 100.0);
push_q(&mut s, e.phase_locking_value, 100.0);
push_q(&mut s, e.artifact_score, 100.0);
} else {
s.push_str("no_eeg");
}
s.push('|');
// response_summary
push_q(&mut s, self.outcome.entrainment_score, 1000.0);
push_q(&mut s, self.outcome.recommended_next_frequency_hz, 10.0);
s.push(if self.outcome.safety_pass { 'P' } else { 'F' });
s.push('|');
// safety_events
for ev in &self.safety_events {
s.push_str(&serde_json::to_string(ev).unwrap_or_default());
s.push(';');
}
s.into_bytes()
}
}
/// Append a float quantized to `1/scale` resolution, as a stable integer token.
fn push_q(s: &mut String, v: f64, scale: f64) {
let q = if v.is_finite() {
(v * scale).round() as i64
} else {
i64::MIN
};
s.push_str(&q.to_string());
s.push(',');
}
fn hex_encode(bytes: &[u8]) -> String {
let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes {
s.push_str(&format!("{:02x}", b));
}
s
}
#[cfg(test)]
mod tests {
use super::*;
use crate::safety::{AdverseEvent, StopReason};
fn builder() -> SessionBuilder {
SessionBuilder::new(
"subject-A",
VersionTriple::default(),
1_700_000_000_000,
StimulusParameters::prior(),
RuViewState::calm_baseline(),
SubjectiveReport::default(),
Outcome {
entrainment_score: 0.71,
safety_pass: true,
recommended_next_frequency_hz: 39.5,
},
)
}
#[test]
fn identical_sessions_hash_identically() {
let a = builder().finalize();
let b = builder().finalize();
assert_eq!(a.session_hash, b.session_hash);
assert_eq!(a.session_id, b.session_id);
}
#[test]
fn changing_frequency_changes_hash() {
let a = builder().finalize();
let mut b2 = builder();
b2.stimulus.frequency_hz = 41.0;
let b = b2.finalize();
assert_ne!(a.session_hash, b.session_hash);
}
#[test]
fn safety_events_alter_the_witness() {
let a = builder().finalize();
let b = builder()
.with_safety_events(vec![StopReason::AdverseEvent(AdverseEvent::Dizziness)])
.finalize();
assert_ne!(a.session_hash, b.session_hash);
}
#[test]
fn session_id_is_hash_prefix() {
let r = builder().finalize();
assert_eq!(r.session_id.0, r.session_hash[..16]);
}
#[test]
fn record_roundtrips_through_json() {
let r = builder().finalize();
let json = serde_json::to_string(&r).unwrap();
let back: SessionRecord = serde_json::from_str(&json).unwrap();
assert_eq!(r, back);
}
#[test]
fn eeg_presence_changes_witness() {
let a = builder().finalize();
let b = builder()
.with_eeg(EegMeasurement {
gamma_power_gain: 0.4,
phase_locking_value: 0.6,
artifact_score: 0.03,
})
.finalize();
assert_ne!(a.session_hash, b.session_hash);
}
}
+274
View File
@@ -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 3644 Hz finding).
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct LatentPerson {
/// The individual's true peak entrainment frequency in Hz.
pub peak_hz: f64,
/// Tuning-curve width in Hz (smaller = sharper, harder to find).
pub width_hz: f64,
/// Maximum achievable gamma gain at the peak `[0,1]`.
pub max_gain: f64,
/// How quickly comfort falls as intensity rises `[0,1]`.
pub intensity_sensitivity: f64,
/// Intrinsic measurement noise level `[0,1]`.
pub noise: f64,
}
impl LatentPerson {
/// Derive a stable latent person from a pseudonymous id. Deterministic: the
/// same id always yields the same physiology, so multi-session studies are
/// reproducible.
pub fn from_id(person_id: &str) -> Self {
let h = stable_hash(&[b"latent-person", person_id.as_bytes()]);
// Map hash bytes to parameter ranges.
let u = |i: usize| (h[i] as f64) / 255.0;
Self {
peak_hz: 37.0 + u(0) * 6.0, // 37..43 Hz
width_hz: 1.5 + u(1) * 2.5, // 1.5..4.0 Hz
max_gain: 0.45 + u(2) * 0.45, // 0.45..0.90
intensity_sensitivity: 0.2 + u(3) * 0.6, // 0.2..0.8
noise: 0.02 + u(4) * 0.08, // 0.02..0.10
}
}
}
/// One simulated session outcome.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SimulatedResponse {
/// Synthetic EEG entrainment measurement.
pub eeg: EegMeasurement,
/// Updated RuView state for this session (motion/comfort feedback).
pub ruview: RuViewState,
/// Subjective comfort `[0,1]`.
pub comfort: f64,
/// Whether an adverse event occurred (rises with overstimulation).
pub adverse_event: bool,
}
/// 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::<f64>() - 0.5) * 2.0 * person.noise };
let gamma_power_gain =
clamp01(person.max_gain * tuning * state_factor * intensity_gain + noise());
// Phase locking tracks tuning but is less intensity-dependent.
let phase_locking_value = clamp01(0.9 * tuning * state_factor + noise());
// Artifact rises with motion and intensity.
let artifact_score = clamp01(state.motion_artifact + 0.1 * intensity + noise().abs());
// --- Comfort: high intensity and long duration erode comfort. ---
let dur_load = (stimulus.duration_minutes / 15.0).clamp(0.0, 1.0);
let comfort = clamp01(
0.95 - person.intensity_sensitivity * (0.7 * intensity + 0.3 * dur_load) + noise(),
);
// --- Adverse event: rare, and only when truly overstimulated. ---
let overstim = intensity * dur_load;
let adverse_event = overstim > 0.85 && rng.gen::<f64>() < (overstim - 0.85) * 2.0;
// Feedback into RuView state: discomfort raises motion/restlessness.
let mut ruview = *state;
ruview.motion_artifact = clamp01(state.motion_artifact + (1.0 - comfort) * 0.1);
ruview.restlessness_score = clamp01(state.restlessness_score + (1.0 - comfort) * 0.15);
SimulatedResponse {
eeg: EegMeasurement {
gamma_power_gain,
phase_locking_value,
artifact_score,
},
ruview,
comfort,
adverse_event,
}
}
/// Per-session ChaCha20 stream seeded from all inputs — reproducible noise.
fn session_rng(
&self,
person: &LatentPerson,
stimulus: &StimulusParameters,
session_index: u64,
) -> ChaCha20Rng {
// Quantize stimulus to 0.1 Hz / 0.01 intensity so floating noise in the
// last bits cannot fork the stream; matches the ±0.1 Hz control spec.
let q_freq = (stimulus.frequency_hz * 10.0).round() as i64;
let q_bright = (stimulus.brightness_level * 100.0).round() as i64;
let q_vol = (stimulus.volume_level * 100.0).round() as i64;
let q_peak = (person.peak_hz * 1000.0).round() as i64;
let h = stable_hash(&[
b"session-rng",
&self.seed.to_le_bytes(),
&session_index.to_le_bytes(),
&q_freq.to_le_bytes(),
&q_bright.to_le_bytes(),
&q_vol.to_le_bytes(),
&q_peak.to_le_bytes(),
]);
let mut seed32 = [0u8; 32];
seed32.copy_from_slice(&h);
ChaCha20Rng::from_seed(seed32)
}
}
fn state_modulation(state: &RuViewState) -> f64 {
let sleep = match state.sleep_state {
SleepState::QuietWake => 1.0,
SleepState::Drowsy => 0.85,
SleepState::Asleep => 0.6,
SleepState::Active => 0.7,
SleepState::Unknown => 0.8,
};
// Stillness and breathing stability help; restlessness hurts.
let calm = 0.5 + 0.3 * state.stillness_score + 0.2 * state.breathing_stability
- 0.2 * state.restlessness_score;
(sleep * calm).clamp(0.0, 1.0)
}
#[inline]
fn clamp01(v: f64) -> f64 {
if v.is_nan() {
0.0
} else {
v.clamp(0.0, 1.0)
}
}
/// SHA-256 over concatenated byte chunks → 32 bytes. Deterministic and
/// portable; the witness foundation for the whole crate.
pub(crate) fn stable_hash(chunks: &[&[u8]]) -> [u8; 32] {
let mut hasher = Sha256::new();
for c in chunks {
hasher.update((c.len() as u64).to_le_bytes());
hasher.update(c);
}
hasher.finalize().into()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::stimulus::StimulusParameters;
#[test]
fn same_inputs_produce_identical_output() {
let sim = ResponseSimulator::new(42);
let person = LatentPerson::from_id("subject-A");
let state = RuViewState::calm_baseline();
let stim = StimulusParameters::prior();
let a = sim.simulate(&person, &state, &stim, 0);
let b = sim.simulate(&person, &state, &stim, 0);
assert_eq!(a, b);
}
#[test]
fn different_session_index_changes_noise() {
let sim = ResponseSimulator::new(42);
let person = LatentPerson::from_id("subject-A");
let state = RuViewState::calm_baseline();
let stim = StimulusParameters::prior();
let a = sim.simulate(&person, &state, &stim, 0);
let b = sim.simulate(&person, &state, &stim, 1);
assert_ne!(a.eeg.gamma_power_gain, b.eeg.gamma_power_gain);
}
#[test]
fn latent_person_is_stable_per_id() {
assert_eq!(
LatentPerson::from_id("subject-A"),
LatentPerson::from_id("subject-A")
);
assert_ne!(
LatentPerson::from_id("subject-A").peak_hz,
LatentPerson::from_id("subject-Z").peak_hz
);
}
#[test]
fn peak_frequency_entrains_better_than_band_edge() {
let sim = ResponseSimulator::new(7);
let person = LatentPerson::from_id("subject-B");
let state = RuViewState::calm_baseline();
let mut at_peak = StimulusParameters::prior();
at_peak.frequency_hz = (person.peak_hz * 10.0).round() / 10.0;
let mut at_edge = StimulusParameters::prior();
at_edge.frequency_hz = 36.0;
let r_peak = sim.simulate(&person, &state, &at_peak, 0);
let r_edge = sim.simulate(&person, &state, &at_edge, 0);
// Only assert when the latent peak is genuinely away from the edge.
if (person.peak_hz - 36.0).abs() > 1.0 {
assert!(r_peak.eeg.gamma_power_gain > r_edge.eeg.gamma_power_gain);
}
}
#[test]
fn calm_state_entrains_better_than_restless() {
let sim = ResponseSimulator::new(3);
let person = LatentPerson::from_id("subject-C");
let stim = StimulusParameters::prior();
let calm = RuViewState::calm_baseline();
let mut restless = RuViewState::calm_baseline();
restless.restlessness_score = 0.9;
restless.stillness_score = 0.2;
restless.sleep_state = SleepState::Active;
let r_calm = sim.simulate(&person, &calm, &stim, 0);
let r_restless = sim.simulate(&person, &restless, &stim, 0);
assert!(r_calm.eeg.gamma_power_gain > r_restless.eeg.gamma_power_gain);
}
}
+284
View File
@@ -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 3644, prior 40).
pub frequency_hz: f64,
/// Stimulation modality.
pub modality: Modality,
/// Visual brightness in `[0,1]` (capped well below unsafe flicker intensity).
pub brightness_level: f64,
/// Audio volume in `[0,1]` (comfort-bounded).
pub volume_level: f64,
/// Duty-cycle shape.
pub duty_cycle: DutyCycle,
/// Inter-modality phase offset in milliseconds.
pub phase_offset_ms: f64,
/// Session duration in minutes.
pub duration_minutes: f64,
}
impl StimulusParameters {
/// The evidence-based starting prior: 40 Hz combined audio-visual, gentle
/// intensities, continuous, short (ADR-250 §5 "Starting prior").
pub fn prior() -> Self {
Self {
frequency_hz: 40.0,
modality: Modality::AudioVisual,
brightness_level: 0.30,
volume_level: 0.28,
duty_cycle: DutyCycle::Continuous,
phase_offset_ms: 0.0,
duration_minutes: 10.0,
}
}
}
/// Reasons a [`StimulusParameters`] is rejected by the envelope. Each variant
/// is logged verbatim into the RuFlo safety trail.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", tag = "kind", content = "detail")]
pub enum EnvelopeViolation {
/// Frequency outside `[min_hz, max_hz]`.
Frequency { value: f64, min: f64, max: f64 },
/// Brightness above the hard cap.
Brightness { value: f64, cap: f64 },
/// Volume above the hard cap.
Volume { value: f64, cap: f64 },
/// Duration above the per-stage maximum.
Duration { value: f64, max: f64 },
/// A non-finite (NaN/Inf) field was supplied.
NonFinite { field: &'static str },
}
/// 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<EnvelopeViolation>> {
let mut v = Vec::new();
for (field, val) in [
("frequency_hz", s.frequency_hz),
("brightness_level", s.brightness_level),
("volume_level", s.volume_level),
("duration_minutes", s.duration_minutes),
("phase_offset_ms", s.phase_offset_ms),
] {
if !val.is_finite() {
v.push(EnvelopeViolation::NonFinite { field });
}
}
if !v.is_empty() {
return Err(v);
}
if s.frequency_hz < self.min_hz || s.frequency_hz > self.max_hz {
v.push(EnvelopeViolation::Frequency {
value: s.frequency_hz,
min: self.min_hz,
max: self.max_hz,
});
}
if s.brightness_level > self.brightness_cap || s.brightness_level < 0.0 {
v.push(EnvelopeViolation::Brightness {
value: s.brightness_level,
cap: self.brightness_cap,
});
}
if s.volume_level > self.volume_cap || s.volume_level < 0.0 {
v.push(EnvelopeViolation::Volume {
value: s.volume_level,
cap: self.volume_cap,
});
}
if s.duration_minutes > self.max_duration_minutes || s.duration_minutes <= 0.0 {
v.push(EnvelopeViolation::Duration {
value: s.duration_minutes,
max: self.max_duration_minutes,
});
}
if v.is_empty() {
Ok(())
} else {
Err(v)
}
}
/// Best-effort coercion of `s` into the envelope. Used as a defensive final
/// stage on any emitted recommendation; coercion can only ever *reduce*
/// intensity / pull frequency inward, never expand it. The result always
/// satisfies [`contains`](Self::contains).
pub fn clamp(&self, mut s: StimulusParameters) -> StimulusParameters {
s.frequency_hz = clamp_safe(s.frequency_hz, self.min_hz, self.max_hz);
s.brightness_level = clamp_safe(s.brightness_level, 0.0, self.brightness_cap);
s.volume_level = clamp_safe(s.volume_level, 0.0, self.volume_cap);
s.phase_offset_ms =
clamp_safe(s.phase_offset_ms, -self.max_phase_offset_ms, self.max_phase_offset_ms);
// Duration must be strictly positive; floor at 1 minute.
s.duration_minutes = clamp_safe(s.duration_minutes, 1.0, self.max_duration_minutes);
s
}
/// The calibration sweep grid (ADR-250 §8 Phase 1): integer-Hz steps across
/// the band, intersected with the envelope. Returns frequencies in Hz.
pub fn calibration_frequencies(&self) -> Vec<f64> {
let lo = self.min_hz.ceil() as i32;
let hi = self.max_hz.floor() as i32;
(lo..=hi).map(|f| f as f64).collect()
}
}
#[cfg(test)]
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);
}
}