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

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-11 15:26:17 -04:00

362 lines
14 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Synthetic response simulator — Milestone 1 (ADR-250 §21).
//!
//! `frequency_response_curve(person, state, stimulus)` — a deterministic stand-in
//! for real EEG + RuView measurements so the optimizer, safety bounds, and
//! RuVector update logic can be exercised and replayed bit-exactly **before any
//! hardware or human exposure**.
//!
//! Determinism contract (same discipline as `nvsim`): the response for a given
//! `(global_seed, person_id, session_index, stimulus)` is byte-identical across
//! runs and machines. Noise is drawn from a ChaCha20 stream seeded from a
//! SHA-256 of those inputs — no OS entropy, no wall clock.
use rand::{Rng, SeedableRng};
use rand_chacha::ChaCha20Rng;
use sha2::{Digest, Sha256};
use crate::response::{EegMeasurement, RuViewState, SleepState};
use crate::safety::AdverseEvent;
use crate::stimulus::StimulusParameters;
/// A person's hidden response physiology. Real people are not 40 Hz-identical;
/// each has a latent best frequency the optimizer must *discover* (ADR-250 §1,
/// the 2025 PLOS One 3644 Hz finding).
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct LatentPerson {
/// The individual's true peak entrainment frequency in Hz.
pub peak_hz: f64,
/// Tuning-curve width in Hz (smaller = sharper, harder to find).
pub width_hz: f64,
/// Maximum achievable gamma gain at the peak `[0,1]`.
pub max_gain: f64,
/// How quickly comfort falls as intensity rises `[0,1]`.
pub intensity_sensitivity: f64,
/// Intrinsic measurement noise level `[0,1]`.
pub noise: f64,
}
impl LatentPerson {
/// Derive a stable latent person from a pseudonymous id. Deterministic: the
/// same id always yields the same physiology, so multi-session studies are
/// reproducible.
pub fn from_id(person_id: &str) -> Self {
let h = stable_hash(&[b"latent-person", person_id.as_bytes()]);
// Map hash bytes to parameter ranges.
let u = |i: usize| (h[i] as f64) / 255.0;
Self {
peak_hz: 37.0 + u(0) * 6.0, // 37..43 Hz
width_hz: 1.5 + u(1) * 2.5, // 1.5..4.0 Hz
max_gain: 0.45 + u(2) * 0.45, // 0.45..0.90
intensity_sensitivity: 0.2 + u(3) * 0.6, // 0.2..0.8
noise: 0.02 + u(4) * 0.08, // 0.02..0.10
}
}
}
/// One simulated session outcome.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SimulatedResponse {
/// Synthetic EEG entrainment measurement.
pub eeg: EegMeasurement,
/// Updated RuView state for this session (motion/comfort feedback).
pub ruview: RuViewState,
/// Subjective comfort `[0,1]`.
pub comfort: f64,
/// Whether an adverse event occurred (rises with overstimulation).
pub adverse_event: bool,
}
/// A deterministic safety-fault injection (Finding 5, 2026-06-11 review). The
/// synthetic simulator is the M1 validation harness; real adverse events arrive
/// from hardware/clinic. Under the absolute intensity caps (≤ 0.6) the organic
/// overstimulation path can no longer reach the adverse threshold, so this
/// scheduled injection is how the integrated per-tick monitor + terminate-and-
/// lock path is exercised in simulation.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct FaultSchedule {
/// Session index at which the fault surfaces.
pub at_session_index: u64,
/// Tick (0-based, ≈ minutes) within that session at which it surfaces.
pub at_tick: usize,
/// What surfaces.
pub fault: InjectedFault,
}
/// The kind of safety fault a [`FaultSchedule`] surfaces.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum InjectedFault {
/// Surface an adverse event at the scheduled tick (engages terminate-and-lock
/// if its class warrants it).
Adverse(AdverseEvent),
/// Drop sensor confidence to this value at the scheduled tick.
LowConfidence(f64),
}
/// One per-tick safety sample the in-session monitor evaluates (Finding 5).
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SafetySample {
/// Adverse event surfaced at this tick, if any.
pub adverse: Option<AdverseEvent>,
/// Sensor confidence at this tick `[0,1]`.
pub sensor_confidence: f64,
}
/// Deterministic response simulator.
#[derive(Debug, Clone)]
pub struct ResponseSimulator {
seed: u64,
/// Optional deterministic fault injection for the per-tick safety path.
fault: Option<FaultSchedule>,
}
impl ResponseSimulator {
pub fn new(seed: u64) -> Self {
Self { seed, fault: None }
}
/// A simulator that surfaces a scheduled safety fault, for validating the
/// integrated per-tick monitor + terminate-and-lock path (Finding 5).
pub fn with_fault(seed: u64, fault: FaultSchedule) -> Self {
Self {
seed,
fault: Some(fault),
}
}
/// Per-tick safety samples for one session (Finding 5). One tick ≈ one
/// minute of the planned duration (≥ 1). Normal sessions yield clean
/// samples — the aggregate [`simulate`](Self::simulate) cannot produce an
/// adverse event inside the absolute caps — so the witness of a clean
/// session is unchanged; a configured [`FaultSchedule`] surfaces a
/// deterministic event at its tick.
pub fn session_ticks(
&self,
person: &LatentPerson,
state: &RuViewState,
stimulus: &StimulusParameters,
session_index: u64,
) -> Vec<SafetySample> {
let n = (stimulus.duration_minutes.round() as i64).clamp(1, 600) as usize;
// Organic adverse signal from the aggregate physics (kept wired even if
// it cannot fire under the absolute caps): surfaced mid-session.
let agg = self.simulate(person, state, stimulus, session_index);
let organic_tick = n / 2;
(0..n)
.map(|i| {
let mut sample = SafetySample {
adverse: None,
sensor_confidence: state.sensor_confidence,
};
if agg.adverse_event && i == organic_tick {
sample.adverse = Some(AdverseEvent::AbnormalDistress);
}
if let Some(f) = &self.fault {
if f.at_session_index == session_index && f.at_tick == i {
match f.fault {
InjectedFault::Adverse(ev) => sample.adverse = Some(ev),
InjectedFault::LowConfidence(c) => sample.sensor_confidence = c,
}
}
}
sample
})
.collect()
}
/// Simulate a session. `session_index` makes repeated identical protocols
/// produce independent (but reproducible) noise draws.
pub fn simulate(
&self,
person: &LatentPerson,
state: &RuViewState,
stimulus: &StimulusParameters,
session_index: u64,
) -> SimulatedResponse {
let mut rng = self.session_rng(person, stimulus, session_index);
// --- Frequency tuning curve: Gaussian bump around the latent peak. ---
let df = stimulus.frequency_hz - person.peak_hz;
let tuning = (-0.5 * (df / person.width_hz).powi(2)).exp();
// --- State modulation: calm, still, awake-but-quiet entrains best. ---
let state_factor = state_modulation(state);
// --- Intensity helps gamma up to a point, then risks overstimulation. ---
let intensity = 0.5 * (stimulus.brightness_level + stimulus.volume_level);
// Mild positive contribution from intensity, saturating.
let intensity_gain = 0.6 + 0.4 * (1.0 - (-3.0 * intensity).exp());
let mut noise = || -> f64 { (rng.gen::<f64>() - 0.5) * 2.0 * person.noise };
let gamma_power_gain =
clamp01(person.max_gain * tuning * state_factor * intensity_gain + noise());
// Phase locking tracks tuning but is less intensity-dependent.
let phase_locking_value = clamp01(0.9 * tuning * state_factor + noise());
// Artifact rises with motion and intensity.
let artifact_score = clamp01(state.motion_artifact + 0.1 * intensity + noise().abs());
// --- Comfort: high intensity and long duration erode comfort. ---
let dur_load = (stimulus.duration_minutes / 15.0).clamp(0.0, 1.0);
let comfort = clamp01(
0.95 - person.intensity_sensitivity * (0.7 * intensity + 0.3 * dur_load) + noise(),
);
// --- Adverse event: rare, and only when truly overstimulated. ---
let overstim = intensity * dur_load;
let adverse_event = overstim > 0.85 && rng.gen::<f64>() < (overstim - 0.85) * 2.0;
// Feedback into RuView state: discomfort raises motion/restlessness.
let mut ruview = *state;
ruview.motion_artifact = clamp01(state.motion_artifact + (1.0 - comfort) * 0.1);
ruview.restlessness_score = clamp01(state.restlessness_score + (1.0 - comfort) * 0.15);
SimulatedResponse {
eeg: EegMeasurement {
gamma_power_gain,
phase_locking_value,
artifact_score,
},
ruview,
comfort,
adverse_event,
}
}
/// Per-session ChaCha20 stream seeded from all inputs — reproducible noise.
fn session_rng(
&self,
person: &LatentPerson,
stimulus: &StimulusParameters,
session_index: u64,
) -> ChaCha20Rng {
// Quantize stimulus to 0.1 Hz / 0.01 intensity so floating noise in the
// last bits cannot fork the stream; matches the ±0.1 Hz control spec.
let q_freq = (stimulus.frequency_hz * 10.0).round() as i64;
let q_bright = (stimulus.brightness_level * 100.0).round() as i64;
let q_vol = (stimulus.volume_level * 100.0).round() as i64;
let q_peak = (person.peak_hz * 1000.0).round() as i64;
let h = stable_hash(&[
b"session-rng",
&self.seed.to_le_bytes(),
&session_index.to_le_bytes(),
&q_freq.to_le_bytes(),
&q_bright.to_le_bytes(),
&q_vol.to_le_bytes(),
&q_peak.to_le_bytes(),
]);
let mut seed32 = [0u8; 32];
seed32.copy_from_slice(&h);
ChaCha20Rng::from_seed(seed32)
}
}
fn state_modulation(state: &RuViewState) -> f64 {
let sleep = match state.sleep_state {
SleepState::QuietWake => 1.0,
SleepState::Drowsy => 0.85,
SleepState::Asleep => 0.6,
SleepState::Active => 0.7,
SleepState::Unknown => 0.8,
};
// Stillness and breathing stability help; restlessness hurts.
let calm = 0.5 + 0.3 * state.stillness_score + 0.2 * state.breathing_stability
- 0.2 * state.restlessness_score;
(sleep * calm).clamp(0.0, 1.0)
}
#[inline]
fn clamp01(v: f64) -> f64 {
if v.is_nan() {
0.0
} else {
v.clamp(0.0, 1.0)
}
}
/// SHA-256 over concatenated byte chunks → 32 bytes. Deterministic and
/// portable; the witness foundation for the whole crate.
pub(crate) fn stable_hash(chunks: &[&[u8]]) -> [u8; 32] {
let mut hasher = Sha256::new();
for c in chunks {
hasher.update((c.len() as u64).to_le_bytes());
hasher.update(c);
}
hasher.finalize().into()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::stimulus::StimulusParameters;
#[test]
fn same_inputs_produce_identical_output() {
let sim = ResponseSimulator::new(42);
let person = LatentPerson::from_id("subject-A");
let state = RuViewState::calm_baseline();
let stim = StimulusParameters::prior();
let a = sim.simulate(&person, &state, &stim, 0);
let b = sim.simulate(&person, &state, &stim, 0);
assert_eq!(a, b);
}
#[test]
fn different_session_index_changes_noise() {
let sim = ResponseSimulator::new(42);
let person = LatentPerson::from_id("subject-A");
let state = RuViewState::calm_baseline();
let stim = StimulusParameters::prior();
let a = sim.simulate(&person, &state, &stim, 0);
let b = sim.simulate(&person, &state, &stim, 1);
assert_ne!(a.eeg.gamma_power_gain, b.eeg.gamma_power_gain);
}
#[test]
fn latent_person_is_stable_per_id() {
assert_eq!(
LatentPerson::from_id("subject-A"),
LatentPerson::from_id("subject-A")
);
assert_ne!(
LatentPerson::from_id("subject-A").peak_hz,
LatentPerson::from_id("subject-Z").peak_hz
);
}
#[test]
fn peak_frequency_entrains_better_than_band_edge() {
let sim = ResponseSimulator::new(7);
let person = LatentPerson::from_id("subject-B");
let state = RuViewState::calm_baseline();
let mut at_peak = StimulusParameters::prior();
at_peak.frequency_hz = (person.peak_hz * 10.0).round() / 10.0;
let mut at_edge = StimulusParameters::prior();
at_edge.frequency_hz = 36.0;
let r_peak = sim.simulate(&person, &state, &at_peak, 0);
let r_edge = sim.simulate(&person, &state, &at_edge, 0);
// Only assert when the latent peak is genuinely away from the edge.
if (person.peak_hz - 36.0).abs() > 1.0 {
assert!(r_peak.eeg.gamma_power_gain > r_edge.eeg.gamma_power_gain);
}
}
#[test]
fn calm_state_entrains_better_than_restless() {
let sim = ResponseSimulator::new(3);
let person = LatentPerson::from_id("subject-C");
let stim = StimulusParameters::prior();
let calm = RuViewState::calm_baseline();
let mut restless = RuViewState::calm_baseline();
restless.restlessness_score = 0.9;
restless.stillness_score = 0.2;
restless.sleep_state = SleepState::Active;
let r_calm = sim.simulate(&person, &calm, &stim, 0);
let r_restless = sim.simulate(&person, &restless, &stim, 0);
assert!(r_calm.eeg.gamma_power_gain > r_restless.eeg.gamma_power_gain);
}
}