mirror of
https://github.com/ruvnet/RuView
synced 2026-07-25 17:51:48 +00:00
3408883f79
Governed, deterministic, safety-constrained personalization of 40 Hz-prior light+sound stimulation (RuView sensing + RuVector response modeling + RuFlo audit). 40 Hz is the starting prior, not the hard-coded answer. 11 modules: stimulus/SafetyEnvelope, safety (exclusion screen + latched monitor), response (20-field PersonResponseVector + optional EEG), objective (safe-entrainment score, safety as hard gate), simulator (deterministic ChaCha20 frequency_response_curve), optimizer (calibration sweep + GP/EI + closed-loop), bandit (LinUCB), session (reproducible SHA-256 witness), ruflo (consent/exclusion/envelope/audit/trial-sham/clinician-export/claim-discipline), proof (deterministic bundle witness), math. Safety invariant asserted in tests: no emitted stimulus can ever leave the SafetyEnvelope; non-finite inputs clamp to the conservative floor. Claim discipline: only 'personalized entrainment optimization', never Alzheimer's treatment (ADR-250 §19). Standalone leaf, publish=false pending safety sign-off. Validation: 64 unit/integration + 1 doctest pass; full workspace gate green (2,862 passed, 0 failed); deterministic witness pinned; criterion benches (safety-stop tick ~9.3 ns vs the ADR §17 500 ms bound). Adds ADR-250 doc, registers the crate, updates CLAUDE.md crate table and CHANGELOG. https://claude.ai/code/session_01MjBucx95K4BuUxZi8NWwRH
229 lines
7.6 KiB
Rust
229 lines
7.6 KiB
Rust
//! The safe-entrainment objective (ADR-250 §7).
|
||
//!
|
||
//! `score = w1·gamma + w2·phase_locking + w3·breathing_stability + w4·adherence
|
||
//! + w5·comfort − w6·motion_artifact − w7·adverse_risk − w8·overstim`
|
||
//!
|
||
//! Safety is **not** a soft term here: it is a hard gate applied *before*
|
||
//! scoring (`SafetyEnvelope`, `SafetyMonitor`). The `adverse_event_risk` and
|
||
//! `overstimulation_penalty` terms only shape preference *within* the already
|
||
//! safe region.
|
||
|
||
use serde::{Deserialize, Serialize};
|
||
|
||
use crate::response::{EegMeasurement, RuViewState, SubjectiveReport};
|
||
use crate::stimulus::{SafetyEnvelope, StimulusParameters};
|
||
|
||
/// Objective weights (ADR-250 §7 default weighting).
|
||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||
pub struct ObjectiveWeights {
|
||
pub gamma_gain: f64,
|
||
pub phase_locking: f64,
|
||
pub breathing_stability: f64,
|
||
pub adherence: f64,
|
||
pub comfort: f64,
|
||
pub motion_artifact: f64,
|
||
pub adverse_event_risk: f64,
|
||
pub overstimulation: f64,
|
||
}
|
||
|
||
impl Default for ObjectiveWeights {
|
||
/// ADR-250 §7 default weighting table.
|
||
fn default() -> Self {
|
||
Self {
|
||
gamma_gain: 0.30,
|
||
phase_locking: 0.25,
|
||
breathing_stability: 0.10,
|
||
adherence: 0.10,
|
||
comfort: 0.15,
|
||
motion_artifact: 0.05,
|
||
adverse_event_risk: 0.20,
|
||
overstimulation: 0.10,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Inputs to the score, gathered from one session's observations.
|
||
#[derive(Debug, Clone, Copy)]
|
||
pub struct ScoreInputs<'a> {
|
||
pub stimulus: &'a StimulusParameters,
|
||
pub ruview: &'a RuViewState,
|
||
pub eeg: Option<&'a EegMeasurement>,
|
||
pub subjective: &'a SubjectiveReport,
|
||
/// Per-session adverse-event risk estimate `[0,1]` (model-provided).
|
||
pub adverse_event_risk: f64,
|
||
}
|
||
|
||
/// The safe-entrainment objective.
|
||
#[derive(Debug, Clone, Copy)]
|
||
pub struct SafeEntrainmentObjective {
|
||
pub weights: ObjectiveWeights,
|
||
pub envelope: SafetyEnvelope,
|
||
}
|
||
|
||
impl SafeEntrainmentObjective {
|
||
pub fn new(weights: ObjectiveWeights, envelope: SafetyEnvelope) -> Self {
|
||
Self { weights, envelope }
|
||
}
|
||
|
||
/// Score a session in `[~−1, ~1]`. When EEG is absent, the gamma and
|
||
/// phase-locking terms fall back to RF-derived proxies (stillness and
|
||
/// breathing stability) at reduced weight — never claiming verified neural
|
||
/// entrainment (ADR-250 §16 consequence 3).
|
||
pub fn score(&self, inp: &ScoreInputs) -> f64 {
|
||
let w = &self.weights;
|
||
|
||
let (gamma, plv) = match inp.eeg {
|
||
Some(e) => (e.gamma_power_gain, e.phase_locking_value),
|
||
// RF-only proxy: down-weighted, derived from calm/still physiology.
|
||
None => (
|
||
0.5 * inp.ruview.stillness_score,
|
||
0.5 * inp.ruview.breathing_stability,
|
||
),
|
||
};
|
||
|
||
let overstim = self.overstimulation_penalty(inp.stimulus);
|
||
|
||
w.gamma_gain * gamma
|
||
+ w.phase_locking * plv
|
||
+ w.breathing_stability * inp.ruview.breathing_stability
|
||
+ w.adherence * inp.ruview.adherence
|
||
+ w.comfort * inp.subjective.comfort
|
||
- w.motion_artifact * inp.ruview.motion_artifact
|
||
- w.adverse_event_risk * inp.adverse_event_risk
|
||
- w.overstimulation * overstim
|
||
}
|
||
|
||
/// Overstimulation penalty `[0,1]`: how close intensity sits to the caps,
|
||
/// plus a duration term. Penalizes pushing brightness/volume toward the
|
||
/// envelope edges and running long sessions before tolerance is shown.
|
||
pub fn overstimulation_penalty(&self, s: &StimulusParameters) -> f64 {
|
||
let b = if self.envelope.brightness_cap > 0.0 {
|
||
s.brightness_level / self.envelope.brightness_cap
|
||
} else {
|
||
0.0
|
||
};
|
||
let v = if self.envelope.volume_cap > 0.0 {
|
||
s.volume_level / self.envelope.volume_cap
|
||
} else {
|
||
0.0
|
||
};
|
||
let d = if self.envelope.max_duration_minutes > 0.0 {
|
||
s.duration_minutes / self.envelope.max_duration_minutes
|
||
} else {
|
||
0.0
|
||
};
|
||
// Mean of the three normalized loads, clamped to [0,1].
|
||
((b + v + d) / 3.0).clamp(0.0, 1.0)
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::response::SubjectiveReport;
|
||
|
||
fn objective() -> SafeEntrainmentObjective {
|
||
SafeEntrainmentObjective::new(ObjectiveWeights::default(), SafetyEnvelope::conservative())
|
||
}
|
||
|
||
#[test]
|
||
fn weights_sum_to_documented_totals() {
|
||
let w = ObjectiveWeights::default();
|
||
// Positive entrainment+experience terms sum to 0.90 (ADR-250 §7).
|
||
let pos = w.gamma_gain + w.phase_locking + w.breathing_stability + w.adherence + w.comfort;
|
||
assert!((pos - 0.90).abs() < 1e-9);
|
||
}
|
||
|
||
#[test]
|
||
fn strong_entrainment_scores_higher_than_weak() {
|
||
let obj = objective();
|
||
let stim = StimulusParameters::prior();
|
||
let ruview = RuViewState::calm_baseline();
|
||
let subj = SubjectiveReport::default();
|
||
let strong = EegMeasurement {
|
||
gamma_power_gain: 0.8,
|
||
phase_locking_value: 0.8,
|
||
artifact_score: 0.02,
|
||
};
|
||
let weak = EegMeasurement {
|
||
gamma_power_gain: 0.1,
|
||
phase_locking_value: 0.1,
|
||
artifact_score: 0.02,
|
||
};
|
||
let s_strong = obj.score(&ScoreInputs {
|
||
stimulus: &stim,
|
||
ruview: &ruview,
|
||
eeg: Some(&strong),
|
||
subjective: &subj,
|
||
adverse_event_risk: 0.0,
|
||
});
|
||
let s_weak = obj.score(&ScoreInputs {
|
||
stimulus: &stim,
|
||
ruview: &ruview,
|
||
eeg: Some(&weak),
|
||
subjective: &subj,
|
||
adverse_event_risk: 0.0,
|
||
});
|
||
assert!(s_strong > s_weak);
|
||
}
|
||
|
||
#[test]
|
||
fn adverse_risk_reduces_score() {
|
||
let obj = objective();
|
||
let stim = StimulusParameters::prior();
|
||
let ruview = RuViewState::calm_baseline();
|
||
let subj = SubjectiveReport::default();
|
||
let eeg = EegMeasurement {
|
||
gamma_power_gain: 0.5,
|
||
phase_locking_value: 0.5,
|
||
artifact_score: 0.02,
|
||
};
|
||
let safe = obj.score(&ScoreInputs {
|
||
stimulus: &stim,
|
||
ruview: &ruview,
|
||
eeg: Some(&eeg),
|
||
subjective: &subj,
|
||
adverse_event_risk: 0.0,
|
||
});
|
||
let risky = obj.score(&ScoreInputs {
|
||
stimulus: &stim,
|
||
ruview: &ruview,
|
||
eeg: Some(&eeg),
|
||
subjective: &subj,
|
||
adverse_event_risk: 0.9,
|
||
});
|
||
assert!(risky < safe);
|
||
}
|
||
|
||
#[test]
|
||
fn overstimulation_penalty_grows_with_intensity() {
|
||
let obj = objective();
|
||
let mut low = StimulusParameters::prior();
|
||
low.brightness_level = 0.05;
|
||
low.volume_level = 0.05;
|
||
low.duration_minutes = 5.0;
|
||
let mut high = StimulusParameters::prior();
|
||
high.brightness_level = 0.40;
|
||
high.volume_level = 0.40;
|
||
high.duration_minutes = 15.0;
|
||
assert!(obj.overstimulation_penalty(&high) > obj.overstimulation_penalty(&low));
|
||
}
|
||
|
||
#[test]
|
||
fn no_eeg_falls_back_to_rf_proxy() {
|
||
let obj = objective();
|
||
let stim = StimulusParameters::prior();
|
||
let ruview = RuViewState::calm_baseline();
|
||
let subj = SubjectiveReport::default();
|
||
// Should produce a finite, sane score without EEG.
|
||
let s = obj.score(&ScoreInputs {
|
||
stimulus: &stim,
|
||
ruview: &ruview,
|
||
eeg: None,
|
||
subjective: &subj,
|
||
adverse_event_risk: 0.0,
|
||
});
|
||
assert!(s.is_finite());
|
||
}
|
||
}
|