fix(ruview-gamma): safety becomes a property of construction (safety review, 2 critical + 3 major)

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

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
ruv
2026-06-11 15:26:17 -04:00
parent 246b5ba4af
commit 192b4c6b4a
22 changed files with 1963 additions and 426 deletions
@@ -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 1525 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 1822 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.
+30 -8
View File
@@ -153,11 +153,17 @@ async fn cohort(State(store): State<SharedStore>) -> Json<CohortView> {
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<Cluster> = (0..k).map(|_| Cluster { members: Vec::new() }).collect();
let mut clusters: Vec<Cluster> = (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]
+74 -25
View File
@@ -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<ChainedLine, _> = 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];
+5
View File
@@ -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
+34 -14
View File
@@ -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::<f64>()
/ 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);
+5 -1
View File
@@ -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<Self> {
pub fn new(
envelope: &SafetyEnvelope,
candidates: &[StimulusParameters],
alpha: f64,
) -> Option<Self> {
if candidates.is_empty() {
return None;
}
+152 -14
View File
@@ -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);
}
}
+19 -6
View File
@@ -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::<f64>()
/ 20.0
};
+6 -6
View File
@@ -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
};
+3 -3
View File
@@ -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<f64> {
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]
+410
View File
@@ -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<LockClass> {
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<LockRecord>,
/// Every session timestamp that has run (calibration steps included).
sittings: Vec<u64>,
/// Every unlock acknowledgment, oldest first (audit trail).
unlock_audit: Vec<UnlockRecord>,
}
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<String>, ts_ms: u64) -> Option<LockClass> {
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::<std::collections::BTreeSet<u64>>()
.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<u64> = 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());
}
}
+28 -26
View File
@@ -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]
+1 -1
View File
@@ -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;
+1 -2
View File
@@ -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);
+200 -225
View File
@@ -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<f64>,
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<usize> = 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::<f64>()
/ n as f64
} else {
0.0
};
ClinicianReport {
person_id: self.person_id.clone(),
n_sessions: n,
n_safety_stops: n_stops,
best_frequency_hz: self.optimizer.best().map(|(f, _)| f),
mean_entrainment: mean,
adverse_event_recorded: self.response.adverse_event_flag >= 1.0,
claim: PRODUCT_CLAIM,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn governor() -> RufloGovernor {
RufloGovernor::enroll(
"subject-A",
SafetyEnvelope::conservative(),
&[],
Consent::Granted,
)
.unwrap()
}
#[test]
fn enroll_refuses_without_consent() {
let r = RufloGovernor::enroll(
"x",
SafetyEnvelope::conservative(),
&[],
Consent::Withdrawn,
);
assert_eq!(r.err(), Some(GovernanceError::NoConsent));
}
#[test]
fn enroll_refuses_excluded_condition() {
let r = RufloGovernor::enroll(
"x",
SafetyEnvelope::conservative(),
&[ExclusionCondition::EpilepsyOrSeizureHistory],
Consent::Granted,
);
assert!(matches!(r, Err(GovernanceError::Excluded(_))));
}
#[test]
fn enroll_requires_supervision_for_migraine() {
let r = RufloGovernor::enroll(
"x",
SafetyEnvelope::conservative(),
&[ExclusionCondition::SevereMigraineSensitivity],
Consent::Granted,
);
assert!(matches!(r, Err(GovernanceError::SupervisionRequired(_))));
}
#[test]
fn run_session_refuses_out_of_envelope_stimulus() {
let mut g = governor();
let sim = ResponseSimulator::new(1);
let latent = LatentPerson::from_id("subject-A");
let state = RuViewState::calm_baseline();
let mut bad = StimulusParameters::prior();
bad.frequency_hz = 60.0;
let r = g.run_session(&sim, &latent, &state, &bad, 0);
assert_eq!(r.err(), Some(GovernanceError::OutsideEnvelope));
assert!(g.audit_log().is_empty());
}
#[test]
fn withdrawn_consent_blocks_further_sessions() {
let mut g = governor();
let sim = ResponseSimulator::new(1);
let latent = LatentPerson::from_id("subject-A");
let state = RuViewState::calm_baseline();
g.run_session(&sim, &latent, &state, &StimulusParameters::prior(), 0)
.unwrap();
g.withdraw_consent();
let r = g.run_session(&sim, &latent, &state, &StimulusParameters::prior(), 1);
assert_eq!(r.err(), Some(GovernanceError::NoConsent));
}
#[test]
fn calibration_then_recommendation_lands_near_latent_peak() {
let mut g = governor();
let sim = ResponseSimulator::new(99);
let latent = LatentPerson::from_id("subject-peak");
let state = RuViewState::calm_baseline();
g.run_calibration(&sim, &latent, &state, 5.0, 0).unwrap();
let rec = g.recommend(&StimulusParameters::prior());
assert!(g.envelope.contains(&rec.stimulus));
// Optimizer should prefer a frequency within ±2 Hz of the true peak
// (calibration is short/noisy; ±2 Hz is a robust bound for the test).
assert!((rec.stimulus.frequency_hz - latent.peak_hz).abs() <= 2.0);
}
#[test]
fn every_session_is_witnessed_and_logged() {
let mut g = governor();
let sim = ResponseSimulator::new(5);
let latent = LatentPerson::from_id("subject-A");
let state = RuViewState::calm_baseline();
g.run_calibration(&sim, &latent, &state, 5.0, 0).unwrap();
assert_eq!(g.audit_log().len(), 9); // 36..44 Hz
for rec in g.audit_log() {
assert_eq!(rec.session_hash.len(), 64); // hex SHA-256
}
}
#[test]
fn sham_mode_suppresses_entrainment() {
let latent = LatentPerson::from_id("subject-strong");
let state = RuViewState::calm_baseline();
let sim = ResponseSimulator::new(11);
let mut peak = StimulusParameters::prior();
peak.frequency_hz = (latent.peak_hz * 10.0).round() / 10.0;
peak.frequency_hz = peak.frequency_hz.clamp(36.0, 44.0);
let mut open = governor();
let open_rec = open.run_session(&sim, &latent, &state, &peak, 0).unwrap();
let mut sham = governor();
sham.set_mode(TrialMode::Sham);
let sham_rec = sham.run_session(&sim, &latent, &state, &peak, 0).unwrap();
let open_g = open_rec.eeg_optional.unwrap().gamma_power_gain;
let sham_g = sham_rec.eeg_optional.unwrap().gamma_power_gain;
assert!(sham_g < open_g);
}
#[test]
fn clinician_report_uses_only_allowed_claim() {
let g = governor();
assert_eq!(g.clinician_report().claim, PRODUCT_CLAIM);
assert!(!PRODUCT_CLAIM.to_lowercase().contains("alzheimer"));
assert!(!PRODUCT_CLAIM.to_lowercase().contains("treat"));
}
}
#[path = "ruflo_tests.rs"]
mod tests;
@@ -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<f64>,
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::<f64>()
/ n as f64
} else {
0.0
};
ClinicianReport {
person_id: self.person_id.clone(),
n_sessions: n,
n_safety_stops: n_stops,
best_frequency_hz: self.optimizer.best().map(|(f, _)| f),
mean_entrainment: mean,
adverse_event_recorded: self.response.adverse_event_flag >= 1.0,
locked: self.safety_state.is_locked(),
claim: PRODUCT_CLAIM,
}
}
}
@@ -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,
}
}
}
+338
View File
@@ -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);
}
+11 -6
View File
@@ -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::<f64>() / neighbors.len() as f64;
let mean_dist = neighbors.iter().map(|(_, d)| d).sum::<f64>() / 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]);
}
+92 -5
View File
@@ -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<AdverseEvent>,
/// Sensor confidence at this tick `[0,1]`.
pub sensor_confidence: f64,
}
/// Deterministic response simulator.
#[derive(Debug, Clone)]
pub struct ResponseSimulator {
seed: u64,
/// Optional deterministic fault injection for the per-tick safety path.
fault: Option<FaultSchedule>,
}
impl ResponseSimulator {
pub fn new(seed: u64) -> Self {
Self { seed }
Self { seed, fault: None }
}
/// A simulator that surfaces a scheduled safety fault, for validating the
/// integrated per-tick monitor + terminate-and-lock path (Finding 5).
pub fn with_fault(seed: u64, fault: FaultSchedule) -> Self {
Self {
seed,
fault: Some(fault),
}
}
/// Per-tick safety samples for one session (Finding 5). One tick ≈ one
/// minute of the planned duration (≥ 1). Normal sessions yield clean
/// samples — the aggregate [`simulate`](Self::simulate) cannot produce an
/// adverse event inside the absolute caps — so the witness of a clean
/// session is unchanged; a configured [`FaultSchedule`] surfaces a
/// deterministic event at its tick.
pub fn session_ticks(
&self,
person: &LatentPerson,
state: &RuViewState,
stimulus: &StimulusParameters,
session_index: u64,
) -> Vec<SafetySample> {
let n = (stimulus.duration_minutes.round() as i64).clamp(1, 600) as usize;
// Organic adverse signal from the aggregate physics (kept wired even if
// it cannot fire under the absolute caps): surfaced mid-session.
let agg = self.simulate(person, state, stimulus, session_index);
let organic_tick = n / 2;
(0..n)
.map(|i| {
let mut sample = SafetySample {
adverse: None,
sensor_confidence: state.sensor_confidence,
};
if agg.adverse_event && i == organic_tick {
sample.adverse = Some(AdverseEvent::AbnormalDistress);
}
if let Some(f) = &self.fault {
if f.at_session_index == session_index && f.at_tick == i {
match f.fault {
InjectedFault::Adverse(ev) => sample.adverse = Some(ev),
InjectedFault::LowConfidence(c) => sample.sensor_confidence = c,
}
}
}
sample
})
.collect()
}
/// Simulate a session. `session_index` makes repeated identical protocols
+253 -84
View File
@@ -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
/// 1525 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 3644 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 1525 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<SafetyEnvelope> 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<SafetyEnvelopeWire> for SafetyEnvelope {
type Error = EnvelopeError;
fn try_from(w: SafetyEnvelopeWire) -> Result<Self, Self::Error> {
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<Self, EnvelopeError> {
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, EnvelopeError> {
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, EnvelopeError> {
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, EnvelopeError> {
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;
@@ -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 1525 Hz photosensitive zone.
assert!(e.min_hz() > 25.0);
}
#[test]
fn try_new_rejects_photosensitive_band() {
// An 1822 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<SafetyEnvelope, _> = 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);
}