diff --git a/docs/adr/ADR-250-adaptive-gamma-entrainment.md b/docs/adr/ADR-250-adaptive-gamma-entrainment.md index b117ecb0..1c009a0d 100644 --- a/docs/adr/ADR-250-adaptive-gamma-entrainment.md +++ b/docs/adr/ADR-250-adaptive-gamma-entrainment.md @@ -247,3 +247,51 @@ optional EEG, constrained optimization, and auditable RuFlo workflows. The immediate product claim is **personalized entrainment optimization** — not Alzheimer's treatment. That distinction keeps the system scientifically credible, clinically safer, and commercially defensible. + +## Safety hardening (2026-06-11 review) + +A safety review of the `ruview-gamma` reference crate found five places where +safety depended on *default values* rather than *construction*. The following +invariants now hold by construction; the reference crate enforces and tests each. + +1. **Safety stops are control-flow events (Finding 1).** `run_session` still + returns the witnessed record on a stop, but `run_calibration` (and any + multi-session loop) inspects the record's safety outcome and **terminates the + sweep** on any stop — a stop in step *N* can no longer let steps *N+1…* + proceed. The partial calibration is preserved in the audit log. + +2. **Latched, persisted governor lock (Finding 2, ADR-250 §8 terminate-and-lock).** + An adverse-event / seizure-like / distress stop engages a **latched lock** on + the participant's `ParticipantSafetyState`, which is serialized into the + session store. A *new* governor instance for the same participant also + refuses (`Err(ParticipantLocked)`) until `unlock_with_acknowledgment(operator_note)` + is called, which itself writes an audit record. Lock-class mapping: seizure-like + → `SeizureLike`, abnormal distress → `Distress`, other medical adverse events + → `AdverseEvent`; user-stop / low-sensor-confidence / out-of-envelope stops do + not latch a cross-session lock (retryable / pre-empted, not adverse). + +3. **Compiled-in absolute envelope bounds (Finding 3).** `SafetyEnvelope` fields + are now private, reachable only through a validated `try_new` / `with_*` + constructor and `#[serde(try_from)]`, so no config — however hostile — can be + built outside the absolute bounds: frequency floor **≥ 30 Hz** (keeps the whole + envelope above the 15–25 Hz photosensitive provocative band with margin), + ceiling **≤ 60 Hz**, brightness/volume cap **≤ 0.6**, max session duration + **≤ 30 min**. This mirrors the firmware's compiled-in stance: deserialization + of an 18–22 Hz / brightness-1.0 / 10⁶-minute envelope fails closed. + +4. **Daily-dose cap + inter-session cooldown (Finding 4).** The governor enforces + **≤ 4 sittings per rolling 24 h** and a **≥ 60 min** inter-sitting cooldown from + the persisted ledger; violations return typed errors and are enforced across + governor instances. A calibration sweep delivered at one timestamp is **one + sitting / one dose unit** (sub-sessions do not trip the inter-step cooldown), + and calibration sittings are counted toward the cap — not a backdoor around it. + +5. **Per-tick monitor wired into the session loop (Finding 5).** `run_session` + now evaluates the latched `SafetyMonitor` over **every tick** of the simulated + session; a mid-session latch truncates the session at that tick (the recorded + delivered stimulus duration is reduced to the completed fraction). A clean + session is byte-identical to the prior single-summary path, so the pinned + deterministic proof witness is unchanged. + + *Minor:* `seed_from_cohort` enforces a privacy k-floor (**≥ 3 distinct cohort + profiles**) before consuming cohort priors. diff --git a/v2/crates/ruview-gamma-clinic/src/server.rs b/v2/crates/ruview-gamma-clinic/src/server.rs index fde37cce..32d3382f 100644 --- a/v2/crates/ruview-gamma-clinic/src/server.rs +++ b/v2/crates/ruview-gamma-clinic/src/server.rs @@ -153,11 +153,17 @@ async fn cohort(State(store): State) -> Json { let profiles = s.profiles(); let n = profiles.len(); if n == 0 { - return Json(CohortView { clusters: Vec::new() }); + return Json(CohortView { + clusters: Vec::new(), + }); } let k = 3.min(n); let assign = profiles.cluster(k, 10); - let mut clusters: Vec = (0..k).map(|_| Cluster { members: Vec::new() }).collect(); + let mut clusters: Vec = (0..k) + .map(|_| Cluster { + members: Vec::new(), + }) + .collect(); for (i, &c) in assign.iter().enumerate() { if let Some(p) = profiles.profile(i) { clusters[c].members.push(p.profile_tag.clone()); @@ -192,7 +198,9 @@ mod tests { use tower::ServiceExt; async fn body_json(res: axum::response::Response) -> serde_json::Value { - let bytes = axum::body::to_bytes(res.into_body(), 1 << 20).await.unwrap(); + let bytes = axum::body::to_bytes(res.into_body(), 1 << 20) + .await + .unwrap(); serde_json::from_slice(&bytes).unwrap() } @@ -260,7 +268,9 @@ mod tests { let r = router(seeded_store()); let res = get(&r, "/").await; assert_eq!(res.status(), StatusCode::OK); - let bytes = axum::body::to_bytes(res.into_body(), 1 << 20).await.unwrap(); + let bytes = axum::body::to_bytes(res.into_body(), 1 << 20) + .await + .unwrap(); let html = String::from_utf8(bytes.to_vec()).unwrap(); assert!(html.contains("Clinical Dashboard")); assert!(html.contains("research use only")); @@ -289,7 +299,10 @@ mod tests { #[tokio::test] async fn unknown_participant_is_404() { let r = router(seeded_store()); - assert_eq!(get(&r, "/api/clinic/participants/nobody").await.status(), StatusCode::NOT_FOUND); + assert_eq!( + get(&r, "/api/clinic/participants/nobody").await.status(), + StatusCode::NOT_FOUND + ); } #[tokio::test] @@ -299,11 +312,20 @@ mod tests { let list = v.as_array().unwrap(); assert_eq!(list.len(), 2); // The failed program surfaces NO_CLAIM verbatim — never its raw claim. - let withheld = list.iter().find(|a| a["program_id"] == "home-wellness").unwrap(); + let withheld = list + .iter() + .find(|a| a["program_id"] == "home-wellness") + .unwrap(); assert_eq!(withheld["overall_pass"], false); assert_eq!(withheld["released_claim"], NO_CLAIM); - let passed = list.iter().find(|a| a["program_id"] == "attention-working-memory").unwrap(); - assert_eq!(passed["released_claim"], "personalized frequency-response discovery"); + let passed = list + .iter() + .find(|a| a["program_id"] == "attention-working-memory") + .unwrap(); + assert_eq!( + passed["released_claim"], + "personalized frequency-response discovery" + ); } #[tokio::test] diff --git a/v2/crates/ruview-gamma-clinic/src/store.rs b/v2/crates/ruview-gamma-clinic/src/store.rs index 6a620225..a4d730ce 100644 --- a/v2/crates/ruview-gamma-clinic/src/store.rs +++ b/v2/crates/ruview-gamma-clinic/src/store.rs @@ -192,9 +192,11 @@ impl ClinicStore { fn apply(&mut self, record: ClinicRecord) { match record { ClinicRecord::Profile(p) => self.profiles.upsert(p), - ClinicRecord::Session(s) => { - self.sessions.entry(s.profile_tag.clone()).or_default().push(s) - } + ClinicRecord::Session(s) => self + .sessions + .entry(s.profile_tag.clone()) + .or_default() + .push(s), ClinicRecord::Acceptance(a) => { self.acceptance.insert(a.program_id.clone(), a); } @@ -204,17 +206,29 @@ impl ClinicStore { /// Append a record: chain-hash its exact serialized bytes, write the line, /// update memory. pub fn append(&mut self, record: ClinicRecord) -> Result<(), StoreError> { - let record_json = serde_json::to_string(&record) - .map_err(|e| StoreError::Corrupt { line: 0, reason: e.to_string() })?; + let record_json = serde_json::to_string(&record).map_err(|e| StoreError::Corrupt { + line: 0, + reason: e.to_string(), + })?; let hash = entry_hash(&self.head, &record_json); - let raw = serde_json::value::RawValue::from_string(record_json) - .map_err(|e| StoreError::Corrupt { line: 0, reason: e.to_string() })?; + let raw = serde_json::value::RawValue::from_string(record_json).map_err(|e| { + StoreError::Corrupt { + line: 0, + reason: e.to_string(), + } + })?; let line = serde_json::to_string(&ChainedLine { record: raw, entry_hash: hash.clone(), }) - .map_err(|e| StoreError::Corrupt { line: 0, reason: e.to_string() })?; - let mut f = OpenOptions::new().create(true).append(true).open(&self.path)?; + .map_err(|e| StoreError::Corrupt { + line: 0, + reason: e.to_string(), + })?; + let mut f = OpenOptions::new() + .create(true) + .append(true) + .open(&self.path)?; f.write_all(line.as_bytes())?; f.write_all(b"\n")?; self.head = hash; @@ -227,29 +241,49 @@ impl ClinicStore { let file = match File::open(&self.path) { Ok(f) => f, Err(_) => { - return ChainStatus { valid: true, records: 0, broken_at: None }; + return ChainStatus { + valid: true, + records: 0, + broken_at: None, + }; } }; let mut prev = GENESIS.to_string(); let mut n = 0usize; for (i, line) in BufReader::new(file).lines().enumerate() { let Ok(line) = line else { - return ChainStatus { valid: false, records: n, broken_at: Some(i + 1) }; + return ChainStatus { + valid: false, + records: n, + broken_at: Some(i + 1), + }; }; if line.trim().is_empty() { continue; } let parsed: Result = serde_json::from_str(&line); let Ok(chained) = parsed else { - return ChainStatus { valid: false, records: n, broken_at: Some(i + 1) }; + return ChainStatus { + valid: false, + records: n, + broken_at: Some(i + 1), + }; }; if entry_hash(&prev, chained.record.get()) != chained.entry_hash { - return ChainStatus { valid: false, records: n, broken_at: Some(i + 1) }; + return ChainStatus { + valid: false, + records: n, + broken_at: Some(i + 1), + }; } prev = chained.entry_hash; n += 1; } - ChainStatus { valid: true, records: n, broken_at: None } + ChainStatus { + valid: true, + records: n, + broken_at: None, + } } /// The RuVector layer over loaded profiles (kNN / warm-start / clustering). @@ -329,9 +363,12 @@ mod tests { let (_d, path) = tmp(); { let mut s = ClinicStore::open(&path).unwrap(); - s.append(ClinicRecord::Profile(profile("tag-a", 39.0))).unwrap(); - s.append(ClinicRecord::Session(session("tag-a", 39.0, 0.7))).unwrap(); - s.append(ClinicRecord::Session(session("tag-a", 39.5, 0.75))).unwrap(); + s.append(ClinicRecord::Profile(profile("tag-a", 39.0))) + .unwrap(); + s.append(ClinicRecord::Session(session("tag-a", 39.0, 0.7))) + .unwrap(); + s.append(ClinicRecord::Session(session("tag-a", 39.5, 0.75))) + .unwrap(); s.append(ClinicRecord::Acceptance(AcceptanceSummary { program_id: "sleep-optimization".into(), entrainment_gain: 0.25, @@ -358,8 +395,10 @@ mod tests { let (_d, path) = tmp(); { let mut s = ClinicStore::open(&path).unwrap(); - s.append(ClinicRecord::Session(session("tag-a", 40.0, 0.6))).unwrap(); - s.append(ClinicRecord::Session(session("tag-a", 41.0, 0.7))).unwrap(); + s.append(ClinicRecord::Session(session("tag-a", 40.0, 0.6))) + .unwrap(); + s.append(ClinicRecord::Session(session("tag-a", 41.0, 0.7))) + .unwrap(); } // Doctor the first line's score 0.6 -> 0.9 (a retroactive edit). let text = std::fs::read_to_string(&path).unwrap(); @@ -378,12 +417,20 @@ mod tests { let (_d, path) = tmp(); { let mut s = ClinicStore::open(&path).unwrap(); - s.append(ClinicRecord::Session(session("a", 40.0, 0.5))).unwrap(); - s.append(ClinicRecord::Session(session("a", 41.0, 0.6))).unwrap(); - s.append(ClinicRecord::Session(session("a", 42.0, 0.7))).unwrap(); + s.append(ClinicRecord::Session(session("a", 40.0, 0.5))) + .unwrap(); + s.append(ClinicRecord::Session(session("a", 41.0, 0.6))) + .unwrap(); + s.append(ClinicRecord::Session(session("a", 42.0, 0.7))) + .unwrap(); } let text = std::fs::read_to_string(&path).unwrap(); - let pruned: Vec<&str> = text.lines().enumerate().filter(|(i, _)| *i != 1).map(|(_, l)| l).collect(); + let pruned: Vec<&str> = text + .lines() + .enumerate() + .filter(|(i, _)| *i != 1) + .map(|(_, l)| l) + .collect(); std::fs::write(&path, pruned.join("\n")).unwrap(); assert!(ClinicStore::open(&path).is_err()); } @@ -393,8 +440,10 @@ mod tests { let (_d, path) = tmp(); { let mut s = ClinicStore::open(&path).unwrap(); - s.append(ClinicRecord::Profile(profile("lo", 37.0))).unwrap(); - s.append(ClinicRecord::Profile(profile("hi", 43.0))).unwrap(); + s.append(ClinicRecord::Profile(profile("lo", 37.0))) + .unwrap(); + s.append(ClinicRecord::Profile(profile("hi", 43.0))) + .unwrap(); } let s = ClinicStore::open(&path).unwrap(); let mut q = [0.5; VECTOR_DIM]; diff --git a/v2/crates/ruview-gamma/README.md b/v2/crates/ruview-gamma/README.md index b57084b7..b33b6d91 100644 --- a/v2/crates/ruview-gamma/README.md +++ b/v2/crates/ruview-gamma/README.md @@ -54,11 +54,16 @@ targets: | Test | Target | |------|--------| | LED frequency accuracy | ±0.1 Hz | +| Worst-case frequency error over the session window | ±0.1 Hz | +| Worst-case half-period jitter over the session window | ≤ 500 µs | | Audio-visual sync drift | < 5 ms | | Stop signal → actuator off | < 100 ms | | Session-hash reproducibility | 100% | | EEG entrainment lift vs fixed 40 Hz | ≥ 20% | +All criteria fail closed: NaN measurements, impossible hash counts +(`reproduced > total`), or an empty replay set grade as FAIL. + --- Governed, deterministic, **safety-constrained** personalization of 40 Hz-prior diff --git a/v2/crates/ruview-gamma/src/acceptance.rs b/v2/crates/ruview-gamma/src/acceptance.rs index e505e2b0..deb94866 100644 --- a/v2/crates/ruview-gamma/src/acceptance.rs +++ b/v2/crates/ruview-gamma/src/acceptance.rs @@ -152,15 +152,21 @@ impl AcceptanceHarness { for r in 0..self.criteria.repeats.max(1) { let pid = format!("acc-{}-{}", program.id, r); - let mut gov = match RufloGovernor::enroll_program(&pid, program.clone(), &[], Consent::Granted) - { - Ok(g) => g, - // A program that cannot enroll a clean participant fails closed. - Err(_) => return self.failed_report(program, "enrollment_failed"), - }; + let mut gov = + match RufloGovernor::enroll_program(&pid, program.clone(), &[], Consent::Granted) { + Ok(g) => g, + // A program that cannot enroll a clean participant fails closed. + Err(_) => return self.failed_report(program, "enrollment_failed"), + }; // Vary the noise stream per repeat so repeatability is a real test. - gov.run_calibration(&sim, person, state, program.prior.duration_minutes.min(5.0), r as u64) - .ok(); + gov.run_calibration( + &sim, + person, + state, + program.prior.duration_minutes.min(5.0), + r as u64, + ) + .ok(); for rec in gov.audit_log() { total_sessions += 1; @@ -176,7 +182,11 @@ impl AcceptanceHarness { // Entrainment gain: adaptive recommendation vs the fixed prior. let mean = |stim: &StimulusParameters| -> f64 { (0..16) - .map(|i| sim.simulate(person, state, stim, 10_000 + i).eeg.gamma_power_gain) + .map(|i| { + sim.simulate(person, state, stim, 10_000 + i) + .eeg + .gamma_power_gain + }) .sum::() / 16.0 }; @@ -203,8 +213,12 @@ impl AcceptanceHarness { let safety_pass = safety_stop_rate <= c.max_safety_stop_rate; let adherence_pass = mean_adherence >= c.min_adherence; let repeatability_pass = repeatability_band_hz <= c.max_repeatability_band_hz; - let overall_pass = - claim_allowed(entrainment_pass, safety_pass, adherence_pass, repeatability_pass); + let overall_pass = claim_allowed( + entrainment_pass, + safety_pass, + adherence_pass, + repeatability_pass, + ); AcceptanceReport { program_id: program.id.to_string(), @@ -277,7 +291,10 @@ mod tests { (true, true, true, false), ]; for (e, s, a, r) in one_false { - assert!(!claim_allowed(e, s, a, r), "subset {e}{s}{a}{r} must be denied"); + assert!( + !claim_allowed(e, s, a, r), + "subset {e}{s}{a}{r} must be denied" + ); } assert!(!claim_allowed(false, false, false, false)); } @@ -306,8 +323,11 @@ mod tests { ..Default::default() }, ); - let report = - harness.evaluate(&NeuroProgram::attention_working_memory(), &person, &RuViewState::calm_baseline()); + let report = harness.evaluate( + &NeuroProgram::attention_working_memory(), + &person, + &RuViewState::calm_baseline(), + ); assert!(!report.overall_pass); assert!(!report.entrainment_pass); assert_eq!(report.claim_gate().claim(), NO_CLAIM); diff --git a/v2/crates/ruview-gamma/src/bandit.rs b/v2/crates/ruview-gamma/src/bandit.rs index 88ccf195..8351ac04 100644 --- a/v2/crates/ruview-gamma/src/bandit.rs +++ b/v2/crates/ruview-gamma/src/bandit.rs @@ -109,7 +109,11 @@ 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 { + pub fn new( + envelope: &SafetyEnvelope, + candidates: &[StimulusParameters], + alpha: f64, + ) -> Option { if candidates.is_empty() { return None; } diff --git a/v2/crates/ruview-gamma/src/hil.rs b/v2/crates/ruview-gamma/src/hil.rs index 6b0230ee..7cc1b5a2 100644 --- a/v2/crates/ruview-gamma/src/hil.rs +++ b/v2/crates/ruview-gamma/src/hil.rs @@ -12,10 +12,16 @@ //! | Test | Target | //! |------|--------| //! | LED frequency accuracy | ±0.1 Hz | +//! | Worst-case frequency error over the session window | ±0.1 Hz | +//! | Worst-case half-period jitter over the session window | ≤ 500 µs | //! | Audio-visual sync drift | < 5 ms | //! | Stop signal → actuator off | < 100 ms | //! | Session-hash reproducibility | 100% | //! | EEG entrainment lift vs fixed 40 Hz | ≥ 20% | +//! +//! Every criterion fails closed: a NaN/non-finite measurement, an impossible +//! hash count (`reproduced > total`), or an empty replay set all grade as +//! FAIL, never as "unknown". use serde::{Deserialize, Serialize}; @@ -33,6 +39,11 @@ pub struct HilTargets { pub min_hash_reproducibility: f64, /// Min EEG entrainment lift over fixed 40 Hz, as a fraction. pub min_eeg_lift: f64, + /// Max worst-case half-period jitter over the session window, µs. + /// Grades delivered regularity, not a point estimate: a bench run must + /// report the worst half-period deviation it saw, captured at the sync + /// pin across the whole session. + pub max_half_period_jitter_us: f64, } impl Default for HilTargets { @@ -43,6 +54,9 @@ impl Default for HilTargets { max_stop_latency_ms: 100.0, min_hash_reproducibility: 1.0, min_eeg_lift: 0.20, + // 500 µs is ~4% of the shortest half-period (11.4 ms @ 44 Hz): + // generous for ISR latency, tight enough to catch a wobbling timer. + max_half_period_jitter_us: 500.0, } } } @@ -55,8 +69,15 @@ impl Default for HilTargets { pub struct HilMeasurement { /// Commanded frequency the controller asked for (Hz). pub commanded_frequency_hz: f64, - /// Frequency actually measured at the LED (Hz). + /// Frequency actually measured at the LED (Hz) — session mean. pub measured_frequency_hz: f64, + /// Worst-case |measured − commanded| frequency over the whole session + /// window (Hz). Graded against the same ±0.1 Hz budget as the mean, so + /// the bench measures delivered regularity, not a point estimate. + pub max_abs_freq_error_hz: f64, + /// Worst-case half-period jitter over the session window (µs), measured + /// at the sync pin. + pub max_half_period_jitter_us: f64, /// Measured audio-visual onset skew (ms). pub av_sync_drift_ms: f64, /// Measured stop-assert → actuator-off latency (ms). @@ -75,6 +96,12 @@ pub struct HilMeasurement { pub struct HilReport { pub frequency_error_hz: f64, pub frequency_pass: bool, + /// Worst-case frequency error over the session window (echoed from the + /// measurement) and its verdict against the same ±0.1 Hz budget. + pub worst_frequency_error_hz: f64, + pub worst_frequency_pass: bool, + /// Worst-case half-period jitter verdict (delivered regularity). + pub jitter_pass: bool, pub av_sync_pass: bool, pub stop_latency_pass: bool, pub hash_reproducibility: f64, @@ -86,30 +113,69 @@ pub struct HilReport { } /// Grade a [`HilMeasurement`] against [`HilTargets`]. +/// +/// Fails closed on bad data: any non-finite (NaN/inf) measurement and any +/// impossible hash count (`hashes_reproduced > hashes_total`) grades as FAIL. pub fn verify_hil(m: &HilMeasurement, t: &HilTargets) -> HilReport { + // Session-mean frequency error. A NaN in either operand yields a NaN + // error; the explicit is_finite() makes the fail-closed intent visible + // instead of relying on NaN comparison semantics. let frequency_error_hz = (m.measured_frequency_hz - m.commanded_frequency_hz).abs(); - let frequency_pass = frequency_error_hz <= t.max_frequency_error_hz; - let av_sync_pass = m.av_sync_drift_ms.abs() <= t.max_av_sync_drift_ms; + let frequency_pass = + frequency_error_hz.is_finite() && frequency_error_hz <= t.max_frequency_error_hz; + + // Worst-case-over-window regularity: the same ±0.1 Hz budget must hold + // for the worst excursion, not just the mean. A negative "worst absolute + // error" is self-contradictory bench data ⇒ fail closed. + let worst_frequency_error_hz = m.max_abs_freq_error_hz; + let worst_frequency_pass = worst_frequency_error_hz.is_finite() + && worst_frequency_error_hz >= 0.0 + && worst_frequency_error_hz <= t.max_frequency_error_hz; + + let jitter_pass = m.max_half_period_jitter_us.is_finite() + && m.max_half_period_jitter_us >= 0.0 + && m.max_half_period_jitter_us <= t.max_half_period_jitter_us; + + let av_sync_pass = + m.av_sync_drift_ms.is_finite() && m.av_sync_drift_ms.abs() <= t.max_av_sync_drift_ms; + // Stop latency must be finite and within bound (a missing/NaN measurement // fails closed). let stop_latency_pass = m.stop_latency_ms.is_finite() && m.stop_latency_ms <= t.max_stop_latency_ms; - let hash_reproducibility = if m.hashes_total > 0 { - m.hashes_reproduced as f64 / m.hashes_total as f64 + + // hashes_reproduced > hashes_total is impossible for honest bench data; + // treat it as an invalid measurement, never as >100% reproducibility. + let hash_reproducibility = if m.hashes_total == 0 || m.hashes_reproduced > m.hashes_total { + 0.0 // nothing replayed, or corrupt counts ⇒ unproven ⇒ fail closed } else { - 0.0 // nothing replayed ⇒ unproven ⇒ fail closed + m.hashes_reproduced as f64 / m.hashes_total as f64 }; let hash_pass = hash_reproducibility >= t.min_hash_reproducibility; - let baseline = m.eeg_entrainment_fixed_40hz.max(1e-6); - let eeg_lift = (m.eeg_entrainment_adaptive - baseline) / baseline; - let eeg_lift_pass = eeg_lift >= t.min_eeg_lift; - let overall_pass = - frequency_pass && av_sync_pass && stop_latency_pass && hash_pass && eeg_lift_pass; + let eeg_lift = + if m.eeg_entrainment_adaptive.is_finite() && m.eeg_entrainment_fixed_40hz.is_finite() { + let baseline = m.eeg_entrainment_fixed_40hz.max(1e-6); + (m.eeg_entrainment_adaptive - baseline) / baseline + } else { + f64::NAN // propagate so the verdict below fails closed + }; + let eeg_lift_pass = eeg_lift.is_finite() && eeg_lift >= t.min_eeg_lift; + + let overall_pass = frequency_pass + && worst_frequency_pass + && jitter_pass + && av_sync_pass + && stop_latency_pass + && hash_pass + && eeg_lift_pass; HilReport { frequency_error_hz, frequency_pass, + worst_frequency_error_hz, + worst_frequency_pass, + jitter_pass, av_sync_pass, stop_latency_pass, hash_reproducibility, @@ -127,9 +193,11 @@ mod tests { fn passing() -> HilMeasurement { HilMeasurement { commanded_frequency_hz: 40.0, - measured_frequency_hz: 40.05, // within ±0.1 Hz - av_sync_drift_ms: 2.0, // < 5 ms - stop_latency_ms: 40.0, // < 100 ms + measured_frequency_hz: 40.05, // within ±0.1 Hz (mean) + max_abs_freq_error_hz: 0.08, // worst case also within ±0.1 Hz + max_half_period_jitter_us: 120.0, // < 500 µs + av_sync_drift_ms: 2.0, // < 5 ms + stop_latency_ms: 40.0, // < 100 ms hashes_reproduced: 100, hashes_total: 100, // 100% eeg_entrainment_adaptive: 0.36, @@ -200,4 +268,74 @@ mod tests { m.av_sync_drift_ms = 7.5; assert!(!verify_hil(&m, &HilTargets::default()).av_sync_pass); } + + #[test] + fn worst_case_drift_beyond_budget_fails_even_when_mean_passes() { + let mut m = passing(); + m.max_abs_freq_error_hz = 0.25; // mean is 0.05 Hz, worst case is not + let r = verify_hil(&m, &HilTargets::default()); + assert!(r.frequency_pass); + assert!(!r.worst_frequency_pass); + assert!(!r.overall_pass); + } + + #[test] + fn half_period_jitter_beyond_budget_fails() { + let mut m = passing(); + m.max_half_period_jitter_us = 900.0; + let r = verify_hil(&m, &HilTargets::default()); + assert!(!r.jitter_pass); + assert!(!r.overall_pass); + } + + #[test] + fn nan_measurements_fail_closed_per_criterion() { + let t = HilTargets::default(); + + let mut m = passing(); + m.measured_frequency_hz = f64::NAN; + assert!(!verify_hil(&m, &t).frequency_pass); + + let mut m = passing(); + m.max_abs_freq_error_hz = f64::NAN; + assert!(!verify_hil(&m, &t).worst_frequency_pass); + + let mut m = passing(); + m.max_half_period_jitter_us = f64::NAN; + assert!(!verify_hil(&m, &t).jitter_pass); + + let mut m = passing(); + m.av_sync_drift_ms = f64::NAN; + assert!(!verify_hil(&m, &t).av_sync_pass); + + let mut m = passing(); + m.eeg_entrainment_fixed_40hz = f64::NAN; + assert!(!verify_hil(&m, &t).eeg_lift_pass); + + let mut m = passing(); + m.eeg_entrainment_adaptive = f64::NAN; + assert!(!verify_hil(&m, &t).eeg_lift_pass); + } + + #[test] + fn negative_worst_case_values_fail_closed() { + let t = HilTargets::default(); + let mut m = passing(); + m.max_abs_freq_error_hz = -0.01; // impossible absolute error + assert!(!verify_hil(&m, &t).worst_frequency_pass); + let mut m = passing(); + m.max_half_period_jitter_us = -5.0; + assert!(!verify_hil(&m, &t).jitter_pass); + } + + #[test] + fn impossible_hash_counts_fail_closed() { + let mut m = passing(); + m.hashes_reproduced = 101; // > hashes_total: corrupt bench data + m.hashes_total = 100; + let r = verify_hil(&m, &HilTargets::default()); + assert!(!r.hash_pass); + assert_eq!(r.hash_reproducibility, 0.0); // never reported as >100% + assert!(!r.overall_pass); + } } diff --git a/v2/crates/ruview-gamma/src/lib.rs b/v2/crates/ruview-gamma/src/lib.rs index 62e30f43..225d6d92 100644 --- a/v2/crates/ruview-gamma/src/lib.rs +++ b/v2/crates/ruview-gamma/src/lib.rs @@ -74,6 +74,7 @@ pub mod hil; pub mod math; pub mod objective; pub mod optimizer; +pub mod participant; pub mod program; pub mod proof; pub mod response; @@ -137,9 +138,10 @@ mod integration_tests { let mut store = ProfileStore::new(); for d in 0..3 { let donor_id = format!("{seed_id}-donor-{d}"); - let mut donor = - RufloGovernor::enroll(&donor_id, env, &[], Consent::Granted).unwrap(); - donor.run_calibration(&sim, &latent, &state, 5.0, 0).unwrap(); + let mut donor = RufloGovernor::enroll(&donor_id, env, &[], Consent::Granted).unwrap(); + donor + .run_calibration(&sim, &latent, &state, 5.0, 0) + .unwrap(); store.upsert(donor.export_anonymized_profile()); } @@ -249,7 +251,13 @@ mod integration_tests { let sim = ResponseSimulator::new(77); let latent = LatentPerson::from_id("drift-subject"); let calm = RuViewState::calm_baseline(); - let mut gov = RufloGovernor::enroll("drift-subject", env, &[], Consent::Granted).unwrap(); + // This behavioral test compresses many sessions into a tiny time window + // (timestamps 100..105 ms), which is incompatible with the conservative + // wall-clock dose/cooldown policy; use the simulation policy so the dose + // gate does not pre-empt the drift signal under test. + let mut gov = RufloGovernor::enroll("drift-subject", env, &[], Consent::Granted) + .unwrap() + .with_dose_limits(crate::participant::DoseLimits::permissive_for_simulation()); // Settle in: calibration sweep (9 sessions) → stable. gov.run_calibration(&sim, &latent, &calm, 5.0, 0).unwrap(); @@ -264,7 +272,8 @@ mod integration_tests { let stim = StimulusParameters::prior(); let mut drifted = false; for i in 0..6 { - gov.run_session(&sim, &latent, &collapsed, &stim, 100 + i).unwrap(); + gov.run_session(&sim, &latent, &collapsed, &stim, 100 + i) + .unwrap(); if gov.drift_status() == DriftStatus::Drifted { drifted = true; break; @@ -308,7 +317,11 @@ mod integration_tests { }; let mean = |stim: &StimulusParameters| -> f64 { (0..20) - .map(|i| sim.simulate(&latent, &state, stim, 1000 + i).eeg.gamma_power_gain) + .map(|i| { + sim.simulate(&latent, &state, stim, 1000 + i) + .eeg + .gamma_power_gain + }) .sum::() / 20.0 }; diff --git a/v2/crates/ruview-gamma/src/objective.rs b/v2/crates/ruview-gamma/src/objective.rs index e7c30fd0..8f5a7833 100644 --- a/v2/crates/ruview-gamma/src/objective.rs +++ b/v2/crates/ruview-gamma/src/objective.rs @@ -97,18 +97,18 @@ impl SafeEntrainmentObjective { /// 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 + 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 + 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 + let d = if self.envelope.max_duration_minutes() > 0.0 { + s.duration_minutes / self.envelope.max_duration_minutes() } else { 0.0 }; diff --git a/v2/crates/ruview-gamma/src/optimizer.rs b/v2/crates/ruview-gamma/src/optimizer.rs index 64afc14d..3a4d3450 100644 --- a/v2/crates/ruview-gamma/src/optimizer.rs +++ b/v2/crates/ruview-gamma/src/optimizer.rs @@ -385,8 +385,8 @@ impl ClosedLoopController { /// The 0.1 Hz candidate grid over the envelope (ADR-250 §18 ±0.1 Hz precision). fn fine_grid(envelope: &SafetyEnvelope) -> Vec { - let lo = (envelope.min_hz * 10.0).round() as i64; - let hi = (envelope.max_hz * 10.0).round() as i64; + 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() } @@ -433,7 +433,7 @@ mod tests { } let rec = bo.recommend(&env, &StimulusParameters::prior()); assert!(env.contains(&rec.stimulus)); - assert!(rec.stimulus.frequency_hz <= env.max_hz); + assert!(rec.stimulus.frequency_hz <= env.max_hz()); } #[test] diff --git a/v2/crates/ruview-gamma/src/participant.rs b/v2/crates/ruview-gamma/src/participant.rs new file mode 100644 index 00000000..b397c072 --- /dev/null +++ b/v2/crates/ruview-gamma/src/participant.rs @@ -0,0 +1,410 @@ +//! Cross-session participant safety state — the latched governor lock and the +//! daily-dose / cooldown ledger (Findings 2 & 4, 2026-06-11 safety review). +//! +//! ADR-250 §8 mandates a **hard terminate-and-lock on adverse events**. A +//! [`SafetyMonitor`](crate::safety::SafetyMonitor) latches *within* a session, +//! but a fresh monitor is built per session, so on its own it cannot stop a new +//! session from starting after a seizure-like stop. [`ParticipantSafetyState`] +//! closes that gap: it is the **persisted** safety record for one participant, +//! holding a latched [`LockRecord`] and the session ledger that backs the dose +//! cap and cooldown. Serialize it into the session store and a *new* +//! [`RufloGovernor`](crate::ruflo::RufloGovernor) for the same participant will +//! still refuse until [`ParticipantSafetyState::unlock`] is called with an +//! operator acknowledgment (which itself writes an audit record). +//! +//! ## Lock-class → lock mapping (documented invariant) +//! +//! | Stop reason | Lock? | Class | +//! |-------------|-------|-------| +//! | adverse: seizure-like | **yes** | [`LockClass::SeizureLike`] | +//! | adverse: abnormal distress | **yes** | [`LockClass::Distress`] | +//! | adverse: headache / dizziness / nausea / agitation / visual discomfort | **yes** | [`LockClass::AdverseEvent`] | +//! | adverse: user-stop request | no | — (the participant chose to stop; not a clinical lock) | +//! | sensor confidence below floor | no | — (unverifiable, retryable) | +//! | protocol outside envelope | no | — (pre-empted before delivery) | +//! | completed | no | — | +//! +//! A locked participant is a *fail-closed* state: every lock-warranting stop +//! engages the lock; only an explicit human acknowledgment lifts it. + +use serde::{Deserialize, Serialize}; + +use crate::safety::{AdverseEvent, StopReason}; + +/// The severity class of a latched lock (drives operator messaging and audit). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LockClass { + /// A general adverse event (headache, dizziness, nausea, agitation, visual + /// discomfort). + AdverseEvent, + /// A seizure-like symptom — the most serious class. + SeizureLike, + /// Abnormal distress. + Distress, +} + +impl LockClass { + pub fn tag(self) -> &'static str { + match self { + LockClass::AdverseEvent => "adverse_event", + LockClass::SeizureLike => "seizure_like", + LockClass::Distress => "distress", + } + } +} + +/// The lock class a safety stop warrants, or `None` if the stop does not lock. +/// This is the single source of truth for the table in the module docs. +pub fn lock_class_for(reason: &StopReason) -> Option { + match reason { + StopReason::AdverseEvent(ev) => match ev { + AdverseEvent::SeizureLikeSymptom => Some(LockClass::SeizureLike), + AdverseEvent::AbnormalDistress => Some(LockClass::Distress), + // The participant choosing to stop is not a clinical adverse event + // requiring human acknowledgment to ever resume. + AdverseEvent::UserStopRequest => None, + AdverseEvent::Headache + | AdverseEvent::Dizziness + | AdverseEvent::Nausea + | AdverseEvent::Agitation + | AdverseEvent::VisualDiscomfort => Some(LockClass::AdverseEvent), + }, + // Operational stops fail the session closed but do not latch a + // cross-session lock (they are retryable / pre-empted, not adverse). + StopReason::SensorConfidenceBelowFloor { .. } + | StopReason::ProtocolOutsideEnvelope + | StopReason::Completed => None, + } +} + +/// A short, stable tag for a stop reason (for audit / error messages). +pub fn stop_tag(reason: &StopReason) -> String { + match reason { + StopReason::Completed => "completed".to_string(), + StopReason::AdverseEvent(ev) => format!("adverse:{}", ev.tag()), + StopReason::SensorConfidenceBelowFloor { .. } => { + "sensor_confidence_below_floor".to_string() + } + StopReason::ProtocolOutsideEnvelope => "protocol_outside_envelope".to_string(), + } +} + +/// A latched lock on a participant (ADR-250 §8 terminate-and-lock). Persisted so +/// a new governor instance also honors it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct LockRecord { + pub class: LockClass, + /// The stop reason that engaged the lock (see [`stop_tag`]). + pub reason_tag: String, + /// Caller-supplied epoch milliseconds at which the lock engaged. + pub locked_at_ms: u64, +} + +/// An auditable unlock acknowledgment (ADR-250 §11 audit trail). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct UnlockRecord { + /// The operator's free-text justification (e.g. clinician sign-off). + pub operator_note: String, + /// Caller-supplied epoch milliseconds at which the unlock occurred. + pub unlocked_at_ms: u64, + /// The class that was cleared (for the audit record). + pub cleared_class: LockClass, +} + +/// Daily-dose and cooldown policy (Finding 4). Conservative consts by default; +/// the absolute *envelope* bounds are non-negotiable, but dosing is a wall-clock +/// policy the deterministic core can only enforce against caller-supplied +/// timestamps, so it is configurable (with a conservative default). +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub struct DoseLimits { + /// Maximum distinct **sittings** in any rolling 24 h window. + pub max_sittings_per_24h: usize, + /// Minimum gap between distinct sittings, in minutes. + pub min_inter_sitting_minutes: f64, +} + +impl DoseLimits { + /// Conservative default cap (Finding 4 example): ≤ 4 sittings/day. + pub const MAX_SITTINGS_PER_24H: usize = 4; + /// Conservative default cooldown (Finding 4 example): ≥ 60 min between sittings. + pub const MIN_INTER_SITTING_MINUTES: f64 = 60.0; + /// The rolling-window width in milliseconds (24 h). + pub const ROLLING_WINDOW_MS: u64 = 24 * 60 * 60 * 1000; + + /// The enforced-by-default conservative policy. + pub fn conservative() -> Self { + Self { + max_sittings_per_24h: Self::MAX_SITTINGS_PER_24H, + min_inter_sitting_minutes: Self::MIN_INTER_SITTING_MINUTES, + } + } + + /// Research/simulation policy that disables dose + cooldown. **Not for real + /// deployments** — simulation compresses many sessions into a tiny time + /// window, which is incompatible with wall-clock dosing; the conservative + /// default governs the clinic/hardware path. + pub fn permissive_for_simulation() -> Self { + Self { + max_sittings_per_24h: usize::MAX, + min_inter_sitting_minutes: 0.0, + } + } +} + +impl Default for DoseLimits { + fn default() -> Self { + Self::conservative() + } +} + +/// Why an admission (start-of-session) check refused. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum DoseViolation { + /// Too many distinct sittings within the rolling 24 h window. + DailyLimit { sittings: usize, max: usize }, + /// Not enough time has elapsed since the previous sitting. + Cooldown { + elapsed_minutes: f64, + required_minutes: f64, + }, +} + +/// The persisted per-participant safety state: a latched lock plus the sitting +/// ledger that backs dose and cooldown. `Default` is the clean state. +/// +/// A **sitting** is identified by its `timestamp_ms`. Sub-sessions delivered at +/// the *same* timestamp (a calibration sweep) are one sitting and one dose unit; +/// they never trip the inter-sitting cooldown against each other. Distinct +/// timestamps are distinct sittings, each counted toward the daily cap — so +/// calibration is **not** a backdoor around the dose budget. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct ParticipantSafetyState { + lock: Option, + /// Every session timestamp that has run (calibration steps included). + sittings: Vec, + /// Every unlock acknowledgment, oldest first (audit trail). + unlock_audit: Vec, +} + +impl ParticipantSafetyState { + /// `true` if the participant is latched-locked. + pub fn is_locked(&self) -> bool { + self.lock.is_some() + } + + /// The active lock record, if any. + pub fn lock_record(&self) -> Option<&LockRecord> { + self.lock.as_ref() + } + + /// The unlock-acknowledgment audit trail. + pub fn unlock_audit(&self) -> &[UnlockRecord] { + &self.unlock_audit + } + + /// Engage the lock (idempotent / latched): the *first* lock-warranting stop + /// wins and is never overwritten or downgraded by later events. + pub fn engage_lock(&mut self, class: LockClass, reason_tag: String, ts_ms: u64) { + if self.lock.is_none() { + self.lock = Some(LockRecord { + class, + reason_tag, + locked_at_ms: ts_ms, + }); + } + } + + /// Lift the lock with an explicit operator acknowledgment, writing an audit + /// record. No-op (but still audited as a redundant ack) if already unlocked? + /// — No: we only audit a real unlock. Returns the cleared class, or `None` + /// if there was nothing locked. + pub fn unlock(&mut self, operator_note: impl Into, ts_ms: u64) -> Option { + let cleared = self.lock.take()?; + self.unlock_audit.push(UnlockRecord { + operator_note: operator_note.into(), + unlocked_at_ms: ts_ms, + cleared_class: cleared.class, + }); + Some(cleared.class) + } + + /// Record a session that actually ran (for dose / cooldown accounting). + pub fn record_session(&mut self, ts_ms: u64) { + self.sittings.push(ts_ms); + } + + /// Distinct sittings recorded within the rolling 24 h window ending at `now_ms`. + pub fn sittings_in_window(&self, now_ms: u64) -> usize { + let lo = now_ms.saturating_sub(DoseLimits::ROLLING_WINDOW_MS); + self.sittings + .iter() + .copied() + .filter(|&t| t >= lo && t <= now_ms) + .collect::>() + .len() + } + + /// Admission gate for a session about to start at `now_ms`. Same-timestamp + /// sub-sessions (calibration sweep) pass freely; a *new* sitting must clear + /// both the cooldown and the daily cap. + pub fn check_admission(&self, now_ms: u64, limits: &DoseLimits) -> Result<(), DoseViolation> { + // Cooldown vs the most recent *earlier* sitting (same-timestamp siblings + // do not count — they are the same sitting). + if limits.min_inter_sitting_minutes > 0.0 { + if let Some(prev) = self.sittings.iter().copied().filter(|&t| t < now_ms).max() { + let elapsed_minutes = (now_ms - prev) as f64 / 60_000.0; + if elapsed_minutes < limits.min_inter_sitting_minutes { + return Err(DoseViolation::Cooldown { + elapsed_minutes, + required_minutes: limits.min_inter_sitting_minutes, + }); + } + } + } + // Daily cap: distinct sittings in the window, including this prospective one. + if limits.max_sittings_per_24h != usize::MAX { + let lo = now_ms.saturating_sub(DoseLimits::ROLLING_WINDOW_MS); + let mut distinct: std::collections::BTreeSet = self + .sittings + .iter() + .copied() + .filter(|&t| t >= lo && t <= now_ms) + .collect(); + distinct.insert(now_ms); + if distinct.len() > limits.max_sittings_per_24h { + return Err(DoseViolation::DailyLimit { + sittings: distinct.len(), + max: limits.max_sittings_per_24h, + }); + } + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::safety::AdverseEvent; + + #[test] + fn seizure_and_distress_map_to_their_classes() { + assert_eq!( + lock_class_for(&StopReason::AdverseEvent(AdverseEvent::SeizureLikeSymptom)), + Some(LockClass::SeizureLike) + ); + assert_eq!( + lock_class_for(&StopReason::AdverseEvent(AdverseEvent::AbnormalDistress)), + Some(LockClass::Distress) + ); + assert_eq!( + lock_class_for(&StopReason::AdverseEvent(AdverseEvent::Headache)), + Some(LockClass::AdverseEvent) + ); + } + + #[test] + fn user_stop_and_operational_stops_do_not_lock() { + assert_eq!( + lock_class_for(&StopReason::AdverseEvent(AdverseEvent::UserStopRequest)), + None + ); + assert_eq!( + lock_class_for(&StopReason::SensorConfidenceBelowFloor { + value: 0.1, + floor: 0.5 + }), + None + ); + assert_eq!(lock_class_for(&StopReason::ProtocolOutsideEnvelope), None); + assert_eq!(lock_class_for(&StopReason::Completed), None); + } + + #[test] + fn lock_is_latched_first_wins() { + let mut s = ParticipantSafetyState::default(); + s.engage_lock(LockClass::Distress, "adverse:abnormal_distress".into(), 100); + s.engage_lock(LockClass::AdverseEvent, "adverse:headache".into(), 200); + assert_eq!(s.lock_record().unwrap().class, LockClass::Distress); + assert_eq!(s.lock_record().unwrap().locked_at_ms, 100); + } + + #[test] + fn unlock_clears_and_audits() { + let mut s = ParticipantSafetyState::default(); + s.engage_lock(LockClass::SeizureLike, "adverse:seizure_like".into(), 10); + assert!(s.is_locked()); + let cleared = s.unlock("clinician reviewed; cleared to resume", 999); + assert_eq!(cleared, Some(LockClass::SeizureLike)); + assert!(!s.is_locked()); + assert_eq!(s.unlock_audit().len(), 1); + assert_eq!(s.unlock_audit()[0].cleared_class, LockClass::SeizureLike); + // A redundant unlock is a no-op and is not audited. + assert_eq!(s.unlock("again", 1000), None); + assert_eq!(s.unlock_audit().len(), 1); + } + + #[test] + fn same_timestamp_sweep_is_one_sitting_no_cooldown() { + let mut s = ParticipantSafetyState::default(); + let limits = DoseLimits::conservative(); + let t0 = 1_700_000_000_000u64; + for _ in 0..9 { + assert!(s.check_admission(t0, &limits).is_ok()); + s.record_session(t0); + } + assert_eq!(s.sittings_in_window(t0), 1); + } + + #[test] + fn cooldown_blocks_a_too_soon_second_sitting() { + let mut s = ParticipantSafetyState::default(); + let limits = DoseLimits::conservative(); + let t0 = 0u64; + s.check_admission(t0, &limits).unwrap(); + s.record_session(t0); + // 30 min later — inside the 60 min cooldown. + let t1 = 30 * 60 * 1000; + assert!(matches!( + s.check_admission(t1, &limits), + Err(DoseViolation::Cooldown { .. }) + )); + // 61 min later — clears the cooldown. + let t2 = 61 * 60 * 1000; + assert!(s.check_admission(t2, &limits).is_ok()); + } + + #[test] + fn daily_cap_blocks_the_fifth_sitting() { + let mut s = ParticipantSafetyState::default(); + let limits = DoseLimits::conservative(); + let hour = 60 * 60 * 1000u64; + // Four sittings, each > 60 min apart, all within 24 h. + for i in 0..4u64 { + let t = i * 2 * hour; + s.check_admission(t, &limits).unwrap(); + s.record_session(t); + } + // A fifth within the same 24 h window is over the cap. + let t5 = 9 * hour; + assert!(matches!( + s.check_admission(t5, &limits), + Err(DoseViolation::DailyLimit { + sittings: 5, + max: 4 + }) + )); + } + + #[test] + fn state_roundtrips_through_json() { + let mut s = ParticipantSafetyState::default(); + s.engage_lock(LockClass::Distress, "adverse:abnormal_distress".into(), 7); + s.record_session(7); + let json = serde_json::to_string(&s).unwrap(); + let back: ParticipantSafetyState = serde_json::from_str(&json).unwrap(); + assert_eq!(s, back); + assert!(back.is_locked()); + } +} diff --git a/v2/crates/ruview-gamma/src/program.rs b/v2/crates/ruview-gamma/src/program.rs index b737bf38..00ba86d2 100644 --- a/v2/crates/ruview-gamma/src/program.rs +++ b/v2/crates/ruview-gamma/src/program.rs @@ -133,12 +133,10 @@ impl NeuroProgram { display_name: "Post-Stroke Cognition (recovery state tracking)", evidence_level: EvidenceLevel::EarlyHuman, claim: "personalized entrainment optimization with recovery-state monitoring", - envelope: SafetyEnvelope { - brightness_cap: 0.32, - volume_cap: 0.32, - max_duration_minutes: 12.0, - ..SafetyEnvelope::conservative() - }, + envelope: SafetyEnvelope::conservative() + .with_caps(0.32, 0.32) + .and_then(|e| e.with_max_duration_minutes(12.0)) + .expect("post-stroke envelope is within the absolute bounds"), prior, weights, eligible_states: &[SleepState::QuietWake, SleepState::Drowsy], @@ -167,15 +165,17 @@ impl NeuroProgram { display_name: "Sleep Optimization (state-timed gamma)", evidence_level: EvidenceLevel::EarlyHuman, claim: "sleep-state-timed entrainment optimization", - envelope: SafetyEnvelope { - brightness_cap: 0.10, // near-dark - volume_cap: 0.25, - max_duration_minutes: 30.0, - ..SafetyEnvelope::conservative() - }, + envelope: SafetyEnvelope::conservative() + .with_caps(0.10, 0.25) // near-dark + .and_then(|e| e.with_max_duration_minutes(30.0)) + .expect("sleep envelope is within the absolute bounds"), prior, weights, - eligible_states: &[SleepState::Drowsy, SleepState::Asleep, SleepState::QuietWake], + eligible_states: &[ + SleepState::Drowsy, + SleepState::Asleep, + SleepState::QuietWake, + ], time_preference: TimePreference::PreSleepOrSleep, } } @@ -219,11 +219,9 @@ impl NeuroProgram { display_name: "Mood & Arousal Regulation (calming-response tuning)", evidence_level: EvidenceLevel::EarlyHuman, claim: "personalized calming-response optimization", - envelope: SafetyEnvelope { - brightness_cap: 0.30, - volume_cap: 0.30, - ..SafetyEnvelope::conservative() - }, + envelope: SafetyEnvelope::conservative() + .with_caps(0.30, 0.30) + .expect("mood-arousal envelope is within the absolute bounds"), prior, weights, eligible_states: &[SleepState::QuietWake, SleepState::Drowsy], @@ -244,12 +242,10 @@ impl NeuroProgram { display_name: "Home Neuro-Wellness (no treatment claim)", evidence_level: EvidenceLevel::Speculative, claim: "personal neural-rhythm wellness optimization", - envelope: SafetyEnvelope { - brightness_cap: 0.28, - volume_cap: 0.28, - max_duration_minutes: 10.0, - ..SafetyEnvelope::conservative() - }, + envelope: SafetyEnvelope::conservative() + .with_caps(0.28, 0.28) + .and_then(|e| e.with_max_duration_minutes(10.0)) + .expect("home-wellness envelope is within the absolute bounds"), prior, weights: ObjectiveWeights::default(), eligible_states: &[SleepState::QuietWake], @@ -306,7 +302,13 @@ mod tests { #[test] fn no_program_claim_is_a_disease_treatment_claim() { - let banned = ["treat", "cure", "alzheimer", "stroke recovery cure", "therapy for"]; + let banned = [ + "treat", + "cure", + "alzheimer", + "stroke recovery cure", + "therapy for", + ]; for p in NeuroProgram::catalog() { let claim = p.claim.to_lowercase(); for b in banned { @@ -349,7 +351,7 @@ mod tests { #[test] fn sleep_program_caps_brightness_near_dark() { - assert!(NeuroProgram::sleep_optimization().envelope.brightness_cap <= 0.10); + assert!(NeuroProgram::sleep_optimization().envelope.brightness_cap() <= 0.10); } #[test] diff --git a/v2/crates/ruview-gamma/src/proof.rs b/v2/crates/ruview-gamma/src/proof.rs index 4d7b52d0..04e3ae87 100644 --- a/v2/crates/ruview-gamma/src/proof.rs +++ b/v2/crates/ruview-gamma/src/proof.rs @@ -8,8 +8,8 @@ //! 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::ruflo::{Consent, RufloGovernor}; use crate::simulator::{stable_hash, LatentPerson, ResponseSimulator}; use crate::stimulus::SafetyEnvelope; diff --git a/v2/crates/ruview-gamma/src/response.rs b/v2/crates/ruview-gamma/src/response.rs index 061d01db..49b51113 100644 --- a/v2/crates/ruview-gamma/src/response.rs +++ b/v2/crates/ruview-gamma/src/response.rs @@ -221,8 +221,7 @@ impl PersonResponseVector { 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.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); diff --git a/v2/crates/ruview-gamma/src/ruflo.rs b/v2/crates/ruview-gamma/src/ruflo.rs index 25de9b86..63337154 100644 --- a/v2/crates/ruview-gamma/src/ruflo.rs +++ b/v2/crates/ruview-gamma/src/ruflo.rs @@ -9,9 +9,14 @@ use crate::objective::{SafeEntrainmentObjective, ScoreInputs}; use crate::optimizer::{BayesianOptimizer, CalibrationPlan, Recommendation}; +use crate::participant::{ + lock_class_for, stop_tag, DoseLimits, DoseViolation, LockClass, ParticipantSafetyState, +}; use crate::program::NeuroProgram; -use crate::response::{PersonResponseVector, RuViewState, SessionObservation, SleepState, SubjectiveReport}; -use crate::ruvector::{AnonymizedProfile, DriftDetector, DriftStatus, ProfileStore}; +use crate::response::{ + PersonResponseVector, RuViewState, SessionObservation, SleepState, SubjectiveReport, +}; +use crate::ruvector::{DriftDetector, DriftStatus}; use crate::safety::{ ExclusionCondition, ExclusionScreen, SafetyMonitor, SafetyTick, ScreenOutcome, StopReason, }; @@ -51,19 +56,39 @@ pub enum GovernanceError { NoConsent, #[error("requested stimulus is outside the approved safety envelope")] OutsideEnvelope, + /// Finding 2: the participant is latched-locked after an adverse-event / + /// distress / seizure-like stop (ADR-250 §8 terminate-and-lock). Only + /// [`RufloGovernor::unlock_with_acknowledgment`] clears it. + #[error( + "participant is locked ({class:?} — {reason}); operator acknowledgment required to resume" + )] + ParticipantLocked { class: LockClass, reason: String }, + /// Finding 4: the rolling-24h daily-dose cap is exhausted. + #[error("daily dose limit reached: {sittings} sittings in 24h exceeds the cap of {max}")] + DailyDoseLimit { sittings: usize, max: usize }, + /// Finding 4: the minimum inter-session cooldown has not yet elapsed. + #[error("inter-session cooldown active: {elapsed_minutes:.1} min elapsed, {required_minutes:.1} min required")] + CooldownActive { + elapsed_minutes: f64, + required_minutes: f64, + }, + /// [`RufloGovernor::unlock_with_acknowledgment`] was called but the + /// participant was not locked. + #[error("participant is not locked")] + NotLocked, } -/// Clinician-facing export summary (ADR-250 §11 responsibility 9, §17). -#[derive(Debug, Clone, PartialEq)] -pub struct ClinicianReport { - pub person_id: String, - pub n_sessions: usize, - pub n_safety_stops: usize, - pub best_frequency_hz: Option, - pub mean_entrainment: f64, - pub adverse_event_recorded: bool, - pub claim: &'static str, -} +// Clinician export (`ClinicianReport` + `clinician_report`) lives in the child +// module `report` (split out to keep this file under 500 lines); it is a +// descendant module, so it retains access to the governor's private fields. +#[path = "ruflo_report.rs"] +mod report; +pub use report::ClinicianReport; + +// RuVector cohort bridge (`seed_from_cohort`, `export_anonymized_profile`), +// split out to keep this file under 500 lines; descendant module → private access. +#[path = "ruflo_ruvector.rs"] +mod ruvector_bridge; /// The governed adaptive-gamma protocol runner for one participant. pub struct RufloGovernor { @@ -81,6 +106,11 @@ pub struct RufloGovernor { // ADR-250 §10 item 4: per-person drift detection over the response vector. drift: DriftDetector, drift_status: DriftStatus, + // Finding 2 & 4: persisted cross-session safety state (latched lock + the + // dose/cooldown ledger). Serialize it into the session store so a NEW + // governor for the same participant honors the lock and the dose history. + safety_state: ParticipantSafetyState, + dose_limits: DoseLimits, // Platform extension: the program this participant is enrolled under // (envelope/prior/objective/state-gating/claim). `None` for the bare // `enroll` path (Alzheimer's defaults), which keeps the pinned witness. @@ -120,10 +150,18 @@ impl RufloGovernor { next_index: 0, drift: DriftDetector::default(), drift_status: DriftStatus::Warmup, + safety_state: ParticipantSafetyState::default(), + dose_limits: DoseLimits::conservative(), program: None, }) } + /// Minimum number of distinct cohort profiles required before + /// [`seed_from_cohort`](Self::seed_from_cohort) will consume their priors — a + /// privacy k-floor so a single (or pair of) donor profile(s) cannot shape a + /// new participant's optimizer. + pub const MIN_COHORT_PROFILES: usize = 3; + /// Enroll a participant under a [`NeuroProgram`] (the platform path): the /// program supplies the safety envelope, starting prior, and objective /// weighting for this use case. Same fail-closed consent/exclusion gate as @@ -165,43 +203,9 @@ impl RufloGovernor { .unwrap_or(true) } - /// Seed the optimizer from a cohort of anonymized similar responders - /// (ADR-250 §10 item 3): the `k` nearest profiles' frequency responses - /// enter as **down-weighted pseudo-observations**, shaping where the - /// optimizer looks first without ever counting as this person's measured - /// data ([`BayesianOptimizer::observe_prior`]). Returns how many priors - /// were installed. - pub fn seed_from_cohort(&mut self, store: &ProfileStore, k: usize) -> usize { - let query = self.response.as_array(); - let priors = - store.warm_start_prior(&query, k, self.optimizer.noise_var); - for p in &priors { - // Only frequencies inside this participant's envelope are usable. - if p.frequency_hz >= self.envelope.min_hz && p.frequency_hz <= self.envelope.max_hz { - self.optimizer - .observe_prior(p.frequency_hz, p.expected_score, p.noise_var); - } - } - priors.len() - } - - /// Export this participant as an anonymized profile for the cohort store - /// (ADR-250 §10 items 3/6). Carries the one-way hashed tag, the response - /// vector, and per-frequency scores from **safe sessions only** — never - /// the `person_id`, never raw sensor data. - pub fn export_anonymized_profile(&self) -> AnonymizedProfile { - let frequency_scores: Vec<(f64, f64)> = self - .audit - .iter() - .filter(|r| r.outcome.safety_pass) - .map(|r| (r.stimulus.frequency_hz, r.outcome.entrainment_score)) - .collect(); - AnonymizedProfile { - profile_tag: AnonymizedProfile::tag_for(&self.person_id), - vector: self.response.as_array(), - frequency_scores, - } - } + // `seed_from_cohort` + `export_anonymized_profile` (the RuVector cohort + // bridge, ADR-250 §10) live in the child module `ruvector_bridge` to keep + // this file under 500 lines; it retains private-field access. /// Latest drift judgment (ADR-250 §10 item 4). `Drifted` recommends /// re-running the Phase-1 calibration sweep before trusting further @@ -210,6 +214,57 @@ impl RufloGovernor { self.drift_status } + /// The persisted per-participant safety state (latched lock + dose ledger, + /// Finding 2 & 4). Serialize it into the session store; reload it into a new + /// governor with [`with_safety_state`](Self::with_safety_state) and the lock + /// and dose history are honored across instances. + pub fn safety_state(&self) -> &ParticipantSafetyState { + &self.safety_state + } + + /// This participant's safety envelope. + pub fn envelope(&self) -> &SafetyEnvelope { + &self.envelope + } + + /// Whether the participant is currently latched-locked. + pub fn is_locked(&self) -> bool { + self.safety_state.is_locked() + } + + /// Load a previously-persisted safety state onto a fresh governor (e.g. for a + /// returning participant). A locked state makes this governor refuse to run + /// sessions until [`unlock_with_acknowledgment`](Self::unlock_with_acknowledgment). + pub fn with_safety_state(mut self, state: ParticipantSafetyState) -> Self { + self.safety_state = state; + self + } + + /// Override the dose/cooldown policy (Finding 4). Defaults to + /// [`DoseLimits::conservative`]; real deployments must keep a conservative + /// policy — the permissive variant exists only for time-compressed + /// simulation. + pub fn with_dose_limits(mut self, limits: DoseLimits) -> Self { + self.dose_limits = limits; + self + } + + /// Lift a latched lock with an explicit operator acknowledgment, writing an + /// audit record into the persisted safety state (ADR-250 §8 / §11, Finding 2). + /// + /// # Errors + /// [`GovernanceError::NotLocked`] if the participant was not locked. + pub fn unlock_with_acknowledgment( + &mut self, + operator_note: &str, + timestamp_ms: u64, + ) -> Result<(), GovernanceError> { + match self.safety_state.unlock(operator_note, timestamp_ms) { + Some(_) => Ok(()), + None => Err(GovernanceError::NotLocked), + } + } + /// Switch trial mode (e.g., to `Sham` for a blinded arm). pub fn set_mode(&mut self, mode: TrialMode) { self.mode = mode; @@ -242,7 +297,16 @@ impl RufloGovernor { ) -> 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)?; + // Finding 1: a safety stop is a control-flow event. `run_session` + // still returns the (witnessed) record on a stop, so we must inspect + // its safety outcome and TERMINATE the sweep — a stop in step N must + // not let steps N+1.. proceed. The partial calibration stays in the + // audit log, and any lock-warranting stop has already engaged the + // governor lock (ADR-250 §8 terminate-and-lock). + let record = self.run_session(sim, latent, state, &stim, base_timestamp_ms)?; + if !record.outcome.safety_pass { + break; + } } Ok(()) } @@ -267,14 +331,46 @@ impl RufloGovernor { if self.consent != Consent::Granted { return Err(GovernanceError::NoConsent); } + // Finding 2: a locked participant refuses every session (fail closed) + // until an explicit operator acknowledgment lifts the lock. This holds + // across governor instances because the lock lives in `safety_state`, + // which is serialized into the session store. + if let Some(lock) = self.safety_state.lock_record() { + return Err(GovernanceError::ParticipantLocked { + class: lock.class, + reason: lock.reason_tag.clone(), + }); + } if !self.envelope.contains(stimulus) { return Err(GovernanceError::OutsideEnvelope); } + // Finding 4: daily-dose cap + inter-sitting cooldown. Same-timestamp + // calibration sub-sessions are one sitting and pass freely; a new + // sitting must clear both the cooldown and the daily cap. + if let Err(v) = self + .safety_state + .check_admission(timestamp_ms, &self.dose_limits) + { + return Err(match v { + DoseViolation::DailyLimit { sittings, max } => { + GovernanceError::DailyDoseLimit { sittings, max } + } + DoseViolation::Cooldown { + elapsed_minutes, + required_minutes, + } => GovernanceError::CooldownActive { + elapsed_minutes, + required_minutes, + }, + }); + } let idx = self.next_index; self.next_index += 1; + // Commit this sitting to the dose ledger (calibration steps included). + self.safety_state.record_session(timestamp_ms); - // --- Observe (simulated) response. --- + // --- Observe (simulated) aggregate response (the scoring source). --- let mut resp = sim.simulate(latent, state, stimulus, idx); if self.mode == TrialMode::Sham { // Blinding: no entrainment is actually delivered. @@ -282,22 +378,56 @@ impl RufloGovernor { resp.eeg.phase_locking_value *= 0.05; } - // --- Safety monitor over the (single-summary) tick. --- + // --- Finding 5: per-tick safety monitor over the WHOLE session. Every + // tick is evaluated and the monitor latches; a mid-session latch + // truncates the session at that tick. A clean session produces no + // events (byte-identical witness to the prior single-summary path, + // since for a clean run the only tick signal is the unchanged sensor + // confidence). This makes the <500 ms stop-latency contract apply to + // the integrated path, at least in simulation. let mut monitor = SafetyMonitor::new(self.confidence_floor); + let ticks = sim.session_ticks(latent, state, stimulus, idx); + let n_ticks = ticks.len().max(1); 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 mut stop_tick: Option = None; + for (i, t) in ticks.iter().enumerate() { + if let Some(stop) = monitor.evaluate(SafetyTick { + adverse: t.adverse, + sensor_confidence: t.sensor_confidence, + stimulus_in_envelope: true, + }) { + safety_events.push(stop); + stop_tick = Some(i); + break; + } } let safety_pass = !safety_events.iter().any(StopReason::is_safety_stop); + let adverse_event = resp.adverse_event + || safety_events + .iter() + .any(|s| matches!(s, StopReason::AdverseEvent(_))); + + // The stimulus *as delivered*: a mid-session latch truncates the + // duration to the completed fraction (Finding 5). For a clean session + // this equals the planned stimulus, so the witness is unchanged. + let recorded_stimulus = if let Some(t) = stop_tick { + let fraction = (t + 1) as f64 / n_ticks as f64; + let mut truncated = *stimulus; + truncated.duration_minutes = stimulus.duration_minutes * fraction; + self.envelope.clamp(truncated) + } else { + *stimulus + }; + + // Finding 2: terminate-and-lock — engage the latched lock if the stop + // warrants it (persisted, so a new governor for this participant also + // refuses until acknowledged). + if let Some(stop) = safety_events.iter().find(|s| s.is_safety_stop()) { + if let Some(class) = lock_class_for(stop) { + self.safety_state + .engage_lock(class, stop_tag(stop), timestamp_ms); + } + } // --- Score the session. --- let subjective = SubjectiveReport { @@ -309,7 +439,7 @@ impl RufloGovernor { ruview: &resp.ruview, eeg: Some(&resp.eeg), subjective: &subjective, - adverse_event_risk: if resp.adverse_event { 1.0 } else { 0.0 }, + adverse_event_risk: if adverse_event { 1.0 } else { 0.0 }, }); // --- Feed the optimizer only when the session was safe. --- @@ -319,12 +449,12 @@ impl RufloGovernor { // --- Update RuVector memory + drift detection (ADR-250 §10 item 4). --- self.response.update(&SessionObservation { - stimulus: *stimulus, + stimulus: recorded_stimulus, ruview: resp.ruview, eeg: Some(resp.eeg), subjective, safety_pass, - adverse_event: resp.adverse_event, + adverse_event, }); self.drift_status = self.drift.update(&self.response.as_array()); @@ -336,7 +466,7 @@ impl RufloGovernor { self.person_id.clone(), self.versions.clone(), timestamp_ms, - *stimulus, + recorded_stimulus, resp.ruview, subjective, Outcome { @@ -352,163 +482,8 @@ impl RufloGovernor { self.audit.push(record.clone()); Ok(record) } - - /// Build the clinician export (ADR-250 §11 responsibility 9). - pub fn clinician_report(&self) -> ClinicianReport { - let n = self.audit.len(); - let n_stops = self - .audit - .iter() - .flat_map(|r| &r.safety_events) - .filter(|e| e.is_safety_stop()) - .count(); - let mean = if n > 0 { - self.audit - .iter() - .map(|r| r.outcome.entrainment_score) - .sum::() - / n as f64 - } else { - 0.0 - }; - ClinicianReport { - person_id: self.person_id.clone(), - n_sessions: n, - n_safety_stops: n_stops, - best_frequency_hz: self.optimizer.best().map(|(f, _)| f), - mean_entrainment: mean, - adverse_event_recorded: self.response.adverse_event_flag >= 1.0, - claim: PRODUCT_CLAIM, - } - } } #[cfg(test)] -mod tests { - use super::*; - - fn governor() -> RufloGovernor { - RufloGovernor::enroll( - "subject-A", - SafetyEnvelope::conservative(), - &[], - Consent::Granted, - ) - .unwrap() - } - - #[test] - fn enroll_refuses_without_consent() { - let r = RufloGovernor::enroll( - "x", - SafetyEnvelope::conservative(), - &[], - Consent::Withdrawn, - ); - assert_eq!(r.err(), Some(GovernanceError::NoConsent)); - } - - #[test] - fn enroll_refuses_excluded_condition() { - let r = RufloGovernor::enroll( - "x", - SafetyEnvelope::conservative(), - &[ExclusionCondition::EpilepsyOrSeizureHistory], - Consent::Granted, - ); - assert!(matches!(r, Err(GovernanceError::Excluded(_)))); - } - - #[test] - fn enroll_requires_supervision_for_migraine() { - let r = RufloGovernor::enroll( - "x", - SafetyEnvelope::conservative(), - &[ExclusionCondition::SevereMigraineSensitivity], - Consent::Granted, - ); - assert!(matches!(r, Err(GovernanceError::SupervisionRequired(_)))); - } - - #[test] - fn run_session_refuses_out_of_envelope_stimulus() { - let mut g = governor(); - let sim = ResponseSimulator::new(1); - let latent = LatentPerson::from_id("subject-A"); - let state = RuViewState::calm_baseline(); - let mut bad = StimulusParameters::prior(); - bad.frequency_hz = 60.0; - let r = g.run_session(&sim, &latent, &state, &bad, 0); - assert_eq!(r.err(), Some(GovernanceError::OutsideEnvelope)); - assert!(g.audit_log().is_empty()); - } - - #[test] - fn withdrawn_consent_blocks_further_sessions() { - let mut g = governor(); - let sim = ResponseSimulator::new(1); - let latent = LatentPerson::from_id("subject-A"); - let state = RuViewState::calm_baseline(); - g.run_session(&sim, &latent, &state, &StimulusParameters::prior(), 0) - .unwrap(); - g.withdraw_consent(); - let r = g.run_session(&sim, &latent, &state, &StimulusParameters::prior(), 1); - assert_eq!(r.err(), Some(GovernanceError::NoConsent)); - } - - #[test] - fn calibration_then_recommendation_lands_near_latent_peak() { - let mut g = governor(); - let sim = ResponseSimulator::new(99); - let latent = LatentPerson::from_id("subject-peak"); - let state = RuViewState::calm_baseline(); - g.run_calibration(&sim, &latent, &state, 5.0, 0).unwrap(); - let rec = g.recommend(&StimulusParameters::prior()); - assert!(g.envelope.contains(&rec.stimulus)); - // Optimizer should prefer a frequency within ±2 Hz of the true peak - // (calibration is short/noisy; ±2 Hz is a robust bound for the test). - assert!((rec.stimulus.frequency_hz - latent.peak_hz).abs() <= 2.0); - } - - #[test] - fn every_session_is_witnessed_and_logged() { - let mut g = governor(); - let sim = ResponseSimulator::new(5); - let latent = LatentPerson::from_id("subject-A"); - let state = RuViewState::calm_baseline(); - g.run_calibration(&sim, &latent, &state, 5.0, 0).unwrap(); - assert_eq!(g.audit_log().len(), 9); // 36..44 Hz - for rec in g.audit_log() { - assert_eq!(rec.session_hash.len(), 64); // hex SHA-256 - } - } - - #[test] - fn sham_mode_suppresses_entrainment() { - let latent = LatentPerson::from_id("subject-strong"); - let state = RuViewState::calm_baseline(); - let sim = ResponseSimulator::new(11); - let mut peak = StimulusParameters::prior(); - peak.frequency_hz = (latent.peak_hz * 10.0).round() / 10.0; - peak.frequency_hz = peak.frequency_hz.clamp(36.0, 44.0); - - let mut open = governor(); - let open_rec = open.run_session(&sim, &latent, &state, &peak, 0).unwrap(); - - let mut sham = governor(); - sham.set_mode(TrialMode::Sham); - let sham_rec = sham.run_session(&sim, &latent, &state, &peak, 0).unwrap(); - - let open_g = open_rec.eeg_optional.unwrap().gamma_power_gain; - let sham_g = sham_rec.eeg_optional.unwrap().gamma_power_gain; - assert!(sham_g < open_g); - } - - #[test] - fn clinician_report_uses_only_allowed_claim() { - let g = governor(); - assert_eq!(g.clinician_report().claim, PRODUCT_CLAIM); - assert!(!PRODUCT_CLAIM.to_lowercase().contains("alzheimer")); - assert!(!PRODUCT_CLAIM.to_lowercase().contains("treat")); - } -} +#[path = "ruflo_tests.rs"] +mod tests; diff --git a/v2/crates/ruview-gamma/src/ruflo_report.rs b/v2/crates/ruview-gamma/src/ruflo_report.rs new file mode 100644 index 00000000..a0fda23b --- /dev/null +++ b/v2/crates/ruview-gamma/src/ruflo_report.rs @@ -0,0 +1,52 @@ +//! Clinician export for [`RufloGovernor`] (ADR-250 §11 responsibility 9, §17). +//! +//! Split out of `ruflo.rs` to keep that file under 500 lines. This is a child +//! module of `ruflo`, so it retains access to the governor's private fields. + +use super::{RufloGovernor, PRODUCT_CLAIM}; + +/// Clinician-facing export summary (ADR-250 §11 responsibility 9, §17). +#[derive(Debug, Clone, PartialEq)] +pub struct ClinicianReport { + pub person_id: String, + pub n_sessions: usize, + pub n_safety_stops: usize, + pub best_frequency_hz: Option, + pub mean_entrainment: f64, + pub adverse_event_recorded: bool, + /// Whether the participant is currently latched-locked (Finding 2). + pub locked: bool, + pub claim: &'static str, +} + +impl RufloGovernor { + /// Build the clinician export (ADR-250 §11 responsibility 9). + pub fn clinician_report(&self) -> ClinicianReport { + let n = self.audit.len(); + let n_stops = self + .audit + .iter() + .flat_map(|r| &r.safety_events) + .filter(|e| e.is_safety_stop()) + .count(); + let mean = if n > 0 { + self.audit + .iter() + .map(|r| r.outcome.entrainment_score) + .sum::() + / n as f64 + } else { + 0.0 + }; + ClinicianReport { + person_id: self.person_id.clone(), + n_sessions: n, + n_safety_stops: n_stops, + best_frequency_hz: self.optimizer.best().map(|(f, _)| f), + mean_entrainment: mean, + adverse_event_recorded: self.response.adverse_event_flag >= 1.0, + locked: self.safety_state.is_locked(), + claim: PRODUCT_CLAIM, + } + } +} diff --git a/v2/crates/ruview-gamma/src/ruflo_ruvector.rs b/v2/crates/ruview-gamma/src/ruflo_ruvector.rs new file mode 100644 index 00000000..c8ad3215 --- /dev/null +++ b/v2/crates/ruview-gamma/src/ruflo_ruvector.rs @@ -0,0 +1,52 @@ +//! RuVector cohort bridge for [`RufloGovernor`] (ADR-250 §10 items 3/6). +//! +//! Split out of `ruflo.rs` to keep it under 500 lines. This is a child module +//! of `ruflo`, so it retains access to the governor's private fields. + +use super::RufloGovernor; +use crate::ruvector::{AnonymizedProfile, ProfileStore}; + +impl RufloGovernor { + /// Seed the optimizer from a cohort of anonymized similar responders + /// (ADR-250 §10 item 3): the `k` nearest profiles' frequency responses enter + /// as **down-weighted pseudo-observations**, shaping where the optimizer + /// looks first without ever counting as this person's measured data. Returns + /// how many priors were installed. + /// + /// Honors the privacy k-floor [`RufloGovernor::MIN_COHORT_PROFILES`]: a + /// cohort smaller than that yields no priors at all. + pub fn seed_from_cohort(&mut self, store: &ProfileStore, k: usize) -> usize { + if store.len() < Self::MIN_COHORT_PROFILES { + return 0; + } + let query = self.response.as_array(); + let priors = store.warm_start_prior(&query, k, self.optimizer.noise_var); + for p in &priors { + // Only frequencies inside this participant's envelope are usable. + if p.frequency_hz >= self.envelope.min_hz() && p.frequency_hz <= self.envelope.max_hz() + { + self.optimizer + .observe_prior(p.frequency_hz, p.expected_score, p.noise_var); + } + } + priors.len() + } + + /// Export this participant as an anonymized profile for the cohort store + /// (ADR-250 §10 items 3/6). Carries the one-way hashed tag, the response + /// vector, and per-frequency scores from **safe sessions only** — never the + /// `person_id`, never raw sensor data. + pub fn export_anonymized_profile(&self) -> AnonymizedProfile { + let frequency_scores: Vec<(f64, f64)> = self + .audit + .iter() + .filter(|r| r.outcome.safety_pass) + .map(|r| (r.stimulus.frequency_hz, r.outcome.entrainment_score)) + .collect(); + AnonymizedProfile { + profile_tag: AnonymizedProfile::tag_for(&self.person_id), + vector: self.response.as_array(), + frequency_scores, + } + } +} diff --git a/v2/crates/ruview-gamma/src/ruflo_tests.rs b/v2/crates/ruview-gamma/src/ruflo_tests.rs new file mode 100644 index 00000000..4bb14b50 --- /dev/null +++ b/v2/crates/ruview-gamma/src/ruflo_tests.rs @@ -0,0 +1,338 @@ +//! Tests for the RuFlo governor. Split out of `ruflo.rs` (kept under 500 lines); +//! this is a child module of `ruflo`, so it retains access to private fields. + +use super::*; +use crate::safety::AdverseEvent; +use crate::simulator::{FaultSchedule, InjectedFault}; + +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")); +} + +// ==================================================================== +// Safety-hardening adversarial tests (2026-06-11 review). +// ==================================================================== + +/// Finding 1: a safety stop is a control-flow event — the sweep halts at the +/// stopping step rather than running every remaining frequency. +#[test] +fn sweep_halts_mid_calibration_on_safety_stop() { + let env = SafetyEnvelope::conservative(); + let latent = LatentPerson::from_id("subject-A"); + let state = RuViewState::calm_baseline(); + // Low sensor confidence at calibration step 3 (tick 0) → confidence stop. + // (A confidence stop is retryable and does NOT lock the participant.) + let sim = ResponseSimulator::with_fault( + 1, + FaultSchedule { + at_session_index: 3, + at_tick: 0, + fault: InjectedFault::LowConfidence(0.1), + }, + ); + let mut g = RufloGovernor::enroll("subject-A", env, &[], Consent::Granted).unwrap(); + g.run_calibration(&sim, &latent, &state, 5.0, 0).unwrap(); + // Steps 0,1,2 passed, step 3 stopped → 4 records (not the full 9-step sweep). + assert_eq!(g.audit_log().len(), 4); + assert!(!g.audit_log().last().unwrap().outcome.safety_pass); + assert!(!g.is_locked()); +} + +/// Findings 1 & 2: an adverse-event stop terminates the session AND latches the +/// governor lock; a fresh governor loaded with the persisted state still +/// refuses until an explicit operator acknowledgment (which is itself audited). +#[test] +fn adverse_event_terminates_and_locks_across_instances_until_acknowledged() { + let env = SafetyEnvelope::conservative(); + let latent = LatentPerson::from_id("subject-A"); + let state = RuViewState::calm_baseline(); + let stim = StimulusParameters::prior(); // 10 min → 10 ticks + let sim = ResponseSimulator::with_fault( + 1, + FaultSchedule { + at_session_index: 0, + at_tick: 3, + fault: InjectedFault::Adverse(AdverseEvent::SeizureLikeSymptom), + }, + ); + let mut g = RufloGovernor::enroll("subject-A", env, &[], Consent::Granted).unwrap(); + // The session still returns its witnessed record (Finding 1), marked stopped. + let rec = g.run_session(&sim, &latent, &state, &stim, 0).unwrap(); + assert!(!rec.outcome.safety_pass); + // …and the governor is now latched-locked (Finding 2 terminate-and-lock). + assert!(g.is_locked()); + assert_eq!( + g.safety_state().lock_record().unwrap().class, + LockClass::SeizureLike + ); + + // Same governor refuses to start any further session. + let clean = ResponseSimulator::new(2); + let err = g + .run_session(&clean, &latent, &state, &stim, 4_000_000) + .unwrap_err(); + assert!(matches!(err, GovernanceError::ParticipantLocked { .. })); + + // Persistence: a NEW governor loaded with the serialized state also refuses. + let persisted = g.safety_state().clone(); + let mut g2 = RufloGovernor::enroll("subject-A", env, &[], Consent::Granted) + .unwrap() + .with_safety_state(persisted); + assert!(matches!( + g2.run_session(&clean, &latent, &state, &stim, 4_000_000), + Err(GovernanceError::ParticipantLocked { .. }) + )); + + // Unlock with acknowledgment writes an audit record and lifts the lock. + assert!(matches!( + g2.unlock_with_acknowledgment("not yet locked?", 0), + Ok(()) + )); + assert!(!g2.is_locked()); + assert_eq!(g2.safety_state().unlock_audit().len(), 1); + assert_eq!( + g2.safety_state().unlock_audit()[0].cleared_class, + LockClass::SeizureLike + ); + // A redundant unlock is refused (nothing locked). + assert!(matches!( + g2.unlock_with_acknowledgment("again", 1), + Err(GovernanceError::NotLocked) + )); + // Now a clean session runs (far enough out to clear the cooldown). + assert!(g2 + .run_session(&clean, &latent, &state, &stim, 4_000_000) + .is_ok()); +} + +/// Finding 4: inter-session cooldown blocks a too-soon second sitting. +#[test] +fn cooldown_blocks_a_too_soon_session() { + let env = SafetyEnvelope::conservative(); + let latent = LatentPerson::from_id("subject-A"); + let state = RuViewState::calm_baseline(); + let stim = StimulusParameters::prior(); + let sim = ResponseSimulator::new(1); + let mut g = RufloGovernor::enroll("subject-A", env, &[], Consent::Granted).unwrap(); + g.run_session(&sim, &latent, &state, &stim, 0).unwrap(); + // 30 min later — inside the 60 min cooldown. + let err = g + .run_session(&sim, &latent, &state, &stim, 30 * 60 * 1000) + .unwrap_err(); + assert!(matches!(err, GovernanceError::CooldownActive { .. })); +} + +/// Finding 4: the daily-dose cap is enforced across governor instances via the +/// persisted ledger; calibration sittings count toward the budget. +#[test] +fn daily_dose_cap_enforced_across_governor_instances() { + let env = SafetyEnvelope::conservative(); + let latent = LatentPerson::from_id("subject-A"); + let state = RuViewState::calm_baseline(); + let stim = StimulusParameters::prior(); + let sim = ResponseSimulator::new(1); + let hour = 60 * 60 * 1000u64; + let mut g = RufloGovernor::enroll("subject-A", env, &[], Consent::Granted).unwrap(); + // Four sittings, each 2 h apart (clears cooldown), all within 24 h. + for i in 0..4u64 { + g.run_session(&sim, &latent, &state, &stim, i * 2 * hour) + .unwrap(); + } + // Persist + reload into a NEW governor instance. + let persisted = g.safety_state().clone(); + let mut g2 = RufloGovernor::enroll("subject-A", env, &[], Consent::Granted) + .unwrap() + .with_safety_state(persisted); + // The fifth sitting within 24 h is refused by the reloaded instance. + let err = g2 + .run_session(&sim, &latent, &state, &stim, 9 * hour) + .unwrap_err(); + assert!(matches!( + err, + GovernanceError::DailyDoseLimit { + sittings: 5, + max: 4 + } + )); +} + +/// A calibration sweep delivered at a single timestamp is ONE dose unit, so it +/// is not a backdoor around the daily cap (Finding 4 documented invariant). +#[test] +fn calibration_sweep_is_one_dose_sitting() { + let env = SafetyEnvelope::conservative(); + let latent = LatentPerson::from_id("subject-A"); + let state = RuViewState::calm_baseline(); + let sim = ResponseSimulator::new(5); + let mut g = RufloGovernor::enroll("subject-A", env, &[], Consent::Granted).unwrap(); + g.run_calibration(&sim, &latent, &state, 5.0, 1_700_000_000_000) + .unwrap(); + assert_eq!(g.audit_log().len(), 9); + assert_eq!(g.safety_state().sittings_in_window(1_700_000_000_000), 1); +} + +/// Finding 5: a per-tick latch mid-session truncates the recorded (delivered) +/// stimulus to the completed fraction. +#[test] +fn mid_session_tick_latch_truncates_the_session() { + let env = SafetyEnvelope::conservative(); + let latent = LatentPerson::from_id("subject-A"); + let state = RuViewState::calm_baseline(); + let stim = StimulusParameters::prior(); // 10 min → 10 ticks + // Adverse at tick 2 → delivered fraction (2+1)/10 = 0.3 → 3.0 min. + let sim = ResponseSimulator::with_fault( + 9, + FaultSchedule { + at_session_index: 0, + at_tick: 2, + fault: InjectedFault::Adverse(AdverseEvent::Headache), + }, + ); + let mut g = RufloGovernor::enroll("subject-A", env, &[], Consent::Granted).unwrap(); + let rec = g.run_session(&sim, &latent, &state, &stim, 0).unwrap(); + assert!(!rec.outcome.safety_pass); + assert!((rec.stimulus.duration_minutes - 3.0).abs() < 1e-9); + assert!(rec.stimulus.duration_minutes < stim.duration_minutes); +} + +/// Minor: `seed_from_cohort` honors the privacy k-floor (≥ 3 distinct profiles). +#[test] +fn seed_from_cohort_respects_min_cohort_floor() { + use crate::ruvector::{AnonymizedProfile, ProfileStore, VECTOR_DIM}; + let env = SafetyEnvelope::conservative(); + let mk = |tag: &str| { + let mut vector = [0.5; VECTOR_DIM]; + vector[5] = 13.0; // breathing_rate in range + vector[11] = 39.0; // stimulus_frequency + AnonymizedProfile { + profile_tag: tag.into(), + vector, + frequency_scores: vec![(39.0, 0.8)], + } + }; + let mut store = ProfileStore::new(); + store.upsert(mk("a")); + store.upsert(mk("b")); + let mut g = RufloGovernor::enroll("subject-A", env, &[], Consent::Granted).unwrap(); + // Below the floor → no priors consumed. + assert_eq!(g.seed_from_cohort(&store, 2), 0); + // At/above the floor → priors may be consumed. + store.upsert(mk("c")); + assert!(g.seed_from_cohort(&store, 3) > 0); +} diff --git a/v2/crates/ruview-gamma/src/ruvector.rs b/v2/crates/ruview-gamma/src/ruvector.rs index 615a6d80..4a76a8be 100644 --- a/v2/crates/ruview-gamma/src/ruvector.rs +++ b/v2/crates/ruview-gamma/src/ruvector.rs @@ -157,7 +157,11 @@ impl ProfileStore { .enumerate() .map(|(i, p)| (i, unit_distance(&q, &normalize(&p.vector)))) .collect(); - d.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal).then(a.0.cmp(&b.0))); + d.sort_by(|a, b| { + a.1.partial_cmp(&b.1) + .unwrap_or(std::cmp::Ordering::Equal) + .then(a.0.cmp(&b.0)) + }); d.truncate(k); d } @@ -199,8 +203,7 @@ impl ProfileStore { buckets.entry(q).or_default().push((score, w)); } } - let mean_dist = - neighbors.iter().map(|(_, d)| d).sum::() / neighbors.len() as f64; + let mean_dist = neighbors.iter().map(|(_, d)| d).sum::() / neighbors.len() as f64; buckets .into_iter() .map(|(q, entries)| { @@ -215,8 +218,7 @@ impl ProfileStore { frequency_hz: q as f64 / 10.0, expected_score: mean, // Floor × base, inflated by cohort disagreement and distance. - noise_var: base_noise * Self::PRIOR_NOISE_FLOOR * (1.0 + mean_dist) - + var, + noise_var: base_noise * Self::PRIOR_NOISE_FLOOR * (1.0 + mean_dist) + var, } }) .collect() @@ -252,7 +254,10 @@ impl ProfileStore { .fold(f64::INFINITY, f64::min); (i, dmin) }) - .fold((0usize, -1.0f64), |acc, (i, d)| if d > acc.1 { (i, d) } else { acc }); + .fold( + (0usize, -1.0f64), + |acc, (i, d)| if d > acc.1 { (i, d) } else { acc }, + ); centers.push(pts[far_idx]); } diff --git a/v2/crates/ruview-gamma/src/simulator.rs b/v2/crates/ruview-gamma/src/simulator.rs index 72b2afd3..7bf2b392 100644 --- a/v2/crates/ruview-gamma/src/simulator.rs +++ b/v2/crates/ruview-gamma/src/simulator.rs @@ -15,6 +15,7 @@ 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; @@ -43,11 +44,11 @@ impl LatentPerson { // 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 + 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 + noise: 0.02 + u(4) * 0.08, // 0.02..0.10 } } } @@ -65,15 +66,101 @@ pub struct SimulatedResponse { 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, + /// 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, } impl ResponseSimulator { pub fn new(seed: u64) -> Self { - Self { seed } + 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 { + 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 diff --git a/v2/crates/ruview-gamma/src/stimulus.rs b/v2/crates/ruview-gamma/src/stimulus.rs index f0bbfcf5..874b3002 100644 --- a/v2/crates/ruview-gamma/src/stimulus.rs +++ b/v2/crates/ruview-gamma/src/stimulus.rs @@ -108,36 +108,262 @@ pub enum EnvelopeViolation { NonFinite { field: &'static str }, } +/// Compiled-in **absolute** bounds — the floor under every envelope (Finding 3, +/// 2026-06-11 safety review). No `SafetyEnvelope` can be constructed (by code, +/// builder, or deserialization) that violates these. They make safety a +/// property of *construction*, not of default values, mirroring the firmware's +/// compiled-in stance. +pub mod absolute { + /// Hard frequency floor (Hz). Chosen **>= 30 Hz** so the entire envelope — + /// including its lowest edge — sits above the photosensitive/provocative + /// 15–25 Hz flicker band with margin. A config can never push stimulation + /// into that band. + pub const MIN_HZ: f64 = 30.0; + /// Hard frequency ceiling (Hz). Above the 36–44 Hz search band with room + /// for future programs, bounded so the actuator is never driven absurdly fast. + pub const MAX_HZ: f64 = 60.0; + /// Hard brightness cap — no envelope may uncap flicker brightness toward 1.0. + pub const BRIGHTNESS_CAP: f64 = 0.6; + /// Hard volume cap — comfort ceiling. + pub const VOLUME_CAP: f64 = 0.6; + /// Hard maximum single-session duration (minutes) — no `1e6`-minute sessions. + pub const MAX_DURATION_MINUTES: f64 = 30.0; + /// Hard maximum absolute inter-modality phase offset (ms). + pub const MAX_PHASE_OFFSET_MS: f64 = 20.0; +} + +/// Why an attempted [`SafetyEnvelope`] construction was rejected. Every variant +/// is a *safe* refusal: a rejected envelope is never built, so deserialization +/// of a hostile config fails closed instead of silently widening the bounds. +#[derive(Debug, Clone, Copy, PartialEq, thiserror::Error)] +pub enum EnvelopeError { + /// A field was NaN/Inf. + #[error("envelope field `{field}` is not finite")] + NonFinite { field: &'static str }, + /// `min_hz` below the compiled-in photosensitive-safe floor. + #[error("min_hz {min_hz} is below the absolute floor {floor} Hz (photosensitive-band guard)")] + FrequencyFloor { min_hz: f64, floor: f64 }, + /// `max_hz` above the compiled-in ceiling. + #[error("max_hz {max_hz} exceeds the absolute ceiling {ceiling} Hz")] + FrequencyCeiling { max_hz: f64, ceiling: f64 }, + /// Band is empty or inverted. + #[error("frequency band is empty/inverted: min_hz {min_hz} >= max_hz {max_hz}")] + BandInverted { min_hz: f64, max_hz: f64 }, + /// Brightness cap out of `[0, ABS]`. + #[error("brightness_cap {cap} outside [0, {abs}]")] + BrightnessCap { cap: f64, abs: f64 }, + /// Volume cap out of `[0, ABS]`. + #[error("volume_cap {cap} outside [0, {abs}]")] + VolumeCap { cap: f64, abs: f64 }, + /// Duration out of `(0, ABS]`. + #[error("max_duration_minutes {minutes} outside (0, {abs}]")] + Duration { minutes: f64, abs: f64 }, + /// Phase offset cap out of `[0, ABS]`. + #[error("max_phase_offset_ms {ms} outside [0, {abs}]")] + PhaseOffset { ms: f64, abs: f64 }, +} + /// 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. +/// +/// **Safety by construction (Finding 3, 2026-06-11 review):** the bound fields +/// are private and reachable only through [`SafetyEnvelope::try_new`] (and the +/// `with_*` builders), which reject anything outside the compiled-in +/// [`absolute`] bounds. Deserialization routes through the same validator via +/// `#[serde(try_from)]`, so no config — however hostile — can place the band in +/// the 15–25 Hz photosensitive zone, uncap brightness/volume toward 1.0, or set +/// a million-minute session. #[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +#[serde(into = "SafetyEnvelopeWire", try_from = "SafetyEnvelopeWire")] 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, + min_hz: f64, + max_hz: f64, + brightness_cap: f64, + volume_cap: f64, + max_duration_minutes: f64, + max_phase_offset_ms: f64, +} + +/// Transparent serde mirror of [`SafetyEnvelope`]. Deserialization always passes +/// through [`SafetyEnvelope::try_new`] (`try_from`), so the absolute bounds +/// cannot be bypassed; serialization is a plain projection (`into`). +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +struct SafetyEnvelopeWire { + min_hz: f64, + max_hz: f64, + brightness_cap: f64, + volume_cap: f64, + max_duration_minutes: f64, + max_phase_offset_ms: f64, +} + +impl From for SafetyEnvelopeWire { + fn from(e: SafetyEnvelope) -> Self { + Self { + min_hz: e.min_hz, + max_hz: e.max_hz, + brightness_cap: e.brightness_cap, + volume_cap: e.volume_cap, + max_duration_minutes: e.max_duration_minutes, + max_phase_offset_ms: e.max_phase_offset_ms, + } + } +} + +impl TryFrom for SafetyEnvelope { + type Error = EnvelopeError; + fn try_from(w: SafetyEnvelopeWire) -> Result { + Self::try_new( + w.min_hz, + w.max_hz, + w.brightness_cap, + w.volume_cap, + w.max_duration_minutes, + w.max_phase_offset_ms, + ) + } } 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, + /// Construct an envelope, enforcing the compiled-in [`absolute`] bounds. The + /// **only** constructor; `conservative()` and the `with_*` builders all route + /// through it, and so does deserialization. Returns [`EnvelopeError`] (a safe + /// refusal) for any out-of-bounds request. + pub fn try_new( + min_hz: f64, + max_hz: f64, + brightness_cap: f64, + volume_cap: f64, + max_duration_minutes: f64, + max_phase_offset_ms: f64, + ) -> Result { + for (field, v) in [ + ("min_hz", min_hz), + ("max_hz", max_hz), + ("brightness_cap", brightness_cap), + ("volume_cap", volume_cap), + ("max_duration_minutes", max_duration_minutes), + ("max_phase_offset_ms", max_phase_offset_ms), + ] { + if !v.is_finite() { + return Err(EnvelopeError::NonFinite { field }); + } } + if min_hz < absolute::MIN_HZ { + return Err(EnvelopeError::FrequencyFloor { + min_hz, + floor: absolute::MIN_HZ, + }); + } + if max_hz > absolute::MAX_HZ { + return Err(EnvelopeError::FrequencyCeiling { + max_hz, + ceiling: absolute::MAX_HZ, + }); + } + if min_hz >= max_hz { + return Err(EnvelopeError::BandInverted { min_hz, max_hz }); + } + if !(0.0..=absolute::BRIGHTNESS_CAP).contains(&brightness_cap) { + return Err(EnvelopeError::BrightnessCap { + cap: brightness_cap, + abs: absolute::BRIGHTNESS_CAP, + }); + } + if !(0.0..=absolute::VOLUME_CAP).contains(&volume_cap) { + return Err(EnvelopeError::VolumeCap { + cap: volume_cap, + abs: absolute::VOLUME_CAP, + }); + } + if max_duration_minutes <= 0.0 || max_duration_minutes > absolute::MAX_DURATION_MINUTES { + return Err(EnvelopeError::Duration { + minutes: max_duration_minutes, + abs: absolute::MAX_DURATION_MINUTES, + }); + } + if !(0.0..=absolute::MAX_PHASE_OFFSET_MS).contains(&max_phase_offset_ms) { + return Err(EnvelopeError::PhaseOffset { + ms: max_phase_offset_ms, + abs: absolute::MAX_PHASE_OFFSET_MS, + }); + } + Ok(Self { + min_hz, + max_hz, + brightness_cap, + volume_cap, + max_duration_minutes, + max_phase_offset_ms, + }) + } + + /// Lower band edge (Hz). Always `>= absolute::MIN_HZ`. + pub fn min_hz(&self) -> f64 { + self.min_hz + } + /// Upper band edge (Hz). Always `<= absolute::MAX_HZ`. + pub fn max_hz(&self) -> f64 { + self.max_hz + } + /// Hard brightness cap. Always `<= absolute::BRIGHTNESS_CAP`. + pub fn brightness_cap(&self) -> f64 { + self.brightness_cap + } + /// Hard volume cap. Always `<= absolute::VOLUME_CAP`. + pub fn volume_cap(&self) -> f64 { + self.volume_cap + } + /// Maximum session duration (minutes). Always `<= absolute::MAX_DURATION_MINUTES`. + pub fn max_duration_minutes(&self) -> f64 { + self.max_duration_minutes + } + /// Maximum absolute inter-modality phase offset (ms). + pub fn max_phase_offset_ms(&self) -> f64 { + self.max_phase_offset_ms + } + + /// Re-band an existing envelope (re-validated against the absolute bounds). + pub fn with_band(self, min_hz: f64, max_hz: f64) -> Result { + Self::try_new( + min_hz, + max_hz, + self.brightness_cap, + self.volume_cap, + self.max_duration_minutes, + self.max_phase_offset_ms, + ) + } + /// Override the intensity caps (re-validated against the absolute bounds). + pub fn with_caps(self, brightness_cap: f64, volume_cap: f64) -> Result { + Self::try_new( + self.min_hz, + self.max_hz, + brightness_cap, + volume_cap, + self.max_duration_minutes, + self.max_phase_offset_ms, + ) + } + /// Override the maximum session duration (re-validated against the absolute bounds). + pub fn with_max_duration_minutes(self, minutes: f64) -> Result { + Self::try_new( + self.min_hz, + self.max_hz, + self.brightness_cap, + self.volume_cap, + minutes, + self.max_phase_offset_ms, + ) + } + + /// Conservative research-grade default envelope (ADR-250 §5 default ranges, + /// "safety_profile: conservative"). Well inside every [`absolute`] bound. + pub fn conservative() -> Self { + Self::try_new(36.0, 44.0, 0.40, 0.40, 15.0, 5.0) + .expect("conservative envelope is within the compiled-in absolute bounds") } /// `true` iff every field of `s` lies inside the envelope and is finite. @@ -203,8 +429,11 @@ impl SafetyEnvelope { 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); + 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 @@ -220,65 +449,5 @@ impl SafetyEnvelope { } #[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); - } -} +#[path = "stimulus_tests.rs"] +mod tests; diff --git a/v2/crates/ruview-gamma/src/stimulus_tests.rs b/v2/crates/ruview-gamma/src/stimulus_tests.rs new file mode 100644 index 00000000..1f7f01ef --- /dev/null +++ b/v2/crates/ruview-gamma/src/stimulus_tests.rs @@ -0,0 +1,149 @@ +//! Unit tests for `stimulus` — split out to keep stimulus.rs under 500 lines. +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); +} + +// ---- Finding 3: absolute bounds are a property of construction ---- + +#[test] +fn conservative_is_within_absolute_bounds() { + let e = SafetyEnvelope::conservative(); + assert!(e.min_hz() >= absolute::MIN_HZ); + assert!(e.max_hz() <= absolute::MAX_HZ); + assert!(e.brightness_cap() <= absolute::BRIGHTNESS_CAP); + assert!(e.volume_cap() <= absolute::VOLUME_CAP); + assert!(e.max_duration_minutes() <= absolute::MAX_DURATION_MINUTES); + // The whole conservative band clears the 15–25 Hz photosensitive zone. + assert!(e.min_hz() > 25.0); +} + +#[test] +fn try_new_rejects_photosensitive_band() { + // An 18–22 Hz envelope is squarely inside the provocative band. + let err = SafetyEnvelope::try_new(18.0, 22.0, 0.3, 0.3, 10.0, 0.0).unwrap_err(); + assert!(matches!(err, EnvelopeError::FrequencyFloor { .. })); +} + +#[test] +fn try_new_rejects_uncapped_brightness_and_volume() { + assert!(matches!( + SafetyEnvelope::try_new(36.0, 44.0, 1.0, 0.3, 10.0, 0.0).unwrap_err(), + EnvelopeError::BrightnessCap { .. } + )); + assert!(matches!( + SafetyEnvelope::try_new(36.0, 44.0, 0.3, 1.0, 10.0, 0.0).unwrap_err(), + EnvelopeError::VolumeCap { .. } + )); +} + +#[test] +fn try_new_rejects_million_minute_session() { + assert!(matches!( + SafetyEnvelope::try_new(36.0, 44.0, 0.3, 0.3, 1e6, 0.0).unwrap_err(), + EnvelopeError::Duration { .. } + )); +} + +#[test] +fn try_new_rejects_ceiling_and_inverted_band() { + assert!(matches!( + SafetyEnvelope::try_new(36.0, 80.0, 0.3, 0.3, 10.0, 0.0).unwrap_err(), + EnvelopeError::FrequencyCeiling { .. } + )); + assert!(matches!( + SafetyEnvelope::try_new(44.0, 36.0, 0.3, 0.3, 10.0, 0.0).unwrap_err(), + EnvelopeError::BandInverted { .. } + )); +} + +#[test] +fn deserialization_cannot_build_an_18_22hz_envelope() { + // Hostile config places the band in the photosensitive zone — serde must + // route through try_new and FAIL, not silently accept it. + let hostile = r#"{ + "min_hz": 18.0, "max_hz": 22.0, + "brightness_cap": 1.0, "volume_cap": 1.0, + "max_duration_minutes": 1000000.0, "max_phase_offset_ms": 0.0 + }"#; + let parsed: Result = serde_json::from_str(hostile); + assert!( + parsed.is_err(), + "deserialization must reject a photosensitive-band envelope" + ); +} + +#[test] +fn valid_envelope_roundtrips_through_serde() { + let e = SafetyEnvelope::conservative(); + let json = serde_json::to_string(&e).unwrap(); + let back: SafetyEnvelope = serde_json::from_str(&json).unwrap(); + assert_eq!(e, back); +} + +#[test] +fn with_builders_revalidate_against_absolute_bounds() { + let e = SafetyEnvelope::conservative(); + // Legal override succeeds. + assert!(e.with_caps(0.5, 0.5).is_ok()); + // Illegal override (above the brightness ceiling) is refused. + assert!(e.with_caps(0.9, 0.3).is_err()); + // Re-banding into the photosensitive zone is refused. + assert!(e.with_band(18.0, 22.0).is_err()); +} + +#[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); +}