mirror of
https://github.com/ruvnet/RuView
synced 2026-07-05 14:33:19 +00:00
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:
@@ -247,3 +247,51 @@ optional EEG, constrained optimization, and auditable RuFlo workflows. The
|
||||
immediate product claim is **personalized entrainment optimization** — not
|
||||
Alzheimer's treatment. That distinction keeps the system scientifically
|
||||
credible, clinically safer, and commercially defensible.
|
||||
|
||||
## Safety hardening (2026-06-11 review)
|
||||
|
||||
A safety review of the `ruview-gamma` reference crate found five places where
|
||||
safety depended on *default values* rather than *construction*. The following
|
||||
invariants now hold by construction; the reference crate enforces and tests each.
|
||||
|
||||
1. **Safety stops are control-flow events (Finding 1).** `run_session` still
|
||||
returns the witnessed record on a stop, but `run_calibration` (and any
|
||||
multi-session loop) inspects the record's safety outcome and **terminates the
|
||||
sweep** on any stop — a stop in step *N* can no longer let steps *N+1…*
|
||||
proceed. The partial calibration is preserved in the audit log.
|
||||
|
||||
2. **Latched, persisted governor lock (Finding 2, ADR-250 §8 terminate-and-lock).**
|
||||
An adverse-event / seizure-like / distress stop engages a **latched lock** on
|
||||
the participant's `ParticipantSafetyState`, which is serialized into the
|
||||
session store. A *new* governor instance for the same participant also
|
||||
refuses (`Err(ParticipantLocked)`) until `unlock_with_acknowledgment(operator_note)`
|
||||
is called, which itself writes an audit record. Lock-class mapping: seizure-like
|
||||
→ `SeizureLike`, abnormal distress → `Distress`, other medical adverse events
|
||||
→ `AdverseEvent`; user-stop / low-sensor-confidence / out-of-envelope stops do
|
||||
not latch a cross-session lock (retryable / pre-empted, not adverse).
|
||||
|
||||
3. **Compiled-in absolute envelope bounds (Finding 3).** `SafetyEnvelope` fields
|
||||
are now private, reachable only through a validated `try_new` / `with_*`
|
||||
constructor and `#[serde(try_from)]`, so no config — however hostile — can be
|
||||
built outside the absolute bounds: frequency floor **≥ 30 Hz** (keeps the whole
|
||||
envelope above the 15–25 Hz photosensitive provocative band with margin),
|
||||
ceiling **≤ 60 Hz**, brightness/volume cap **≤ 0.6**, max session duration
|
||||
**≤ 30 min**. This mirrors the firmware's compiled-in stance: deserialization
|
||||
of an 18–22 Hz / brightness-1.0 / 10⁶-minute envelope fails closed.
|
||||
|
||||
4. **Daily-dose cap + inter-session cooldown (Finding 4).** The governor enforces
|
||||
**≤ 4 sittings per rolling 24 h** and a **≥ 60 min** inter-sitting cooldown from
|
||||
the persisted ledger; violations return typed errors and are enforced across
|
||||
governor instances. A calibration sweep delivered at one timestamp is **one
|
||||
sitting / one dose unit** (sub-sessions do not trip the inter-step cooldown),
|
||||
and calibration sittings are counted toward the cap — not a backdoor around it.
|
||||
|
||||
5. **Per-tick monitor wired into the session loop (Finding 5).** `run_session`
|
||||
now evaluates the latched `SafetyMonitor` over **every tick** of the simulated
|
||||
session; a mid-session latch truncates the session at that tick (the recorded
|
||||
delivered stimulus duration is reduced to the completed fraction). A clean
|
||||
session is byte-identical to the prior single-summary path, so the pinned
|
||||
deterministic proof witness is unchanged.
|
||||
|
||||
*Minor:* `seed_from_cohort` enforces a privacy k-floor (**≥ 3 distinct cohort
|
||||
profiles**) before consuming cohort priors.
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -108,36 +108,262 @@ pub enum EnvelopeViolation {
|
||||
NonFinite { field: &'static str },
|
||||
}
|
||||
|
||||
/// Compiled-in **absolute** bounds — the floor under every envelope (Finding 3,
|
||||
/// 2026-06-11 safety review). No `SafetyEnvelope` can be constructed (by code,
|
||||
/// builder, or deserialization) that violates these. They make safety a
|
||||
/// property of *construction*, not of default values, mirroring the firmware's
|
||||
/// compiled-in stance.
|
||||
pub mod absolute {
|
||||
/// Hard frequency floor (Hz). Chosen **>= 30 Hz** so the entire envelope —
|
||||
/// including its lowest edge — sits above the photosensitive/provocative
|
||||
/// 15–25 Hz flicker band with margin. A config can never push stimulation
|
||||
/// into that band.
|
||||
pub const MIN_HZ: f64 = 30.0;
|
||||
/// Hard frequency ceiling (Hz). Above the 36–44 Hz search band with room
|
||||
/// for future programs, bounded so the actuator is never driven absurdly fast.
|
||||
pub const MAX_HZ: f64 = 60.0;
|
||||
/// Hard brightness cap — no envelope may uncap flicker brightness toward 1.0.
|
||||
pub const BRIGHTNESS_CAP: f64 = 0.6;
|
||||
/// Hard volume cap — comfort ceiling.
|
||||
pub const VOLUME_CAP: f64 = 0.6;
|
||||
/// Hard maximum single-session duration (minutes) — no `1e6`-minute sessions.
|
||||
pub const MAX_DURATION_MINUTES: f64 = 30.0;
|
||||
/// Hard maximum absolute inter-modality phase offset (ms).
|
||||
pub const MAX_PHASE_OFFSET_MS: f64 = 20.0;
|
||||
}
|
||||
|
||||
/// Why an attempted [`SafetyEnvelope`] construction was rejected. Every variant
|
||||
/// is a *safe* refusal: a rejected envelope is never built, so deserialization
|
||||
/// of a hostile config fails closed instead of silently widening the bounds.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, thiserror::Error)]
|
||||
pub enum EnvelopeError {
|
||||
/// A field was NaN/Inf.
|
||||
#[error("envelope field `{field}` is not finite")]
|
||||
NonFinite { field: &'static str },
|
||||
/// `min_hz` below the compiled-in photosensitive-safe floor.
|
||||
#[error("min_hz {min_hz} is below the absolute floor {floor} Hz (photosensitive-band guard)")]
|
||||
FrequencyFloor { min_hz: f64, floor: f64 },
|
||||
/// `max_hz` above the compiled-in ceiling.
|
||||
#[error("max_hz {max_hz} exceeds the absolute ceiling {ceiling} Hz")]
|
||||
FrequencyCeiling { max_hz: f64, ceiling: f64 },
|
||||
/// Band is empty or inverted.
|
||||
#[error("frequency band is empty/inverted: min_hz {min_hz} >= max_hz {max_hz}")]
|
||||
BandInverted { min_hz: f64, max_hz: f64 },
|
||||
/// Brightness cap out of `[0, ABS]`.
|
||||
#[error("brightness_cap {cap} outside [0, {abs}]")]
|
||||
BrightnessCap { cap: f64, abs: f64 },
|
||||
/// Volume cap out of `[0, ABS]`.
|
||||
#[error("volume_cap {cap} outside [0, {abs}]")]
|
||||
VolumeCap { cap: f64, abs: f64 },
|
||||
/// Duration out of `(0, ABS]`.
|
||||
#[error("max_duration_minutes {minutes} outside (0, {abs}]")]
|
||||
Duration { minutes: f64, abs: f64 },
|
||||
/// Phase offset cap out of `[0, ABS]`.
|
||||
#[error("max_phase_offset_ms {ms} outside [0, {abs}]")]
|
||||
PhaseOffset { ms: f64, abs: f64 },
|
||||
}
|
||||
|
||||
/// The predefined safety envelope. Optimization happens **only inside** these
|
||||
/// bounds; the system "must never autonomously expand beyond the allowed safety
|
||||
/// envelope" (ADR-250 §12). The envelope itself is data, never widened by the
|
||||
/// optimizer — only an operator constructs a wider one deliberately.
|
||||
///
|
||||
/// **Safety by construction (Finding 3, 2026-06-11 review):** the bound fields
|
||||
/// are private and reachable only through [`SafetyEnvelope::try_new`] (and the
|
||||
/// `with_*` builders), which reject anything outside the compiled-in
|
||||
/// [`absolute`] bounds. Deserialization routes through the same validator via
|
||||
/// `#[serde(try_from)]`, so no config — however hostile — can place the band in
|
||||
/// the 15–25 Hz photosensitive zone, uncap brightness/volume toward 1.0, or set
|
||||
/// a million-minute session.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(into = "SafetyEnvelopeWire", try_from = "SafetyEnvelopeWire")]
|
||||
pub struct SafetyEnvelope {
|
||||
pub min_hz: f64,
|
||||
pub max_hz: f64,
|
||||
/// Hard brightness cap — flicker-exposure conservatism (ADR-250 §12, OQ 7).
|
||||
pub brightness_cap: f64,
|
||||
/// Hard volume cap — comfort conservatism.
|
||||
pub volume_cap: f64,
|
||||
/// Maximum session duration for the current stage.
|
||||
pub max_duration_minutes: f64,
|
||||
/// Maximum absolute inter-modality phase offset.
|
||||
pub max_phase_offset_ms: f64,
|
||||
min_hz: f64,
|
||||
max_hz: f64,
|
||||
brightness_cap: f64,
|
||||
volume_cap: f64,
|
||||
max_duration_minutes: f64,
|
||||
max_phase_offset_ms: f64,
|
||||
}
|
||||
|
||||
/// Transparent serde mirror of [`SafetyEnvelope`]. Deserialization always passes
|
||||
/// through [`SafetyEnvelope::try_new`] (`try_from`), so the absolute bounds
|
||||
/// cannot be bypassed; serialization is a plain projection (`into`).
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||
struct SafetyEnvelopeWire {
|
||||
min_hz: f64,
|
||||
max_hz: f64,
|
||||
brightness_cap: f64,
|
||||
volume_cap: f64,
|
||||
max_duration_minutes: f64,
|
||||
max_phase_offset_ms: f64,
|
||||
}
|
||||
|
||||
impl From<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 15–25 Hz photosensitive zone.
|
||||
assert!(e.min_hz() > 25.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_new_rejects_photosensitive_band() {
|
||||
// An 18–22 Hz envelope is squarely inside the provocative band.
|
||||
let err = SafetyEnvelope::try_new(18.0, 22.0, 0.3, 0.3, 10.0, 0.0).unwrap_err();
|
||||
assert!(matches!(err, EnvelopeError::FrequencyFloor { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_new_rejects_uncapped_brightness_and_volume() {
|
||||
assert!(matches!(
|
||||
SafetyEnvelope::try_new(36.0, 44.0, 1.0, 0.3, 10.0, 0.0).unwrap_err(),
|
||||
EnvelopeError::BrightnessCap { .. }
|
||||
));
|
||||
assert!(matches!(
|
||||
SafetyEnvelope::try_new(36.0, 44.0, 0.3, 1.0, 10.0, 0.0).unwrap_err(),
|
||||
EnvelopeError::VolumeCap { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_new_rejects_million_minute_session() {
|
||||
assert!(matches!(
|
||||
SafetyEnvelope::try_new(36.0, 44.0, 0.3, 0.3, 1e6, 0.0).unwrap_err(),
|
||||
EnvelopeError::Duration { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_new_rejects_ceiling_and_inverted_band() {
|
||||
assert!(matches!(
|
||||
SafetyEnvelope::try_new(36.0, 80.0, 0.3, 0.3, 10.0, 0.0).unwrap_err(),
|
||||
EnvelopeError::FrequencyCeiling { .. }
|
||||
));
|
||||
assert!(matches!(
|
||||
SafetyEnvelope::try_new(44.0, 36.0, 0.3, 0.3, 10.0, 0.0).unwrap_err(),
|
||||
EnvelopeError::BandInverted { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialization_cannot_build_an_18_22hz_envelope() {
|
||||
// Hostile config places the band in the photosensitive zone — serde must
|
||||
// route through try_new and FAIL, not silently accept it.
|
||||
let hostile = r#"{
|
||||
"min_hz": 18.0, "max_hz": 22.0,
|
||||
"brightness_cap": 1.0, "volume_cap": 1.0,
|
||||
"max_duration_minutes": 1000000.0, "max_phase_offset_ms": 0.0
|
||||
}"#;
|
||||
let parsed: Result<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);
|
||||
}
|
||||
Reference in New Issue
Block a user