feat(ruview-gamma): implement ADR-250 Adaptive Gamma Entrainment crate

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
This commit is contained in:
Claude
2026-06-10 03:30:28 +00:00
parent 9161fa7156
commit 3408883f79
20 changed files with 3592 additions and 0 deletions
+1
View File
@@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- **`ruview-gamma` crate (ADR-250) — Adaptive Gamma Entrainment.** Governed, deterministic, safety-constrained personalization of 40 Hz-prior light+sound stimulation, treating 40 Hz as the evidence-based *starting prior* and learning each person's safe entrainment response curve. Eleven modules: `stimulus` (params + `SafetyEnvelope` validate/clamp), `safety` (exclusion screen + latched `SafetyMonitor` with hard-stop reasons), `response` (`RuViewState`, optional `EegMeasurement`, 20-field `PersonResponseVector` with sticky adverse flag), `objective` (safe-entrainment score; safety is a hard gate, not a weight), `simulator` (deterministic ChaCha20 `frequency_response_curve`), `optimizer` (Phase-1 calibration sweep + Phase-2 GP/Expected-Improvement + Phase-4 closed-loop control), `bandit` (Phase-3 LinUCB over envelope-safe arms), `session` (reproducible SHA-256 `session_hash`), `ruflo` (consent→exclusion→envelope→run→monitor→score→update→witnessed audit, trial/sham mode, clinician export, claim discipline), `proof` (deterministic bundle witness), `math` (dependency-light numerics). **Safety invariant** (asserted in tests): no recommendation, calibration step, bandit arm, or closed-loop nudge can ever emit a stimulus outside the `SafetyEnvelope`; non-finite inputs clamp to the conservative floor. **Claim discipline**: the only product claim is `PRODUCT_CLAIM` = "personalized entrainment optimization" — never Alzheimer's treatment (ADR-250 §19). Standalone leaf crate (no internal RuView deps), `publish = false` pending safety sign-off. 64 unit/integration tests + 1 doctest pass; deterministic witness pinned (`13cb164c…`); criterion benches (safety-stop tick ~9.3 ns vs the ADR §17 500 ms bound, Bayesian recommend ~105 µs, full 9-session governed sweep ~486 µs). See [ADR-250](docs/adr/ADR-250-adaptive-gamma-entrainment.md).
- **RuView beyond-SOTA research series** (`docs/research/ruview-beyond-sota/`, 6 docs) — research-swarm output defining the beyond-SOTA bar and the path to it: system capability audit (role→crate maturity matrix, gap analysis, risk register), web-verified 2026 SOTA landscape per capability axis (incl. ratified IEEE 802.11bf-2025), 8-pillar target architecture on the ADR-136 contract spine (no rewrite), 6-layer benchmark/validation methodology (all 15 criterion bench targets inventoried; ADR-149 statistical protocol), and a determinism-safe optimization roadmap. Includes session validation evidence: 2,797 workspace tests / 0 failed, Python proof PASS (bit-exact), paired pre/post criterion runs.
### Performance
+1
View File
@@ -22,6 +22,7 @@ Dual codebase: Python v1 (`v1/`) and Rust port (`v2/`).
| `nvsim` | Deterministic NV-diamond magnetometer pipeline simulator (ADR-089) — standalone leaf, WASM-ready |
| `vendor/rvcsi` (submodule) | **rvCSI** — edge RF sensing runtime (ADR-095/096): 9 crates (`rvcsi-core`/`-dsp`/`-events`/`-adapter-file`/`-adapter-nexmon`/`-ruvector`/`-runtime`/`-node`/`-cli`). Lives in its own repo ([github.com/ruvnet/rvcsi](https://github.com/ruvnet/rvcsi)), vendored here under `vendor/rvcsi`, published to crates.io as `rvcsi-* 0.3.x` and to npm as `@ruv/rvcsi`. Not a `v2/` workspace member — depend on the published crates (or the submodule's `crates/rvcsi-*` paths). Normalized `CsiFrame`/`CsiWindow`/`CsiEvent` schema, validate-before-FFI, reusable DSP, typed confidence-scored events, the napi-c Nexmon shim (real nexmon_csi `.pcap` from a Raspberry Pi 5 / 4 / 3B+ — BCM43455c0), the napi-rs SDK, the `rvcsi` CLI, a Claude Code plugin. |
| `ruview-swarm` | Drone swarm control system (ADR-148) — hierarchical-mesh topology, Raft consensus, MARL, CSI sensing payload, MAVLink/PX4 compat, Ruflo AI-agent integration |
| `ruview-gamma` | Adaptive Gamma Entrainment (ADR-250) — governed, deterministic, safety-constrained personalization of 40 Hz-prior light+sound stimulation (RuView sensing + RuVector response modeling + RuFlo audit). Standalone leaf, ChaCha20 + SHA-256 witness. Research platform, not a medical device. |
### RuvSense Modules (`signal/src/ruvsense/`)
| Module | Purpose |
@@ -0,0 +1,207 @@
# ADR-250: Adaptive Gamma Entrainment Using RuVector and RuView
| Field | Value |
|-------|-------|
| **Status** | Proposed |
| **Date** | 2026-06-09 |
| **Owner** | RuView, RuVector, RuFlo clinical systems |
| **Decision type** | Architecture, safety, research platform |
| **Scope** | Personalized noninvasive sensory stimulation and passive state sensing |
| **Codebase target** | `v2/crates/ruview-gamma` (this ADR's reference implementation) |
> **Not medical advice:** This ADR defines a research and engineering
> architecture. It does **not** define an approved Alzheimer's treatment. The
> immediate product claim is **personalized entrainment optimization**, never
> Alzheimer's treatment.
---
## 1. Context
Recent research suggests that noninvasive 40 Hz multisensory stimulation using
light and sound can influence gamma neural activity and may activate glymphatic
clearance pathways associated with amyloid removal in Alzheimer's mouse models.
The strongest mechanistic support comes from a 2024 Nature paper showing that
multisensory gamma stimulation promoted cerebrospinal fluid influx, interstitial
fluid efflux, aquaporin-4 polarization, meningeal lymphatic dilation, VIP
interneuron signaling, and amyloid clearance in 5XFAD mice. Blocking glymphatic
clearance abolished the amyloid-clearing effect.
Human evidence is promising but still early. A 2022 study in mild probable
Alzheimer's disease reported that 40 Hz sensory stimulation was feasible and
well tolerated, with exploratory signals around brain structure, connectivity,
sleep, and memory. A small 2025 long-term pilot reported daily 40 Hz
audiovisual stimulation over two years was safe and feasible and may slow
cognitive and biomarker progression, but the sample size was very small and not
definitive.
The field mostly treats 40 Hz as a fixed protocol. That is a useful population
prior, but individual brains may differ by baseline gamma state, arousal, sleep
quality, sensory acuity, medication, age, disease stage, and comfort tolerance.
A 2025 PLOS One study re-evaluated gamma stimulation frequency across 3644 Hz,
supporting the idea that frequency choice should be empirically measured rather
than assumed.
## 2. Problem
Fixed 40 Hz stimulation creates four engineering limits:
1. It assumes the same frequency works for everyone.
2. It does not continuously verify entrainment.
3. It ignores physiological state (sleep, motion, breathing, restlessness, comfort).
4. It cannot safely optimize stimulation parameters over time.
For clinical or wellness-grade deployment, the system must answer: *which
frequency, modality, intensity, timing, and session structure produces the
strongest safe entrainment for this person in this state?*
## 3. Decision
We build an **Adaptive Gamma Entrainment Architecture** where 40 Hz is the
initial prior, not the hard-coded answer. The system uses:
1. **RuView** as the passive state-sensing layer.
2. **RuVector** as the personal response-modeling layer.
3. A **constrained optimizer** to select stimulation parameters.
4. **RuFlo** as the governed workflow, audit, safety, and protocol-execution layer.
5. **Clinical-mode separation** to prevent unsupported therapeutic claims.
The system optimizes stimulation **only within a predefined safety envelope**
and separates entrainment optimization from disease-outcome claims.
## 4. Architecture Overview
```
Person baseline → RuView passive sensing → optional EEG → stimulus session
→ response extraction → RuVector personal response vector
→ constrained optimizer → next best protocol → RuFlo audit + governance
```
| Component | Role | Output |
|-----------|------|--------|
| RuView | Passive sensing of body and environment | breathing, motion, posture, stillness, sleep state, adherence |
| EEG (optional) | Direct entrainment measurement | gamma power, phase locking, artifact score |
| Stimulus controller | Light + sound actuator | frequency, intensity, phase, duty cycle, duration |
| RuVector | Learns personal response surface | individual entrainment vector |
| Optimizer | Selects next safe stimulation setting | recommended protocol |
| RuFlo | Governance and audit | protocol record, safety log, reproducibility trail |
## 5. Stimulus Search Space
| Parameter | Default range | Notes |
|-----------|---------------|-------|
| Frequency | 3644 Hz | published exploratory range |
| Starting prior | 40 Hz | strongest preclinical literature |
| Modality | audio, visual, combined | combined preferred (GENUS-style) |
| Brightness | bounded lowmoderate | avoid unsafe flicker intensity |
| Volume | bounded lowmoderate | comfort-constrained |
| Duty cycle | continuous, ramped, pulsed | start conservative |
| Phase | synchronized, offset | explore only after baseline |
| Duration | short calibration first | longer only after tolerance |
| Time of day | morning, evening, quiet wake | state-dependent |
## 6. Personal Response Vector
RuVector represents each person with a compact 20-field adaptive vector
(`baseline_gamma, baseline_alpha, alpha_gamma_ratio, gamma_power_gain,
phase_locking_value, breathing_rate, breathing_stability, motion_artifact,
posture_state, sleep_state, restlessness_score, stimulus_frequency,
brightness_level, sound_level, duty_cycle, phase_offset, session_duration,
comfort_score, adherence_score, adverse_event_flag`), updated after each
session: `R_{t+1} = update(R_t, stimulus_t, response_t, safety_t)`.
## 7. Optimization Objective
The optimizer maximizes **safe, stable entrainment**, not raw gamma power:
```
score = w1·gamma_power_gain + w2·phase_locking_gain + w3·breathing_stability
+ w4·adherence + w5·comfort
w6·motion_artifact w7·adverse_event_risk w8·overstimulation_penalty
```
Default weights: gamma 0.30, phase-locking 0.25, comfort 0.15, breathing 0.10,
adherence 0.10, motion penalty 0.05; **safety penalty is a hard constraint, not
negotiable**.
## 8. Learning Method (staged loop)
- **Phase 1 — Conservative calibration:** short sessions at 3644 Hz (1 Hz steps).
- **Phase 2 — Bayesian optimization:** GP surrogate + Expected Improvement,
subject to `safety==true ∧ comfort≥threshold ∧ adverse_event_risk≤threshold`.
- **Phase 3 — Contextual bandit:** once enough sessions exist, LinUCB over state
context → stimulus action → safe-entrainment reward.
- **Phase 4 — Closed-loop control:** mid-session, bounded frequency nudges when
entrainment drops, intensity reduction on discomfort, scoring pause on motion
spikes, and hard terminate-and-lock on adverse events.
## 912. RuView / RuVector / RuFlo roles & Safety
RuView supplies non-camera passive context (breathing, motion, posture,
stillness, restlessness, sleep proxy, interference, adherence). RuVector
supplies adaptive memory (personal vector, session-to-session learning,
anonymized nearest-neighbor, drift detection, clustering, recommendation, edge
inference) and predicts **safe-entrainment / comfort / artifact / adherence
likelihood** — not Alzheimer's improvement. RuFlo governs (consent,
inclusion/exclusion, scheduling, safety-stop rules, parameter audit trail, ADR
linkage, model-version tracking, clinician export, trial-mode separation).
Every session is reproducible via
`session_hash = hash(protocol_version, model_version, device_version,
stimulus_parameters, sensor_summary, response_summary, safety_events)`.
**Hard-stop conditions:** headache, dizziness, nausea, agitation, visual
discomfort, abnormal distress, seizure-like symptoms, user-stop request, sensor
confidence below threshold, protocol outside approved envelope.
**Exclusion / clinical supervision:** epilepsy or seizure history,
photosensitivity, severe migraine sensitivity, severe psychiatric instability,
implanted neurological devices, significant sensory impairment affecting
protocol validity, medication changes affecting neural response. **The system
must never autonomously expand beyond the allowed safety envelope.**
## 18. Acceptance Criteria
| Criterion | Target |
|-----------|--------|
| Frequency control precision | ±0.1 Hz |
| Session audit completeness | 100% |
| Motion artifact detection | ≥90% valid/invalid classification |
| Adaptive protocol improvement | ≥20% entrainment gain vs fixed 40 Hz |
| Comfort | no worse than fixed 40 Hz |
| Safety stops | 100% logged |
| Repeatability | same optimal band within ±1 Hz across 3 sessions |
| Claim discipline | no disease-treatment claim in product UI |
## 19. Non-Goals
This ADR does **not** claim: RuView treats Alzheimer's; RuVector clears amyloid;
RF sensing measures amyloid directly; personalized frequency improves clinical
outcomes; consumer deployment is safe without screening; 40 Hz is always optimal.
## 21. Implementation Roadmap → reference crate `ruview-gamma`
| Milestone | Module(s) in `ruview-gamma` | Status in this ADR's impl |
|-----------|------------------------------|---------------------------|
| M1 Simulator | `simulator.rs` (deterministic ChaCha20 response surface) | **Implemented** |
| M2 Device harness (contract) | `stimulus.rs`, `safety.rs` (envelope + emergency stop) | **Interfaces + safety implemented** |
| M3 RuView integration (contract) | `response.rs` (`RuViewState`) | **State contract implemented** |
| M4 EEG validation (contract) | `response.rs` (`EegMeasurement`), `objective.rs` | **Optional input implemented** |
| M5 Adaptive optimizer | `optimizer.rs` (Phase 1+2), `bandit.rs` (Phase 3), closed-loop | **Implemented** |
| M6 Trial mode | `ruflo.rs` (consent, inclusion/exclusion, sham, audit, session hash) | **Implemented** |
The crate is a **deterministic, dependency-light leaf** (no internal RuView
deps, ChaCha20 PRNG, SHA-256 witness — same discipline as `nvsim`), so the
optimizer, safety envelope, and RuVector update logic can be tested and replayed
bit-exactly before any hardware or human exposure. Hardware actuation, real RF
sensing, and real EEG land behind feature flags / external adapters; this crate
implements the governed software core and its proofs.
## 22. Final Decision Statement
We build Adaptive Gamma Entrainment as a governed RuView + RuVector
architecture. The system treats 40 Hz as the evidence-based starting prior, then
learns each person's safe entrainment response curve using passive sensing,
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.
Generated
+14
View File
@@ -7456,6 +7456,20 @@ version = "2.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "753a07254fa68db183949ec6c7575d890da4d42404afabc11d610a720fcf570c"
[[package]]
name = "ruview-gamma"
version = "0.3.0"
dependencies = [
"approx",
"criterion",
"rand 0.8.5",
"rand_chacha 0.3.1",
"serde",
"serde_json",
"sha2",
"thiserror 2.0.18",
]
[[package]]
name = "ruview-swarm"
version = "0.1.0"
+1
View File
@@ -71,6 +71,7 @@ members = [
"crates/homecore-assist", # ADR-133 — HOMECORE voice assistant + ruflo bridge
"crates/homecore-server", # iter-9 — HOMECORE integration binary (all 8 crates wired together)
"crates/ruview-swarm", # ADR-148 — drone swarm control system
"crates/ruview-gamma", # ADR-250 — adaptive gamma entrainment (governed research platform)
]
# ADR-040: WASM edge crate targets wasm32-unknown-unknown (no_std),
# excluded from workspace to avoid breaking `cargo test --workspace`.
+49
View File
@@ -0,0 +1,49 @@
[package]
name = "ruview-gamma"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
description = "Adaptive Gamma Entrainment (ADR-250) — governed, deterministic, safety-constrained personalization of 40 Hz-prior sensory stimulation (RuView sensing + RuVector response modeling + RuFlo audit). Research platform, not a medical device."
repository.workspace = true
keywords = ["gamma", "entrainment", "neuromodulation", "bayesian-optimization", "research"]
categories = ["science", "simulation"]
readme = "README.md"
# Research platform. Publishing intentionally disabled: this crate touches a
# safety-critical, clinical-adjacent domain (ADR-250 §12). Flip to true only
# after safety + claim-discipline sign-off.
publish = false
[lib]
# rlib for native workspace linking; cdylib so the deterministic core can be
# wrapped for a browser/edge dashboard later (same posture as nvsim).
crate-type = ["cdylib", "rlib"]
# `ruview-gamma` is a standalone leaf crate with NO internal RuView deps — the
# governed software core (optimizer, safety envelope, RuVector update logic,
# session witness) must be testable and replayable bit-exactly before any
# hardware actuation or human exposure. Hardware/RF/EEG adapters land behind
# feature flags after the core ships (ADR-250 §21, Milestones 24).
[dependencies]
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
# Deterministic ChaCha20 PRNG: same (person, state, stimulus, seed) yields
# byte-identical synthetic responses across runs and machines — the
# reproducibility commitment in ADR-250 §11. default-features off drops the
# getrandom OS-entropy path; all seeds are caller-supplied u64.
rand = { version = "0.8", default-features = false }
rand_chacha = { version = "0.3", default-features = false }
# SHA-256 session witness: hash(protocol, model, device, stimulus, sensors,
# response, safety) — the RuFlo reproducibility trail (ADR-250 §11).
sha2 = { workspace = true }
[dev-dependencies]
approx = "0.5"
criterion = { workspace = true }
[[bench]]
name = "optimizer_bench"
harness = false
+100
View File
@@ -0,0 +1,100 @@
# ruview-gamma — Adaptive Gamma Entrainment (ADR-250)
Governed, deterministic, **safety-constrained** personalization of 40 Hz-prior
multisensory (light + sound) stimulation. Treats 40 Hz as the evidence-based
*starting prior*, then learns each person's safe entrainment response curve using
passive RuView sensing, optional EEG, a constrained optimizer, and auditable
RuFlo workflows.
> **Not medical advice / not a medical device.** This crate is a research and
> engineering platform. The only claim it makes is **"personalized entrainment
> optimization"** (`ruview_gamma::PRODUCT_CLAIM`) — never Alzheimer's treatment,
> amyloid clearance, or any clinical outcome (ADR-250 §19). It performs **no
> hardware actuation**: real stimulus delivery, RF sensing, and EEG arrive
> through external adapters behind feature flags after this governed software
> core ships (ADR-250 §21, Milestones 24).
## Why it exists
The field mostly treats 40 Hz as a fixed protocol. But individual brains differ
by baseline gamma, arousal, sleep, sensory acuity, medication, age, and comfort
(the 2025 PLOS One 3644 Hz re-evaluation). Fixed 40 Hz (1) assumes one
frequency fits all, (2) never verifies entrainment, (3) ignores physiological
state, and (4) cannot safely optimize over time. This crate closes that loop.
## The safety invariant
**No recommendation, calibration step, bandit arm, or closed-loop nudge can ever
emit a `StimulusParameters` outside the `SafetyEnvelope`.** Every emitting path
clamps to the envelope and is asserted against `SafetyEnvelope::contains` in
tests. The optimizer never widens the envelope — only an operator constructs a
wider one deliberately (ADR-250 §12). Non-finite (NaN/∞) inputs clamp toward the
conservative floor, never the cap.
## Module map
| Module | Role (ADR-250 §) | Highlights |
|--------|------------------|------------|
| `stimulus` | §5, §12 | `StimulusParameters`, `SafetyEnvelope` (validate / clamp / grids) |
| `safety` | §12 | exclusion screen, latched `SafetyMonitor`, hard-stop reasons |
| `response` | §6, §9, §10 | `RuViewState`, optional `EegMeasurement`, 20-field `PersonResponseVector` (RuVector memory) with sticky adverse flag |
| `objective` | §7 | safe-entrainment score; safety is a hard gate, not a weight; RF-only proxy when EEG absent |
| `simulator` | §21 M1 | deterministic ChaCha20 `frequency_response_curve(person, state, stimulus)` |
| `optimizer` | §8 | Phase-1 calibration sweep, Phase-2 GP + Expected-Improvement, Phase-4 closed-loop control |
| `bandit` | §8 P3 | LinUCB contextual bandit over envelope-safe arms |
| `session` | §11, §13 | hashable `SessionRecord`, reproducible `session_hash` (SHA-256, quantized canonical form) |
| `ruflo` | §11 | consent → exclusion → envelope → run → monitor → score → update → witnessed audit; trial/sham mode; clinician export; claim discipline |
| `proof` | — | deterministic bundle witness (mirrors `nvsim` / `verify.py`) |
| `math` | — | dependency-light numerics (erf, normal CDF/PDF, Cholesky, RBF) |
## Quick start
```rust
use ruview_gamma::{
ruflo::{Consent, RufloGovernor},
response::RuViewState,
simulator::{LatentPerson, ResponseSimulator},
stimulus::{SafetyEnvelope, StimulusParameters},
};
let envelope = SafetyEnvelope::conservative();
let mut gov = RufloGovernor::enroll("subject-001", envelope, &[], Consent::Granted)
.expect("cleared to participate");
// Milestone 1: drive the governed loop with the deterministic simulator.
let sim = ResponseSimulator::new(42);
let latent = LatentPerson::from_id("subject-001");
let state = RuViewState::calm_baseline();
gov.run_calibration(&sim, &latent, &state, 5.0, 0).unwrap();
let rec = gov.recommend(&StimulusParameters::prior());
assert!(envelope.contains(&rec.stimulus)); // always inside the envelope
```
## Test / validate / benchmark
```bash
cargo test -p ruview-gamma --no-default-features # 64 unit/integration + 1 doctest
cargo bench -p ruview-gamma --no-default-features # criterion micro-benchmarks
```
Determinism is proven, not assumed: `proof::Proof::reference_witness()` runs a
fixed reference participant through the full governed pipeline and pins the
bundle SHA-256 (`Proof::EXPECTED_WITNESS`); the test fails on any silent drift in
the optimizer, simulator, response update, or session hashing.
### Measured (this container, indicative — not a regression gate)
| Bench | Median | Note |
|-------|--------|------|
| `gamma_safety_tick` | ~9.3 ns | vs ADR-250 §17 < 500 ms hard-stop latency bound |
| `gamma_bandit_select` | ~73 ns | LinUCB decision |
| `gamma_bayesian_recommend` | ~105 µs | GP + EI over the 0.1 Hz envelope grid |
| `gamma_calibration_sweep` | ~486 µs | full 9-session enroll → simulate → score → update → witness |
## Roadmap (ADR-250 §21)
M1 simulator ✅ · M2 device harness (envelope + e-stop contract) ✅ · M3 RuView
state contract ✅ · M4 optional EEG input ✅ · M5 adaptive optimizer (BO + bandit
+ closed-loop) ✅ · M6 trial mode (sham/blinding + clinician export) ✅. Hardware
actuation, real RF sensing, and real EEG land behind feature-flagged adapters.
@@ -0,0 +1,90 @@
//! Criterion benchmarks for the adaptive-gamma governed loop (ADR-250 §17).
//!
//! Measures the latency-sensitive paths: a full calibration sweep, a single
//! Bayesian recommendation, a closed-loop safety tick, and a bandit decision.
//! The safety-stop tick is the figure compared against ADR-250 §17's < 500 ms
//! bound — it is O(1) and lands far below.
use criterion::{black_box, criterion_group, criterion_main, Criterion};
use ruview_gamma::bandit::{BanditContext, ContextualBandit};
use ruview_gamma::optimizer::BayesianOptimizer;
use ruview_gamma::response::RuViewState;
use ruview_gamma::ruflo::{Consent, RufloGovernor};
use ruview_gamma::safety::{SafetyMonitor, SafetyTick};
use ruview_gamma::simulator::{LatentPerson, ResponseSimulator};
use ruview_gamma::stimulus::{SafetyEnvelope, StimulusParameters};
fn bench_calibration(c: &mut Criterion) {
let env = SafetyEnvelope::conservative();
let sim = ResponseSimulator::new(42);
let latent = LatentPerson::from_id("bench-subject");
let state = RuViewState::calm_baseline();
c.bench_function("gamma_calibration_sweep", |b| {
b.iter(|| {
let mut gov =
RufloGovernor::enroll("bench-subject", env, &[], Consent::Granted).unwrap();
gov.run_calibration(black_box(&sim), &latent, &state, 5.0, 0)
.unwrap();
black_box(gov.audit_log().len())
})
});
}
fn bench_recommend(c: &mut Criterion) {
let env = SafetyEnvelope::conservative();
let mut bo = BayesianOptimizer::default();
for f in env.calibration_frequencies() {
bo.observe(f, 1.0 - 0.05 * (f - 39.5).powi(2));
}
let base = StimulusParameters::prior();
c.bench_function("gamma_bayesian_recommend", |b| {
b.iter(|| black_box(bo.recommend(black_box(&env), black_box(&base))))
});
}
fn bench_safety_tick(c: &mut Criterion) {
c.bench_function("gamma_safety_tick", |b| {
b.iter(|| {
let mut m = SafetyMonitor::default();
black_box(m.evaluate(black_box(SafetyTick {
adverse: None,
sensor_confidence: 0.9,
stimulus_in_envelope: true,
})))
})
});
}
fn bench_bandit(c: &mut Criterion) {
let env = SafetyEnvelope::conservative();
let candidates: Vec<StimulusParameters> = [38.0, 40.0, 42.0]
.iter()
.map(|&f| {
let mut s = StimulusParameters::prior();
s.frequency_hz = f;
s
})
.collect();
let bandit = ContextualBandit::new(&env, &candidates, 1.0).unwrap();
let ctx = BanditContext {
sleep_quality: 0.7,
time_of_day: 0.5,
breathing_state: 0.8,
motion_state: 0.1,
fatigue_proxy: 0.2,
prior_response: 0.6,
};
c.bench_function("gamma_bandit_select", |b| {
b.iter(|| black_box(bandit.select(black_box(&ctx))))
});
}
criterion_group!(
benches,
bench_calibration,
bench_recommend,
bench_safety_tick,
bench_bandit
);
criterion_main!(benches);
+234
View File
@@ -0,0 +1,234 @@
//! Phase-3 contextual bandit (ADR-250 §8).
//!
//! Once enough sessions exist, protocol selection becomes state-dependent:
//! `context = [sleep_quality, time_of_day, breathing_state, motion_state,
//! fatigue_proxy, prior_response]` → `action = stimulus setting` →
//! `reward = safe_entrainment_score`. We use **LinUCB** (disjoint linear model
//! per arm) — small, deterministic, explainable, and edge-deployable.
//!
//! Arms are a discrete set of *envelope-safe* stimulus settings supplied by the
//! caller; the bandit never invents an out-of-envelope action because it can
//! only ever return one of the arms it was given.
use crate::math::clamp_safe;
use crate::stimulus::{SafetyEnvelope, StimulusParameters};
/// Context vector dimensionality (ADR-250 §8 Phase 3 context list).
pub const CONTEXT_DIM: usize = 6;
/// The decision context, normalized to `[0,1]` per field.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct BanditContext {
pub sleep_quality: f64,
pub time_of_day: f64,
pub breathing_state: f64,
pub motion_state: f64,
pub fatigue_proxy: f64,
pub prior_response: f64,
}
impl BanditContext {
/// Flat feature vector in the documented field order.
pub fn features(&self) -> [f64; CONTEXT_DIM] {
[
clamp_safe(self.sleep_quality, 0.0, 1.0),
clamp_safe(self.time_of_day, 0.0, 1.0),
clamp_safe(self.breathing_state, 0.0, 1.0),
clamp_safe(self.motion_state, 0.0, 1.0),
clamp_safe(self.fatigue_proxy, 0.0, 1.0),
clamp_safe(self.prior_response, 0.0, 1.0),
]
}
}
/// One LinUCB arm: a fixed safe stimulus plus its online linear model. The
/// per-arm `A⁻¹` is maintained incrementally via the ShermanMorrison update,
/// so no matrix inversion runs at decision time.
#[derive(Debug, Clone)]
struct Arm {
stimulus: StimulusParameters,
/// A⁻¹ (d×d, row-major), initialized to I.
a_inv: [f64; CONTEXT_DIM * CONTEXT_DIM],
/// b (d), initialized to 0.
b: [f64; CONTEXT_DIM],
}
impl Arm {
fn new(stimulus: StimulusParameters) -> Self {
let mut a_inv = [0.0; CONTEXT_DIM * CONTEXT_DIM];
for i in 0..CONTEXT_DIM {
a_inv[i * CONTEXT_DIM + i] = 1.0;
}
Self {
stimulus,
a_inv,
b: [0.0; CONTEXT_DIM],
}
}
/// theta = A⁻¹ b
fn theta(&self) -> [f64; CONTEXT_DIM] {
mat_vec(&self.a_inv, &self.b)
}
/// UCB score: μ + α·√(xᵀ A⁻¹ x).
fn ucb(&self, x: &[f64; CONTEXT_DIM], alpha: f64) -> f64 {
let theta = self.theta();
let mean: f64 = theta.iter().zip(x).map(|(t, xi)| t * xi).sum();
let ainv_x = mat_vec(&self.a_inv, x);
let var: f64 = x.iter().zip(&ainv_x).map(|(xi, v)| xi * v).sum();
mean + alpha * var.max(0.0).sqrt()
}
/// Online update with observed `(x, reward)` via ShermanMorrison:
/// A ← A + x xᵀ ⇒ A⁻¹ ← A⁻¹ − (A⁻¹ x xᵀ A⁻¹)/(1 + xᵀ A⁻¹ x).
fn update(&mut self, x: &[f64; CONTEXT_DIM], reward: f64) {
let ainv_x = mat_vec(&self.a_inv, x); // A⁻¹ x (d)
let denom = 1.0 + x.iter().zip(&ainv_x).map(|(xi, v)| xi * v).sum::<f64>();
// A⁻¹ ← A⁻¹ (ainv_x)(ainv_x)ᵀ / denom (since A symmetric ⇒ xᵀA⁻¹ = (A⁻¹x)ᵀ)
for i in 0..CONTEXT_DIM {
for j in 0..CONTEXT_DIM {
self.a_inv[i * CONTEXT_DIM + j] -= ainv_x[i] * ainv_x[j] / denom;
}
}
for i in 0..CONTEXT_DIM {
self.b[i] += reward * x[i];
}
}
}
/// LinUCB contextual bandit over a fixed set of envelope-safe arms.
#[derive(Debug, Clone)]
pub struct ContextualBandit {
arms: Vec<Arm>,
/// Exploration coefficient α.
pub alpha: f64,
}
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> {
if candidates.is_empty() {
return None;
}
let arms = candidates
.iter()
.map(|s| Arm::new(envelope.clamp(*s)))
.collect();
Some(Self { arms, alpha })
}
/// Number of arms.
pub fn n_arms(&self) -> usize {
self.arms.len()
}
/// Select the arm index with the highest UCB for `ctx`. Deterministic
/// tie-break: lowest index wins.
pub fn select(&self, ctx: &BanditContext) -> usize {
let x = ctx.features();
let mut best_i = 0;
let mut best = f64::NEG_INFINITY;
for (i, arm) in self.arms.iter().enumerate() {
let u = arm.ucb(&x, self.alpha);
if u > best {
best = u;
best_i = i;
}
}
best_i
}
/// The stimulus for an arm index (always envelope-safe).
pub fn stimulus(&self, arm: usize) -> StimulusParameters {
self.arms[arm].stimulus
}
/// Record the reward for a chosen arm under context `ctx`.
pub fn update(&mut self, arm: usize, ctx: &BanditContext, reward: f64) {
let x = ctx.features();
self.arms[arm].update(&x, reward);
}
}
/// y = M x for row-major `d×d` M and length-`d` x.
fn mat_vec(m: &[f64; CONTEXT_DIM * CONTEXT_DIM], x: &[f64; CONTEXT_DIM]) -> [f64; CONTEXT_DIM] {
let mut y = [0.0; CONTEXT_DIM];
for i in 0..CONTEXT_DIM {
let mut s = 0.0;
for j in 0..CONTEXT_DIM {
s += m[i * CONTEXT_DIM + j] * x[j];
}
y[i] = s;
}
y
}
#[cfg(test)]
mod tests {
use super::*;
use crate::stimulus::StimulusParameters;
fn candidates() -> Vec<StimulusParameters> {
[38.0, 40.0, 42.0]
.iter()
.map(|&f| {
let mut s = StimulusParameters::prior();
s.frequency_hz = f;
s
})
.collect()
}
fn ctx(sleep: f64) -> BanditContext {
BanditContext {
sleep_quality: sleep,
time_of_day: 0.5,
breathing_state: 0.8,
motion_state: 0.1,
fatigue_proxy: 0.2,
prior_response: 0.6,
}
}
#[test]
fn rejects_empty_candidates() {
let env = SafetyEnvelope::conservative();
assert!(ContextualBandit::new(&env, &[], 1.0).is_none());
}
#[test]
fn all_arms_are_envelope_safe_even_if_candidate_is_not() {
let env = SafetyEnvelope::conservative();
let mut bad = StimulusParameters::prior();
bad.frequency_hz = 100.0;
bad.brightness_level = 9.0;
let b = ContextualBandit::new(&env, &[bad], 1.0).unwrap();
assert!(env.contains(&b.stimulus(0)));
}
#[test]
fn learns_to_prefer_rewarded_arm() {
let env = SafetyEnvelope::conservative();
let mut b = ContextualBandit::new(&env, &candidates(), 0.1).unwrap();
let c = ctx(0.9);
// Arm 2 (42 Hz) is consistently best in this context.
for _ in 0..50 {
for arm in 0..b.n_arms() {
let reward = if arm == 2 { 1.0 } else { 0.1 };
b.update(arm, &c, reward);
}
}
assert_eq!(b.select(&c), 2);
}
#[test]
fn selection_is_deterministic() {
let env = SafetyEnvelope::conservative();
let b = ContextualBandit::new(&env, &candidates(), 1.0).unwrap();
let c = ctx(0.5);
assert_eq!(b.select(&c), b.select(&c));
}
}
+177
View File
@@ -0,0 +1,177 @@
//! # ruview-gamma — Adaptive Gamma Entrainment (ADR-250)
//!
//! Governed, deterministic, safety-constrained personalization of 40 Hz-prior
//! multisensory stimulation. Treats 40 Hz as the evidence-based **starting
//! prior**, then learns each person's safe entrainment response curve using
//! passive [RuView] sensing, optional EEG, a constrained optimizer, and
//! auditable [RuFlo] workflows.
//!
//! > **Not medical advice / not a medical device.** This crate is a research
//! > and engineering platform. The only product claim it makes is
//! > [`ruflo::PRODUCT_CLAIM`] — *"personalized entrainment optimization"* — and
//! > never Alzheimer's treatment, amyloid clearance, or any clinical outcome
//! > (ADR-250 §19 Non-Goals). It performs no hardware actuation: real stimulus
//! > delivery, RF sensing, and EEG arrive through external adapters behind
//! > feature flags after this governed software core ships (ADR-250 §21).
//!
//! ## Design discipline
//!
//! A deterministic, dependency-light **leaf crate** (no internal RuView deps;
//! ChaCha20 PRNG; SHA-256 witness) — the same posture as `nvsim`. The
//! optimizer, safety envelope, RuVector update logic, and session witness are
//! all testable and replayable bit-exactly *before any human exposure*. See
//! [`proof::Proof`] for the deterministic bundle proof.
//!
//! ## Pipeline (ADR-250 §4)
//!
//! ```text
//! enroll (consent + exclusion screen) ── ruflo
//! → calibration sweep 3644 Hz ── optimizer::CalibrationPlan
//! → simulated/observed response ── simulator (M1) / external (M2-4)
//! → safety monitor (hard stop, latched) ── safety
//! → safe-entrainment score ── objective
//! → personal response vector update ── response (RuVector)
//! → Bayesian / bandit recommendation ── optimizer / bandit
//! → witnessed audit record ── session + ruflo
//! ```
//!
//! ## The safety invariant
//!
//! **No recommendation, calibration step, bandit arm, or closed-loop nudge can
//! ever emit a [`stimulus::StimulusParameters`] outside the
//! [`stimulus::SafetyEnvelope`].** Every emitting path clamps to the envelope
//! and is asserted against [`stimulus::SafetyEnvelope::contains`] in tests. The
//! optimizer never widens the envelope — only an operator constructs a wider
//! one deliberately (ADR-250 §12).
//!
//! ## Quick start
//!
//! ```
//! use ruview_gamma::{
//! ruflo::{Consent, RufloGovernor},
//! response::RuViewState,
//! simulator::{LatentPerson, ResponseSimulator},
//! stimulus::{SafetyEnvelope, StimulusParameters},
//! };
//!
//! let envelope = SafetyEnvelope::conservative();
//! let mut gov = RufloGovernor::enroll("subject-001", envelope, &[], Consent::Granted)
//! .expect("cleared to participate");
//!
//! // Milestone 1: drive the governed loop with the deterministic simulator.
//! let sim = ResponseSimulator::new(42);
//! let latent = LatentPerson::from_id("subject-001");
//! let state = RuViewState::calm_baseline();
//! gov.run_calibration(&sim, &latent, &state, 5.0, 0).unwrap();
//!
//! let rec = gov.recommend(&StimulusParameters::prior());
//! assert!(envelope.contains(&rec.stimulus)); // always inside the envelope
//! ```
pub mod bandit;
pub mod math;
pub mod objective;
pub mod optimizer;
pub mod proof;
pub mod response;
pub mod ruflo;
pub mod safety;
pub mod session;
pub mod simulator;
pub mod stimulus;
use thiserror::Error;
/// Crate-level error type (re-exported for callers who want a single error to
/// match on; most modules expose their own typed errors).
#[derive(Debug, Error)]
pub enum GammaError {
/// A governance refusal (consent / exclusion / envelope).
#[error(transparent)]
Governance(#[from] ruflo::GovernanceError),
/// JSON (de)serialization of a session record failed.
#[error("serialization error: {0}")]
Serde(#[from] serde_json::Error),
}
/// The single allowed product claim. Re-exported at crate root for prominence.
pub use ruflo::PRODUCT_CLAIM;
#[cfg(test)]
mod integration_tests {
use crate::response::RuViewState;
use crate::ruflo::{Consent, RufloGovernor, TrialMode};
use crate::simulator::{LatentPerson, ResponseSimulator};
use crate::stimulus::{SafetyEnvelope, StimulusParameters};
/// ADR-250 §18 acceptance: adaptive recommendation beats the fixed 40 Hz
/// prior in mean simulated entrainment for a person whose peak is away
/// from 40 Hz. (We assert improvement, not the exact ≥20% figure, which is
/// simulator-dependent; the harness for the quantitative claim is here.)
#[test]
fn adaptive_beats_fixed_40hz_for_detuned_person() {
let env = SafetyEnvelope::conservative();
let sim = ResponseSimulator::new(2024);
// Pick a subject whose latent peak is clearly off 40 Hz.
let mut chosen = None;
for n in 0..50 {
let id = format!("detuned-{n}");
let p = LatentPerson::from_id(&id);
if (p.peak_hz - 40.0).abs() > 1.5 {
chosen = Some((id, p));
break;
}
}
let (id, latent) = chosen.expect("a detuned subject exists");
let state = RuViewState::calm_baseline();
// Learn via calibration.
let mut gov = RufloGovernor::enroll(&id, env, &[], Consent::Granted).unwrap();
gov.run_calibration(&sim, &latent, &state, 5.0, 0).unwrap();
let rec = gov.recommend(&StimulusParameters::prior());
// Compare mean simulated entrainment over repeated sessions: fixed 40 Hz
// vs the adaptive recommendation. Use fresh session indices.
let fixed = {
let mut s = StimulusParameters::prior();
s.frequency_hz = 40.0;
s
};
let mean = |stim: &StimulusParameters| -> f64 {
(0..20)
.map(|i| sim.simulate(&latent, &state, stim, 1000 + i).eeg.gamma_power_gain)
.sum::<f64>()
/ 20.0
};
assert!(env.contains(&rec.stimulus));
assert!(mean(&rec.stimulus) > mean(&fixed));
}
/// End-to-end: a blinded sham arm yields lower mean entrainment than the
/// open arm across a small cohort — the controlled-trial primitive.
#[test]
fn sham_arm_shows_no_entrainment_across_cohort() {
let env = SafetyEnvelope::conservative();
let sim = ResponseSimulator::new(7);
let state = RuViewState::calm_baseline();
let mut open_sum = 0.0;
let mut sham_sum = 0.0;
for n in 0..8 {
let id = format!("cohort-{n}");
let latent = LatentPerson::from_id(&id);
let mut stim = StimulusParameters::prior();
stim.frequency_hz = (latent.peak_hz * 10.0).round() / 10.0;
stim.frequency_hz = stim.frequency_hz.clamp(36.0, 44.0);
let mut open = RufloGovernor::enroll(&id, env, &[], Consent::Granted).unwrap();
let o = open.run_session(&sim, &latent, &state, &stim, 0).unwrap();
open_sum += o.outcome.entrainment_score;
let mut sham = RufloGovernor::enroll(&id, env, &[], Consent::Granted).unwrap();
sham.set_mode(TrialMode::Sham);
let s = sham.run_session(&sim, &latent, &state, &stim, 0).unwrap();
sham_sum += s.outcome.entrainment_score;
}
assert!(open_sum > sham_sum);
}
}
+161
View File
@@ -0,0 +1,161 @@
//! Small, dependency-light deterministic numerics shared across modules.
//!
//! All functions are pure `f64` and deterministic — no global state, no
//! randomness. Kept intentionally tiny so the Gaussian-process surrogate
//! (`optimizer.rs`) and the LinUCB bandit (`bandit.rs`) do not pull in a
//! linear-algebra crate that could perturb the witness with its own float
//! reassociation choices.
/// Standard-normal PDF φ(z).
#[inline]
pub fn normal_pdf(z: f64) -> f64 {
const INV_SQRT_2PI: f64 = 0.398_942_280_401_432_7;
INV_SQRT_2PI * (-0.5 * z * z).exp()
}
/// Error function via Abramowitz & Stegun 7.1.26 (max abs error ≈ 1.5e-7).
///
/// Deterministic and branch-stable, so the witness is portable across targets.
#[inline]
pub fn erf(x: f64) -> f64 {
let sign = if x < 0.0 { -1.0 } else { 1.0 };
let x = x.abs();
let t = 1.0 / (1.0 + 0.327_591_1 * x);
let y = 1.0
- (((((1.061_405_429 * t - 1.453_152_027) * t) + 1.421_413_741) * t - 0.284_496_736) * t
+ 0.254_829_592)
* t
* (-x * x).exp();
sign * y
}
/// Standard-normal CDF Φ(z).
#[inline]
pub fn normal_cdf(z: f64) -> f64 {
0.5 * (1.0 + erf(z / std::f64::consts::SQRT_2))
}
/// Dot product. Panics if lengths differ (caller invariant).
#[inline]
pub fn dot(a: &[f64], b: &[f64]) -> f64 {
debug_assert_eq!(a.len(), b.len());
a.iter().zip(b).map(|(x, y)| x * y).sum()
}
/// Squared-exponential (RBF) kernel k(a,b) = σ_f² · exp(−‖ab‖²/(2ℓ²)).
#[inline]
pub fn rbf_kernel(a: &[f64], b: &[f64], length_scale: f64, signal_var: f64) -> f64 {
let d2: f64 = a.iter().zip(b).map(|(x, y)| (x - y) * (x - y)).sum();
signal_var * (-0.5 * d2 / (length_scale * length_scale)).exp()
}
/// Cholesky factor L (lower-triangular) of a symmetric positive-definite
/// matrix `a` stored row-major `n×n`. Returns `None` if not SPD (a non-positive
/// pivot), which the caller treats as "fall back to the prior mean".
pub fn cholesky(a: &[f64], n: usize) -> Option<Vec<f64>> {
let mut l = vec![0.0f64; n * n];
for i in 0..n {
for j in 0..=i {
let mut sum = a[i * n + j];
for k in 0..j {
sum -= l[i * n + k] * l[j * n + k];
}
if i == j {
if sum <= 0.0 {
return None;
}
l[i * n + j] = sum.sqrt();
} else {
l[i * n + j] = sum / l[j * n + j];
}
}
}
Some(l)
}
/// Solve `L y = b` (forward substitution) for lower-triangular `L` (`n×n`).
pub fn forward_subst(l: &[f64], b: &[f64], n: usize) -> Vec<f64> {
let mut y = vec![0.0f64; n];
for i in 0..n {
let mut sum = b[i];
for k in 0..i {
sum -= l[i * n + k] * y[k];
}
y[i] = sum / l[i * n + i];
}
y
}
/// Solve `Lᵀ x = y` (back substitution) for lower-triangular `L` (`n×n`).
pub fn back_subst_transpose(l: &[f64], y: &[f64], n: usize) -> Vec<f64> {
let mut x = vec![0.0f64; n];
for i in (0..n).rev() {
let mut sum = y[i];
for k in (i + 1)..n {
sum -= l[k * n + i] * x[k];
}
x[i] = sum / l[i * n + i];
}
x
}
/// Clamp helper that is explicit about non-finite handling: any non-finite
/// input (NaN **or** ±∞) maps to `lo`, so a degenerate value can never escape
/// the safety envelope upward toward `hi`.
#[inline]
pub fn clamp_safe(v: f64, lo: f64, hi: f64) -> f64 {
if !v.is_finite() {
lo
} else {
v.max(lo).min(hi)
}
}
#[cfg(test)]
mod tests {
use super::*;
use approx::assert_abs_diff_eq;
#[test]
fn erf_known_values() {
assert_abs_diff_eq!(erf(0.0), 0.0, epsilon = 1e-9);
assert_abs_diff_eq!(erf(1.0), 0.842_700_79, epsilon = 1e-6);
assert_abs_diff_eq!(erf(-1.0), -0.842_700_79, epsilon = 1e-6);
}
#[test]
fn normal_cdf_symmetry() {
assert_abs_diff_eq!(normal_cdf(0.0), 0.5, epsilon = 1e-9);
assert_abs_diff_eq!(normal_cdf(1.96), 0.975, epsilon = 1e-3);
assert_abs_diff_eq!(normal_cdf(-1.96), 0.025, epsilon = 1e-3);
}
#[test]
fn cholesky_solves_spd_system() {
// A = [[4,2],[2,3]], b=[1,1] -> x = A^-1 b
let a = vec![4.0, 2.0, 2.0, 3.0];
let l = cholesky(&a, 2).expect("SPD");
let b = vec![1.0, 1.0];
let y = forward_subst(&l, &b, 2);
let x = back_subst_transpose(&l, &y, 2);
// Verify A x = b
assert_abs_diff_eq!(4.0 * x[0] + 2.0 * x[1], 1.0, epsilon = 1e-9);
assert_abs_diff_eq!(2.0 * x[0] + 3.0 * x[1], 1.0, epsilon = 1e-9);
}
#[test]
fn cholesky_rejects_non_spd() {
// Indefinite matrix
let a = vec![1.0, 2.0, 2.0, 1.0];
assert!(cholesky(&a, 2).is_none());
}
#[test]
fn clamp_safe_maps_non_finite_to_low() {
assert_eq!(clamp_safe(f64::NAN, 0.1, 0.9), 0.1);
assert_eq!(clamp_safe(f64::INFINITY, 0.1, 0.9), 0.1);
assert_eq!(clamp_safe(f64::NEG_INFINITY, 0.1, 0.9), 0.1);
assert_eq!(clamp_safe(1.5, 0.1, 0.9), 0.9);
assert_eq!(clamp_safe(-1.0, 0.1, 0.9), 0.1);
}
}
+228
View File
@@ -0,0 +1,228 @@
//! 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());
}
}
+372
View File
@@ -0,0 +1,372 @@
//! Constrained optimizer — Phase 1 calibration, Phase 2 Bayesian optimization,
//! Phase 4 closed-loop control (ADR-250 §8).
//!
//! **Invariant (enforced by construction and asserted in tests):** every
//! [`StimulusParameters`] this module emits satisfies
//! [`SafetyEnvelope::contains`]. The optimizer searches *frequency* over the
//! envelope's 0.1 Hz grid (the ±0.1 Hz control spec, ADR-250 §18) while holding
//! intensity at conservative values — it never widens the envelope (ADR-250 §12).
use crate::math::{
back_subst_transpose, cholesky, dot, forward_subst, normal_cdf, normal_pdf, rbf_kernel,
};
use crate::stimulus::{SafetyEnvelope, StimulusParameters};
/// Phase-1 conservative calibration sweep (ADR-250 §8): short sessions at each
/// integer Hz in the band. Hands its results to the Bayesian optimizer.
#[derive(Debug, Clone)]
pub struct CalibrationPlan {
frequencies: Vec<f64>,
next: usize,
}
impl CalibrationPlan {
pub fn new(envelope: &SafetyEnvelope) -> Self {
Self {
frequencies: envelope.calibration_frequencies(),
next: 0,
}
}
/// Number of calibration sessions still pending.
pub fn remaining(&self) -> usize {
self.frequencies.len().saturating_sub(self.next)
}
/// The next calibration stimulus (short duration, conservative intensity),
/// or `None` when the sweep is complete. Always inside the envelope.
pub fn next_stimulus(
&mut self,
envelope: &SafetyEnvelope,
session_minutes: f64,
) -> Option<StimulusParameters> {
let f = *self.frequencies.get(self.next)?;
self.next += 1;
let mut s = StimulusParameters::prior();
s.frequency_hz = f;
s.duration_minutes = session_minutes;
Some(envelope.clamp(s))
}
}
/// Gaussian-process surrogate over the 1-D frequency axis with an
/// Expected-Improvement acquisition (ADR-250 §8 Phase 2).
#[derive(Debug, Clone)]
pub struct BayesianOptimizer {
/// RBF length scale in Hz.
pub length_scale: f64,
/// GP signal variance.
pub signal_var: f64,
/// Observation noise variance (jitter; also keeps K SPD).
pub noise_var: f64,
/// EI exploration margin.
pub xi: f64,
/// Observed `(frequency_hz, score)` pairs.
obs_x: Vec<f64>,
obs_y: Vec<f64>,
}
impl Default for BayesianOptimizer {
fn default() -> Self {
Self {
length_scale: 1.5,
signal_var: 1.0,
noise_var: 1e-4,
xi: 0.01,
obs_x: Vec::new(),
obs_y: Vec::new(),
}
}
}
impl BayesianOptimizer {
/// Record a calibration/optimization result.
pub fn observe(&mut self, frequency_hz: f64, score: f64) {
self.obs_x.push(frequency_hz);
self.obs_y.push(score);
}
/// Number of observations so far.
pub fn n_obs(&self) -> usize {
self.obs_x.len()
}
/// Best observed score, or `None` if no observations.
pub fn best(&self) -> Option<(f64, f64)> {
let mut best: Option<(f64, f64)> = None;
for (&x, &y) in self.obs_x.iter().zip(&self.obs_y) {
if best.map(|(_, by)| y > by).unwrap_or(true) {
best = Some((x, y));
}
}
best
}
/// GP posterior `(mean, variance)` at `x`. Falls back to `(0, signal_var)`
/// (the prior) when there are no observations or K is not SPD.
pub fn predict(&self, x: f64) -> (f64, f64) {
let n = self.obs_x.len();
if n == 0 {
return (0.0, self.signal_var);
}
// K = RBF(X,X) + noise·I
let mut k = vec![0.0f64; n * n];
for i in 0..n {
for j in 0..n {
let mut v = rbf_kernel(
&[self.obs_x[i]],
&[self.obs_x[j]],
self.length_scale,
self.signal_var,
);
if i == j {
v += self.noise_var;
}
k[i * n + j] = v;
}
}
let l = match cholesky(&k, n) {
Some(l) => l,
None => return (0.0, self.signal_var),
};
// k* = RBF(X, x)
let kstar: Vec<f64> = (0..n)
.map(|i| rbf_kernel(&[self.obs_x[i]], &[x], self.length_scale, self.signal_var))
.collect();
// alpha = K^-1 y (solve L Lᵀ alpha = y)
let y1 = forward_subst(&l, &self.obs_y, n);
let alpha = back_subst_transpose(&l, &y1, n);
let mean = dot(&kstar, &alpha);
// var = k(x,x) - v·v, where L v = k*
let v = forward_subst(&l, &kstar, n);
let var = (self.signal_var - dot(&v, &v)).max(0.0);
(mean, var)
}
/// Expected Improvement (for maximization) at `x`.
pub fn expected_improvement(&self, x: f64) -> f64 {
let best = match self.best() {
Some((_, by)) => by,
None => return self.signal_var.sqrt(), // pure exploration
};
let (mu, var) = self.predict(x);
let sigma = var.sqrt();
if sigma <= 1e-12 {
return 0.0;
}
let imp = mu - best - self.xi;
let z = imp / sigma;
imp * normal_cdf(z) + sigma * normal_pdf(z)
}
/// Recommend the next stimulus by maximizing EI over the envelope's 0.1 Hz
/// grid, holding `base`'s intensity (clamped). The result is guaranteed
/// inside the envelope. With no observations it returns the 40 Hz prior.
pub fn recommend(
&self,
envelope: &SafetyEnvelope,
base: &StimulusParameters,
) -> Recommendation {
if self.obs_x.is_empty() {
let s = envelope.clamp(*base);
return Recommendation {
stimulus: s,
expected_improvement: 0.0,
predicted_score: 0.0,
confidence: 0.0,
};
}
let grid = fine_grid(envelope);
let mut best_f = base.frequency_hz;
let mut best_ei = f64::NEG_INFINITY;
for &f in &grid {
let ei = self.expected_improvement(f);
if ei > best_ei {
best_ei = ei;
best_f = f;
}
}
let (mu, var) = self.predict(best_f);
let mut s = *base;
s.frequency_hz = best_f;
let s = envelope.clamp(s);
Recommendation {
stimulus: s,
expected_improvement: best_ei.max(0.0),
predicted_score: mu,
// Confidence shrinks with posterior variance (more data near the
// pick → tighter → higher confidence), squashed to [0,1].
confidence: 1.0 / (1.0 + var.sqrt()),
}
}
}
/// A recommendation plus its explainability fields (ADR-250 §14 response,
/// §16 "explainable recommendation").
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Recommendation {
pub stimulus: StimulusParameters,
pub expected_improvement: f64,
pub predicted_score: f64,
pub confidence: f64,
}
/// Phase-4 closed-loop controller (ADR-250 §8). Applies bounded mid-session
/// adjustments; every output is re-clamped to the envelope.
#[derive(Debug, Clone, Copy)]
pub struct ClosedLoopController {
/// Max single-step frequency nudge in Hz (kept small for safety).
pub max_freq_step_hz: f64,
/// Entrainment below this triggers a corrective nudge.
pub entrainment_floor: f64,
/// Comfort below this triggers an intensity reduction.
pub comfort_floor: f64,
/// Multiplicative intensity reduction on discomfort.
pub intensity_backoff: f64,
}
impl Default for ClosedLoopController {
fn default() -> Self {
Self {
max_freq_step_hz: 0.5,
entrainment_floor: 0.3,
comfort_floor: 0.5,
intensity_backoff: 0.8,
}
}
}
/// One closed-loop action recommendation.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum LoopAction {
/// Continue unchanged.
Hold,
/// Adjust to a new (already envelope-clamped) stimulus.
Adjust(StimulusParameters),
}
impl ClosedLoopController {
/// Decide the next in-session action from live entrainment/comfort.
/// `gradient_sign` indicates which frequency direction recently improved
/// entrainment (+1 up, 1 down, 0 unknown); the nudge respects it.
pub fn step(
&self,
envelope: &SafetyEnvelope,
current: &StimulusParameters,
live_entrainment: f64,
live_comfort: f64,
gradient_sign: f64,
) -> LoopAction {
// Comfort first: discomfort always reduces intensity (safety-leaning).
if live_comfort < self.comfort_floor {
let mut s = *current;
s.brightness_level *= self.intensity_backoff;
s.volume_level *= self.intensity_backoff;
return LoopAction::Adjust(envelope.clamp(s));
}
// Entrainment fading: small bounded frequency nudge toward improvement.
if live_entrainment < self.entrainment_floor {
let dir = if gradient_sign >= 0.0 { 1.0 } else { -1.0 };
let mut s = *current;
s.frequency_hz += dir * self.max_freq_step_hz;
return LoopAction::Adjust(envelope.clamp(s));
}
LoopAction::Hold
}
}
/// 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;
(lo..=hi).map(|i| i as f64 / 10.0).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn calibration_sweep_covers_band_and_stays_safe() {
let env = SafetyEnvelope::conservative();
let mut plan = CalibrationPlan::new(&env);
let mut seen = Vec::new();
while let Some(s) = plan.next_stimulus(&env, 5.0) {
assert!(env.contains(&s));
seen.push(s.frequency_hz);
}
assert_eq!(seen.first(), Some(&36.0));
assert_eq!(seen.last(), Some(&44.0));
assert_eq!(plan.remaining(), 0);
}
#[test]
fn gp_recovers_a_quadratic_peak() {
// Synthetic score surface peaked at 39.5 Hz.
let env = SafetyEnvelope::conservative();
let mut bo = BayesianOptimizer::default();
let truth = |f: f64| 1.0 - 0.05 * (f - 39.5).powi(2);
for f in env.calibration_frequencies() {
bo.observe(f, truth(f));
}
let rec = bo.recommend(&env, &StimulusParameters::prior());
assert!(env.contains(&rec.stimulus));
// Should land within ±1 Hz of the true peak (ADR-250 §18 repeatability).
assert!((rec.stimulus.frequency_hz - 39.5).abs() <= 1.0);
}
#[test]
fn recommendation_is_always_in_envelope() {
let env = SafetyEnvelope::conservative();
let mut bo = BayesianOptimizer::default();
// Adversarial: pretend the band edge is best.
for f in env.calibration_frequencies() {
bo.observe(f, if f >= 44.0 { 10.0 } else { 0.0 });
}
let rec = bo.recommend(&env, &StimulusParameters::prior());
assert!(env.contains(&rec.stimulus));
assert!(rec.stimulus.frequency_hz <= env.max_hz);
}
#[test]
fn no_observations_returns_prior() {
let env = SafetyEnvelope::conservative();
let bo = BayesianOptimizer::default();
let rec = bo.recommend(&env, &StimulusParameters::prior());
assert_eq!(rec.stimulus.frequency_hz, 40.0);
}
#[test]
fn closed_loop_reduces_intensity_on_discomfort() {
let env = SafetyEnvelope::conservative();
let ctl = ClosedLoopController::default();
let cur = StimulusParameters::prior();
match ctl.step(&env, &cur, 0.6, 0.2, 0.0) {
LoopAction::Adjust(s) => {
assert!(s.brightness_level < cur.brightness_level);
assert!(env.contains(&s));
}
LoopAction::Hold => panic!("should have adjusted intensity"),
}
}
#[test]
fn closed_loop_nudge_stays_in_envelope() {
let env = SafetyEnvelope::conservative();
let ctl = ClosedLoopController::default();
let mut cur = StimulusParameters::prior();
cur.frequency_hz = 43.8; // near the upper edge
match ctl.step(&env, &cur, 0.1, 0.9, 1.0) {
LoopAction::Adjust(s) => assert!(env.contains(&s)),
LoopAction::Hold => {}
}
}
#[test]
fn closed_loop_holds_when_healthy() {
let env = SafetyEnvelope::conservative();
let ctl = ClosedLoopController::default();
let cur = StimulusParameters::prior();
assert_eq!(ctl.step(&env, &cur, 0.8, 0.9, 0.0), LoopAction::Hold);
}
}
+75
View File
@@ -0,0 +1,75 @@
//! Deterministic proof bundle (mirrors the `nvsim` / `verify.py` pattern).
//!
//! Runs a fixed reference participant through the full governed pipeline
//! (enroll → calibration sweep → witnessed audit) and hashes the resulting
//! session witnesses into a single bundle digest. If the digest matches
//! [`Proof::EXPECTED_WITNESS`], the optimizer math, simulator physics, response
//! update, and session-hashing code paths are all byte-identical to the
//! 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::simulator::{stable_hash, LatentPerson, ResponseSimulator};
use crate::stimulus::SafetyEnvelope;
/// Deterministic-proof harness for `ruview-gamma`.
pub struct Proof;
impl Proof {
/// Reference participant id (drives the latent physiology).
pub const PERSON_ID: &'static str = "reference-subject-000";
/// Reference simulator seed.
pub const SEED: u64 = 42;
/// SHA-256 (hex) over the concatenated session witnesses of the reference
/// calibration run. Pinned so CI catches any drift.
pub const EXPECTED_WITNESS: &'static str =
"13cb164cc3b3b02da8cdfbb5c23fdd07431c58498396d75a3d9a470305981758";
/// Run the reference scenario and return its bundle witness (hex SHA-256).
pub fn reference_witness() -> String {
let envelope = SafetyEnvelope::conservative();
let mut gov = RufloGovernor::enroll(Self::PERSON_ID, envelope, &[], Consent::Granted)
.expect("reference participant enrolls cleanly");
let sim = ResponseSimulator::new(Self::SEED);
let latent = LatentPerson::from_id(Self::PERSON_ID);
let state = RuViewState::calm_baseline();
gov.run_calibration(&sim, &latent, &state, 5.0, 1_700_000_000_000)
.expect("reference calibration runs");
// Concatenate every session witness in order, then hash the bundle.
let mut chunks: Vec<&[u8]> = Vec::new();
for rec in gov.audit_log() {
chunks.push(rec.session_hash.as_bytes());
}
let digest = stable_hash(&chunks);
hex(&digest)
}
}
fn hex(bytes: &[u8]) -> String {
let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes {
s.push_str(&format!("{:02x}", b));
}
s
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reference_witness_is_deterministic() {
assert_eq!(Proof::reference_witness(), Proof::reference_witness());
}
#[test]
fn reference_witness_matches_expected() {
// If this fails after an intentional change, regenerate the constant
// from the test output and document the change in CHANGELOG.
assert_eq!(Proof::reference_witness(), Proof::EXPECTED_WITNESS);
}
}
+346
View File
@@ -0,0 +1,346 @@
//! Personal response vector and session inputs (ADR-250 §6, §9, §10).
//!
//! This is the RuVector layer's data model. The 20-field
//! [`PersonResponseVector`] is the compact adaptive memory updated after each
//! session via [`PersonResponseVector::update`]
//! (`R_{t+1} = update(R_t, stimulus_t, response_t, safety_t)`, ADR-250 §6).
use serde::{Deserialize, Serialize};
use crate::math::clamp_safe;
use crate::stimulus::StimulusParameters;
/// Coarse posture class from RuView passive sensing (ADR-250 §9).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Posture {
Seated,
Reclined,
Supine,
Standing,
Unknown,
}
/// Coarse sleep / arousal proxy (ADR-250 §9 item 7).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SleepState {
QuietWake,
Drowsy,
Asleep,
Active,
Unknown,
}
/// Passive, non-camera RuView state for a session (ADR-250 §9, §13
/// `ruview_state`). All scores are `[0,1]` unless noted.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct RuViewState {
/// Breaths per minute.
pub breathing_rate: f64,
/// Regularity of the breathing waveform `[0,1]`.
pub breathing_stability: f64,
/// Fraction of frames corrupted by motion `[0,1]` (lower is better).
pub motion_artifact: f64,
pub posture: Posture,
/// Fraction of the session the body was still `[0,1]`.
pub stillness_score: f64,
/// Fidgeting / restlessness `[0,1]`.
pub restlessness_score: f64,
pub sleep_state: SleepState,
/// Person was present for the session `[0,1]` adherence proxy.
pub adherence: f64,
/// Aggregate RuView sensing confidence `[0,1]`.
pub sensor_confidence: f64,
}
impl RuViewState {
/// A calm, well-sensed seated baseline — used as a neutral default in
/// simulation and tests.
pub fn calm_baseline() -> Self {
Self {
breathing_rate: 13.0,
breathing_stability: 0.85,
motion_artifact: 0.05,
posture: Posture::Seated,
stillness_score: 0.9,
restlessness_score: 0.1,
sleep_state: SleepState::QuietWake,
adherence: 1.0,
sensor_confidence: 0.9,
}
}
}
/// Optional direct EEG entrainment measurement (ADR-250 §13 `eeg_optional`).
/// When absent, the optimizer relies on RF-derived proxies only and must not
/// claim verified neural entrainment (ADR-250 §16 negative consequence 3).
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct EegMeasurement {
/// Gamma-band power gain over baseline `[0,1]`.
pub gamma_power_gain: f64,
/// Phase-locking value `[0,1]`.
pub phase_locking_value: f64,
/// EEG artifact fraction `[0,1]` (lower is better).
pub artifact_score: f64,
}
/// Participant-reported subjective state (ADR-250 §13 `subjective`).
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct SubjectiveReport {
pub comfort: f64,
pub fatigue: f64,
}
impl Default for SubjectiveReport {
fn default() -> Self {
Self {
comfort: 0.85,
fatigue: 0.2,
}
}
}
/// Everything observed during one session, the input to the response update.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct SessionObservation {
pub stimulus: StimulusParameters,
pub ruview: RuViewState,
pub eeg: Option<EegMeasurement>,
pub subjective: SubjectiveReport,
/// `false` if any safety stop fired this session.
pub safety_pass: bool,
/// `true` if an adverse event was recorded (sticky into the vector).
pub adverse_event: bool,
}
/// The 20-field adaptive personal response vector (ADR-250 §6). Stored as named
/// fields for clarity; [`as_array`](Self::as_array) gives the flat ordered
/// representation used for nearest-neighbor and clustering in RuVector.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct PersonResponseVector {
pub baseline_gamma: f64,
pub baseline_alpha: f64,
pub alpha_gamma_ratio: f64,
pub gamma_power_gain: f64,
pub phase_locking_value: f64,
pub breathing_rate: f64,
pub breathing_stability: f64,
pub motion_artifact: f64,
/// Posture encoded as an ordinal `[0,1]` for vector math.
pub posture_state: f64,
/// Sleep state encoded as an ordinal `[0,1]`.
pub sleep_state: f64,
pub restlessness_score: f64,
pub stimulus_frequency: f64,
pub brightness_level: f64,
pub sound_level: f64,
pub duty_cycle: f64,
pub phase_offset: f64,
pub session_duration: f64,
pub comfort_score: f64,
pub adherence_score: f64,
/// 1.0 once any adverse event has ever occurred for this person (sticky).
pub adverse_event_flag: f64,
}
impl PersonResponseVector {
/// Exponential-moving-average weight for session-to-session updates. Small
/// so a single noisy session cannot dominate (ADR-250 §16 risk
/// "Over-optimization", mitigation "conservative priors").
pub const EMA_ALPHA: f64 = 0.3;
/// Initialize from a baseline reading before any stimulation.
pub fn baseline(baseline_gamma: f64, baseline_alpha: f64, ruview: &RuViewState) -> Self {
let ratio = if baseline_gamma > 1e-9 {
baseline_alpha / baseline_gamma
} else {
0.0
};
Self {
baseline_gamma,
baseline_alpha,
alpha_gamma_ratio: ratio,
gamma_power_gain: 0.0,
phase_locking_value: 0.0,
breathing_rate: ruview.breathing_rate,
breathing_stability: ruview.breathing_stability,
motion_artifact: ruview.motion_artifact,
posture_state: posture_ordinal(ruview.posture),
sleep_state: sleep_ordinal(ruview.sleep_state),
restlessness_score: ruview.restlessness_score,
stimulus_frequency: 40.0,
brightness_level: 0.0,
sound_level: 0.0,
duty_cycle: 0.0,
phase_offset: 0.0,
session_duration: 0.0,
comfort_score: 0.85,
adherence_score: ruview.adherence,
adverse_event_flag: 0.0,
}
}
/// Flat ordered array (ADR-250 §6 field order) for RuVector similarity ops.
pub fn as_array(&self) -> [f64; 20] {
[
self.baseline_gamma,
self.baseline_alpha,
self.alpha_gamma_ratio,
self.gamma_power_gain,
self.phase_locking_value,
self.breathing_rate,
self.breathing_stability,
self.motion_artifact,
self.posture_state,
self.sleep_state,
self.restlessness_score,
self.stimulus_frequency,
self.brightness_level,
self.sound_level,
self.duty_cycle,
self.phase_offset,
self.session_duration,
self.comfort_score,
self.adherence_score,
self.adverse_event_flag,
]
}
/// `R_{t+1} = update(R_t, stimulus_t, response_t, safety_t)` (ADR-250 §6).
///
/// EMA-blends the continuous response fields toward the latest observation;
/// the adverse-event flag is *sticky* (monotonic 0→1) so a person's safety
/// history can never be smoothed away.
pub fn update(&mut self, obs: &SessionObservation) {
let a = Self::EMA_ALPHA;
let blend = |old: f64, new: f64| old + a * (new - old);
if let Some(eeg) = obs.eeg {
self.gamma_power_gain = blend(self.gamma_power_gain, eeg.gamma_power_gain);
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.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);
self.restlessness_score = blend(self.restlessness_score, obs.ruview.restlessness_score);
self.stimulus_frequency = obs.stimulus.frequency_hz;
self.brightness_level = obs.stimulus.brightness_level;
self.sound_level = obs.stimulus.volume_level;
self.duty_cycle = duty_ordinal(obs.stimulus.duty_cycle);
self.phase_offset = obs.stimulus.phase_offset_ms;
self.session_duration = obs.stimulus.duration_minutes;
self.comfort_score = blend(self.comfort_score, obs.subjective.comfort);
self.adherence_score = blend(self.adherence_score, obs.ruview.adherence);
if obs.adverse_event {
self.adverse_event_flag = 1.0; // sticky
}
// Keep all `[0,1]` fields well-formed regardless of upstream noise.
self.clamp_unit_fields();
}
fn clamp_unit_fields(&mut self) {
for f in [
&mut self.gamma_power_gain,
&mut self.phase_locking_value,
&mut self.breathing_stability,
&mut self.motion_artifact,
&mut self.restlessness_score,
&mut self.comfort_score,
&mut self.adherence_score,
] {
*f = clamp_safe(*f, 0.0, 1.0);
}
}
}
fn posture_ordinal(p: Posture) -> f64 {
match p {
Posture::Standing => 0.0,
Posture::Seated => 0.33,
Posture::Reclined => 0.66,
Posture::Supine => 1.0,
Posture::Unknown => 0.5,
}
}
fn sleep_ordinal(s: SleepState) -> f64 {
match s {
SleepState::Active => 0.0,
SleepState::QuietWake => 0.33,
SleepState::Drowsy => 0.66,
SleepState::Asleep => 1.0,
SleepState::Unknown => 0.5,
}
}
fn duty_ordinal(d: crate::stimulus::DutyCycle) -> f64 {
match d {
crate::stimulus::DutyCycle::Continuous => 0.0,
crate::stimulus::DutyCycle::Ramped => 0.5,
crate::stimulus::DutyCycle::Pulsed => 1.0,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::stimulus::StimulusParameters;
fn obs_with_adverse(adverse: bool) -> SessionObservation {
SessionObservation {
stimulus: StimulusParameters::prior(),
ruview: RuViewState::calm_baseline(),
eeg: Some(EegMeasurement {
gamma_power_gain: 0.4,
phase_locking_value: 0.6,
artifact_score: 0.05,
}),
subjective: SubjectiveReport::default(),
safety_pass: !adverse,
adverse_event: adverse,
}
}
#[test]
fn vector_has_twenty_fields() {
let v = PersonResponseVector::baseline(0.2, 0.5, &RuViewState::calm_baseline());
assert_eq!(v.as_array().len(), 20);
}
#[test]
fn update_moves_gamma_toward_observation() {
let mut v = PersonResponseVector::baseline(0.2, 0.5, &RuViewState::calm_baseline());
let before = v.gamma_power_gain;
v.update(&obs_with_adverse(false));
assert!(v.gamma_power_gain > before);
assert!(v.gamma_power_gain < 0.4); // EMA, not a jump to the target
}
#[test]
fn adverse_flag_is_sticky() {
let mut v = PersonResponseVector::baseline(0.2, 0.5, &RuViewState::calm_baseline());
v.update(&obs_with_adverse(true));
assert_eq!(v.adverse_event_flag, 1.0);
// A subsequent clean session must NOT clear the flag.
v.update(&obs_with_adverse(false));
assert_eq!(v.adverse_event_flag, 1.0);
}
#[test]
fn unit_fields_stay_bounded_under_noise() {
let mut v = PersonResponseVector::baseline(0.2, 0.5, &RuViewState::calm_baseline());
let mut obs = obs_with_adverse(false);
obs.eeg = Some(EegMeasurement {
gamma_power_gain: 99.0,
phase_locking_value: -5.0,
artifact_score: 0.0,
});
v.update(&obs);
assert!(v.gamma_power_gain <= 1.0);
assert!(v.phase_locking_value >= 0.0);
}
}
+415
View File
@@ -0,0 +1,415 @@
//! RuFlo governance layer (ADR-250 §11).
//!
//! The governor is the only public entry point that *runs* sessions. It
//! enforces, in order: consent → inclusion/exclusion screen → envelope check →
//! simulated/observed session → safety monitor → objective score → RuVector
//! update → witnessed audit record. It also owns trial-mode separation (sham /
//! blinding) and the **claim-discipline** statement (ADR-250 §18: "no disease
//! treatment claim").
use crate::objective::{SafeEntrainmentObjective, ScoreInputs};
use crate::optimizer::{BayesianOptimizer, CalibrationPlan, Recommendation};
use crate::response::{PersonResponseVector, RuViewState, SessionObservation, SubjectiveReport};
use crate::safety::{
ExclusionCondition, ExclusionScreen, SafetyMonitor, SafetyTick, ScreenOutcome, StopReason,
};
use crate::session::{Outcome, SessionBuilder, SessionRecord, VersionTriple};
use crate::simulator::{LatentPerson, ResponseSimulator};
use crate::stimulus::{SafetyEnvelope, StimulusParameters};
/// The single, immutable product claim (ADR-250 §22). Exposed so any UI/report
/// can render exactly this and nothing stronger.
pub const PRODUCT_CLAIM: &str = "personalized entrainment optimization";
/// Consent state (ADR-250 §11 RuFlo responsibility 2).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Consent {
Granted,
Withdrawn,
}
/// Trial mode for controlled studies (ADR-250 §21 Milestone 6).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TrialMode {
/// Normal operation: real stimulation, adaptive optimization.
Open,
/// Sham: the participant-facing protocol is logged, but no entrainment is
/// delivered (blinding). Outcomes show the no-treatment baseline.
Sham,
}
/// Governance refusals — every one is a *safe* refusal (fail closed).
#[derive(Debug, thiserror::Error, PartialEq)]
pub enum GovernanceError {
#[error("participant is excluded from unsupervised use: {0:?}")]
Excluded(Vec<ExclusionCondition>),
#[error("clinical supervision required for: {0:?}")]
SupervisionRequired(Vec<ExclusionCondition>),
#[error("consent not granted (or withdrawn)")]
NoConsent,
#[error("requested stimulus is outside the approved safety envelope")]
OutsideEnvelope,
}
/// 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,
}
/// The governed adaptive-gamma protocol runner for one participant.
pub struct RufloGovernor {
person_id: String,
envelope: SafetyEnvelope,
objective: SafeEntrainmentObjective,
optimizer: BayesianOptimizer,
response: PersonResponseVector,
versions: VersionTriple,
consent: Consent,
mode: TrialMode,
confidence_floor: f64,
audit: Vec<SessionRecord>,
next_index: u64,
}
impl RufloGovernor {
/// Enroll a participant. Fails closed on exclusion or missing consent.
pub fn enroll(
person_id: impl Into<String>,
envelope: SafetyEnvelope,
conditions: &[ExclusionCondition],
consent: Consent,
) -> Result<Self, GovernanceError> {
if consent != Consent::Granted {
return Err(GovernanceError::NoConsent);
}
match ExclusionScreen.evaluate(conditions) {
ScreenOutcome::Excluded(c) => return Err(GovernanceError::Excluded(c)),
ScreenOutcome::RequiresClinicalSupervision(c) => {
return Err(GovernanceError::SupervisionRequired(c))
}
ScreenOutcome::Cleared => {}
}
let baseline_ruview = RuViewState::calm_baseline();
Ok(Self {
person_id: person_id.into(),
objective: SafeEntrainmentObjective::new(Default::default(), envelope),
optimizer: BayesianOptimizer::default(),
response: PersonResponseVector::baseline(0.2, 0.5, &baseline_ruview),
versions: VersionTriple::default(),
consent,
mode: TrialMode::Open,
confidence_floor: 0.5,
envelope,
audit: Vec::new(),
next_index: 0,
})
}
/// Switch trial mode (e.g., to `Sham` for a blinded arm).
pub fn set_mode(&mut self, mode: TrialMode) {
self.mode = mode;
}
/// Withdraw consent — all subsequent `run_session` calls fail closed.
pub fn withdraw_consent(&mut self) {
self.consent = Consent::Withdrawn;
}
/// Immutable view of the audit trail (every session is witnessed).
pub fn audit_log(&self) -> &[SessionRecord] {
&self.audit
}
/// Current personal response vector (RuVector memory).
pub fn response_vector(&self) -> &PersonResponseVector {
&self.response
}
/// Run the Phase-1 calibration sweep against a simulated participant,
/// recording every session and seeding the optimizer.
pub fn run_calibration(
&mut self,
sim: &ResponseSimulator,
latent: &LatentPerson,
state: &RuViewState,
session_minutes: f64,
base_timestamp_ms: u64,
) -> 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)?;
}
Ok(())
}
/// Recommend the next protocol given the current state (ADR-250 §14).
pub fn recommend(&self, base: &StimulusParameters) -> Recommendation {
self.optimizer.recommend(&self.envelope, base)
}
/// Run one governed session end-to-end. Returns the witnessed record.
///
/// Fails closed if consent is absent or the stimulus is outside the
/// envelope. Any safety stop is logged into the record (ADR-250 §18).
pub fn run_session(
&mut self,
sim: &ResponseSimulator,
latent: &LatentPerson,
state: &RuViewState,
stimulus: &StimulusParameters,
timestamp_ms: u64,
) -> Result<SessionRecord, GovernanceError> {
if self.consent != Consent::Granted {
return Err(GovernanceError::NoConsent);
}
if !self.envelope.contains(stimulus) {
return Err(GovernanceError::OutsideEnvelope);
}
let idx = self.next_index;
self.next_index += 1;
// --- Observe (simulated) response. ---
let mut resp = sim.simulate(latent, state, stimulus, idx);
if self.mode == TrialMode::Sham {
// Blinding: no entrainment is actually delivered.
resp.eeg.gamma_power_gain *= 0.05;
resp.eeg.phase_locking_value *= 0.05;
}
// --- Safety monitor over the (single-summary) tick. ---
let mut monitor = SafetyMonitor::new(self.confidence_floor);
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 safety_pass = !safety_events.iter().any(StopReason::is_safety_stop);
// --- Score the session. ---
let subjective = SubjectiveReport {
comfort: resp.comfort,
fatigue: 0.2,
};
let score = self.objective.score(&ScoreInputs {
stimulus,
ruview: &resp.ruview,
eeg: Some(&resp.eeg),
subjective: &subjective,
adverse_event_risk: if resp.adverse_event { 1.0 } else { 0.0 },
});
// --- Feed the optimizer only when the session was safe. ---
if safety_pass {
self.optimizer.observe(stimulus.frequency_hz, score);
}
// --- Update RuVector memory. ---
self.response.update(&SessionObservation {
stimulus: *stimulus,
ruview: resp.ruview,
eeg: Some(resp.eeg),
subjective,
safety_pass,
adverse_event: resp.adverse_event,
});
// --- Recommend next frequency for the record. ---
let next = self.optimizer.recommend(&self.envelope, stimulus);
// --- Witnessed audit record. ---
let record = SessionBuilder::new(
self.person_id.clone(),
self.versions.clone(),
timestamp_ms,
*stimulus,
resp.ruview,
subjective,
Outcome {
entrainment_score: score,
safety_pass,
recommended_next_frequency_hz: next.stimulus.frequency_hz,
},
)
.with_eeg(resp.eeg)
.with_safety_events(safety_events)
.finalize();
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"));
}
}
+284
View File
@@ -0,0 +1,284 @@
//! Safety stop conditions, exclusion screening, and the in-session monitor
//! (ADR-250 §12).
//!
//! Safety is a **hard constraint, not a weighted term** (ADR-250 §7). This
//! module owns the two non-negotiable gates:
//! 1. [`ExclusionScreen`] — who may participate at all.
//! 2. [`SafetyMonitor`] — when an in-progress session must stop.
use serde::{Deserialize, Serialize};
/// Adverse symptoms / events that force an immediate hard stop (ADR-250 §12).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AdverseEvent {
Headache,
Dizziness,
Nausea,
Agitation,
VisualDiscomfort,
AbnormalDistress,
SeizureLikeSymptom,
/// The participant pressed stop.
UserStopRequest,
}
impl AdverseEvent {
pub fn tag(self) -> &'static str {
match self {
AdverseEvent::Headache => "headache",
AdverseEvent::Dizziness => "dizziness",
AdverseEvent::Nausea => "nausea",
AdverseEvent::Agitation => "agitation",
AdverseEvent::VisualDiscomfort => "visual_discomfort",
AdverseEvent::AbnormalDistress => "abnormal_distress",
AdverseEvent::SeizureLikeSymptom => "seizure_like_symptom",
AdverseEvent::UserStopRequest => "user_stop_request",
}
}
}
/// Conditions requiring exclusion or explicit clinical supervision
/// (ADR-250 §12).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ExclusionCondition {
EpilepsyOrSeizureHistory,
Photosensitivity,
SevereMigraineSensitivity,
SeverePsychiatricInstability,
ImplantedNeurologicalDevice,
SignificantSensoryImpairment,
RecentMedicationChange,
}
/// Result of pre-enrollment screening.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum ScreenOutcome {
/// Cleared for unsupervised research participation.
Cleared,
/// May proceed only under clinician supervision (records the conditions).
RequiresClinicalSupervision(Vec<ExclusionCondition>),
/// Hard-excluded (records the conditions).
Excluded(Vec<ExclusionCondition>),
}
/// Inclusion/exclusion screen (ADR-250 §11 RuFlo responsibility 3, §12).
#[derive(Debug, Clone, Default)]
pub struct ExclusionScreen;
impl ExclusionScreen {
/// Apply the screening policy. Epilepsy/seizure and photosensitivity are
/// hard exclusions for unsupervised use because the protocol is, by
/// construction, flicker stimulation; the rest require supervision.
pub fn evaluate(&self, conditions: &[ExclusionCondition]) -> ScreenOutcome {
if conditions.is_empty() {
return ScreenOutcome::Cleared;
}
let hard: Vec<ExclusionCondition> = conditions
.iter()
.copied()
.filter(|c| {
matches!(
c,
ExclusionCondition::EpilepsyOrSeizureHistory
| ExclusionCondition::Photosensitivity
)
})
.collect();
if !hard.is_empty() {
return ScreenOutcome::Excluded(hard);
}
ScreenOutcome::RequiresClinicalSupervision(conditions.to_vec())
}
}
/// Why a session was stopped. `Completed` is the only non-stop terminal state.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", tag = "kind", content = "detail")]
pub enum StopReason {
/// Ran to planned completion — not a safety stop.
Completed,
/// An adverse event was reported.
AdverseEvent(AdverseEvent),
/// Sensor confidence dropped below the floor (entrainment unverifiable).
SensorConfidenceBelowFloor { value: f64, floor: f64 },
/// A requested parameter fell outside the approved envelope.
ProtocolOutsideEnvelope,
}
impl StopReason {
/// `true` for every reason that is a *safety* stop (everything but
/// `Completed`). Used by the audit layer to assert "100% safety stops
/// logged" (ADR-250 §18).
pub fn is_safety_stop(&self) -> bool {
!matches!(self, StopReason::Completed)
}
}
/// Live per-tick session telemetry the monitor evaluates.
#[derive(Debug, Clone, Copy)]
pub struct SafetyTick {
/// Reported adverse event this tick, if any.
pub adverse: Option<AdverseEvent>,
/// Aggregate sensor confidence `[0,1]` (RuView + optional EEG).
pub sensor_confidence: f64,
/// Whether the currently-applied stimulus is inside the envelope.
pub stimulus_in_envelope: bool,
}
/// In-session safety monitor with a configurable confidence floor and a
/// bounded stop latency contract (ADR-250 §17: safety-stop latency < 500 ms).
#[derive(Debug, Clone)]
pub struct SafetyMonitor {
/// Minimum acceptable sensor confidence (ADR-250 §12 condition 9).
pub confidence_floor: f64,
/// Declared worst-case evaluation latency in milliseconds; the monitor is
/// O(1) per tick so the real figure is far below this, but the contract is
/// asserted in tests against ADR-250 §17's 500 ms bound.
pub max_eval_latency_ms: u32,
triggered: Option<StopReason>,
}
impl Default for SafetyMonitor {
fn default() -> Self {
Self {
confidence_floor: 0.5,
max_eval_latency_ms: 50,
triggered: None,
}
}
}
impl SafetyMonitor {
pub fn new(confidence_floor: f64) -> Self {
Self {
confidence_floor,
..Default::default()
}
}
/// Evaluate one tick. Once a stop has fired the monitor is *latched* — it
/// keeps returning the original stop reason so a session can never silently
/// resume after a safety event (closed-loop "terminate and lock",
/// ADR-250 §8 Phase 4).
pub fn evaluate(&mut self, tick: SafetyTick) -> Option<StopReason> {
if let Some(reason) = &self.triggered {
return Some(reason.clone());
}
let reason = if let Some(ev) = tick.adverse {
Some(StopReason::AdverseEvent(ev))
} else if !tick.stimulus_in_envelope {
Some(StopReason::ProtocolOutsideEnvelope)
} else if tick.sensor_confidence < self.confidence_floor {
Some(StopReason::SensorConfidenceBelowFloor {
value: tick.sensor_confidence,
floor: self.confidence_floor,
})
} else {
None
};
if let Some(r) = &reason {
self.triggered = Some(r.clone());
}
reason
}
/// Whether the monitor has latched a stop.
pub fn is_stopped(&self) -> bool {
self.triggered.is_some()
}
/// The latched stop reason, if any.
pub fn stop_reason(&self) -> Option<&StopReason> {
self.triggered.as_ref()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_history_clears() {
assert_eq!(ExclusionScreen.evaluate(&[]), ScreenOutcome::Cleared);
}
#[test]
fn epilepsy_is_hard_excluded() {
let out = ExclusionScreen.evaluate(&[ExclusionCondition::EpilepsyOrSeizureHistory]);
assert!(matches!(out, ScreenOutcome::Excluded(_)));
}
#[test]
fn photosensitivity_is_hard_excluded() {
let out = ExclusionScreen.evaluate(&[ExclusionCondition::Photosensitivity]);
assert!(matches!(out, ScreenOutcome::Excluded(_)));
}
#[test]
fn migraine_requires_supervision() {
let out = ExclusionScreen.evaluate(&[ExclusionCondition::SevereMigraineSensitivity]);
assert!(matches!(out, ScreenOutcome::RequiresClinicalSupervision(_)));
}
#[test]
fn adverse_event_triggers_stop() {
let mut m = SafetyMonitor::default();
let r = m.evaluate(SafetyTick {
adverse: Some(AdverseEvent::Dizziness),
sensor_confidence: 0.9,
stimulus_in_envelope: true,
});
assert_eq!(r, Some(StopReason::AdverseEvent(AdverseEvent::Dizziness)));
assert!(r.unwrap().is_safety_stop());
}
#[test]
fn low_confidence_triggers_stop() {
let mut m = SafetyMonitor::new(0.6);
let r = m.evaluate(SafetyTick {
adverse: None,
sensor_confidence: 0.3,
stimulus_in_envelope: true,
});
assert!(matches!(
r,
Some(StopReason::SensorConfidenceBelowFloor { .. })
));
}
#[test]
fn out_of_envelope_triggers_stop() {
let mut m = SafetyMonitor::default();
let r = m.evaluate(SafetyTick {
adverse: None,
sensor_confidence: 0.9,
stimulus_in_envelope: false,
});
assert_eq!(r, Some(StopReason::ProtocolOutsideEnvelope));
}
#[test]
fn stop_is_latched_and_cannot_resume() {
let mut m = SafetyMonitor::default();
m.evaluate(SafetyTick {
adverse: Some(AdverseEvent::Headache),
sensor_confidence: 0.9,
stimulus_in_envelope: true,
});
// A subsequent "all clear" tick must NOT clear the latch.
let r = m.evaluate(SafetyTick {
adverse: None,
sensor_confidence: 1.0,
stimulus_in_envelope: true,
});
assert_eq!(r, Some(StopReason::AdverseEvent(AdverseEvent::Headache)));
assert!(m.is_stopped());
}
#[test]
fn completed_is_not_a_safety_stop() {
assert!(!StopReason::Completed.is_safety_stop());
}
}
+279
View File
@@ -0,0 +1,279 @@
//! Session record and the reproducible session witness (ADR-250 §11, §13).
//!
//! `session_hash = hash(protocol_version, model_version, device_version,
//! stimulus_parameters, sensor_summary, response_summary, safety_events)`.
//!
//! The hash is computed over a **canonical** serialization (fixed field order,
//! quantized floats) so the same session content always yields the same digest
//! across machines — the RuFlo reproducibility trail. The [`SessionId`] is
//! *derived from* the hash, so identifiers are themselves reproducible (no
//! random UUIDs that would break replay).
use serde::{Deserialize, Serialize};
use crate::response::{EegMeasurement, RuViewState, SubjectiveReport};
use crate::safety::StopReason;
use crate::simulator::stable_hash;
use crate::stimulus::StimulusParameters;
/// Version triple identifying the exact software/hardware that ran a session.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct VersionTriple {
pub protocol_version: String,
pub model_version: String,
pub device_version: String,
}
impl Default for VersionTriple {
fn default() -> Self {
Self {
protocol_version: "adr-250-v0.1".to_string(),
model_version: "ruvector-gamma-v0.1".to_string(),
device_version: "sim-harness-v0.1".to_string(),
}
}
}
/// Per-session outcome summary (ADR-250 §13 `outcome`).
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct Outcome {
pub entrainment_score: f64,
pub safety_pass: bool,
pub recommended_next_frequency_hz: f64,
}
/// A complete, hashable session record (ADR-250 §13).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SessionRecord {
/// Derived from the content hash (see [`SessionRecord::finalize`]).
pub session_id: SessionId,
/// Pseudonymous participant id.
pub person_id: String,
pub versions: VersionTriple,
/// Caller-supplied epoch milliseconds (kept explicit for determinism — no
/// wall-clock reads inside the crate).
pub timestamp_ms: u64,
pub stimulus: StimulusParameters,
pub ruview_state: RuViewState,
pub eeg_optional: Option<EegMeasurement>,
pub subjective: SubjectiveReport,
pub outcome: Outcome,
/// Every safety event raised during the session (ADR-250 §18: 100% logged).
pub safety_events: Vec<StopReason>,
/// The session witness (hex SHA-256).
pub session_hash: String,
}
/// A reproducible session identifier: the first 16 hex chars of the witness.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SessionId(pub String);
/// Builder that gathers session inputs and finalizes them into an immutable,
/// witnessed [`SessionRecord`].
#[derive(Debug, Clone)]
pub struct SessionBuilder {
person_id: String,
versions: VersionTriple,
timestamp_ms: u64,
stimulus: StimulusParameters,
ruview: RuViewState,
eeg: Option<EegMeasurement>,
subjective: SubjectiveReport,
outcome: Outcome,
safety_events: Vec<StopReason>,
}
impl SessionBuilder {
#[allow(clippy::too_many_arguments)]
pub fn new(
person_id: impl Into<String>,
versions: VersionTriple,
timestamp_ms: u64,
stimulus: StimulusParameters,
ruview: RuViewState,
subjective: SubjectiveReport,
outcome: Outcome,
) -> Self {
Self {
person_id: person_id.into(),
versions,
timestamp_ms,
stimulus,
ruview,
eeg: None,
subjective,
outcome,
safety_events: Vec::new(),
}
}
pub fn with_eeg(mut self, eeg: EegMeasurement) -> Self {
self.eeg = Some(eeg);
self
}
pub fn with_safety_events(mut self, events: Vec<StopReason>) -> Self {
self.safety_events = events;
self
}
/// Compute the canonical witness and freeze the record.
pub fn finalize(self) -> SessionRecord {
let canon = self.canonical_bytes();
let digest = stable_hash(&[&canon]);
let hex = hex_encode(&digest);
let session_id = SessionId(hex[..16].to_string());
SessionRecord {
session_id,
person_id: self.person_id,
versions: self.versions,
timestamp_ms: self.timestamp_ms,
stimulus: self.stimulus,
ruview_state: self.ruview,
eeg_optional: self.eeg,
subjective: self.subjective,
outcome: self.outcome,
safety_events: self.safety_events,
session_hash: hex,
}
}
/// Canonical byte serialization in the ADR-250 §11 hash field order.
/// Floats are quantized so last-bit jitter never forks the witness.
fn canonical_bytes(&self) -> Vec<u8> {
let mut s = String::new();
s.push_str(&self.versions.protocol_version);
s.push('|');
s.push_str(&self.versions.model_version);
s.push('|');
s.push_str(&self.versions.device_version);
s.push('|');
// stimulus_parameters
push_q(&mut s, self.stimulus.frequency_hz, 10.0);
s.push_str(self.stimulus.modality.tag());
push_q(&mut s, self.stimulus.brightness_level, 100.0);
push_q(&mut s, self.stimulus.volume_level, 100.0);
s.push_str(self.stimulus.duty_cycle.tag());
push_q(&mut s, self.stimulus.phase_offset_ms, 10.0);
push_q(&mut s, self.stimulus.duration_minutes, 10.0);
s.push('|');
// sensor_summary (RuView + optional EEG)
push_q(&mut s, self.ruview.breathing_rate, 10.0);
push_q(&mut s, self.ruview.breathing_stability, 100.0);
push_q(&mut s, self.ruview.motion_artifact, 100.0);
push_q(&mut s, self.ruview.stillness_score, 100.0);
if let Some(e) = &self.eeg {
push_q(&mut s, e.gamma_power_gain, 100.0);
push_q(&mut s, e.phase_locking_value, 100.0);
push_q(&mut s, e.artifact_score, 100.0);
} else {
s.push_str("no_eeg");
}
s.push('|');
// response_summary
push_q(&mut s, self.outcome.entrainment_score, 1000.0);
push_q(&mut s, self.outcome.recommended_next_frequency_hz, 10.0);
s.push(if self.outcome.safety_pass { 'P' } else { 'F' });
s.push('|');
// safety_events
for ev in &self.safety_events {
s.push_str(&serde_json::to_string(ev).unwrap_or_default());
s.push(';');
}
s.into_bytes()
}
}
/// Append a float quantized to `1/scale` resolution, as a stable integer token.
fn push_q(s: &mut String, v: f64, scale: f64) {
let q = if v.is_finite() {
(v * scale).round() as i64
} else {
i64::MIN
};
s.push_str(&q.to_string());
s.push(',');
}
fn hex_encode(bytes: &[u8]) -> String {
let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes {
s.push_str(&format!("{:02x}", b));
}
s
}
#[cfg(test)]
mod tests {
use super::*;
use crate::safety::{AdverseEvent, StopReason};
fn builder() -> SessionBuilder {
SessionBuilder::new(
"subject-A",
VersionTriple::default(),
1_700_000_000_000,
StimulusParameters::prior(),
RuViewState::calm_baseline(),
SubjectiveReport::default(),
Outcome {
entrainment_score: 0.71,
safety_pass: true,
recommended_next_frequency_hz: 39.5,
},
)
}
#[test]
fn identical_sessions_hash_identically() {
let a = builder().finalize();
let b = builder().finalize();
assert_eq!(a.session_hash, b.session_hash);
assert_eq!(a.session_id, b.session_id);
}
#[test]
fn changing_frequency_changes_hash() {
let a = builder().finalize();
let mut b2 = builder();
b2.stimulus.frequency_hz = 41.0;
let b = b2.finalize();
assert_ne!(a.session_hash, b.session_hash);
}
#[test]
fn safety_events_alter_the_witness() {
let a = builder().finalize();
let b = builder()
.with_safety_events(vec![StopReason::AdverseEvent(AdverseEvent::Dizziness)])
.finalize();
assert_ne!(a.session_hash, b.session_hash);
}
#[test]
fn session_id_is_hash_prefix() {
let r = builder().finalize();
assert_eq!(r.session_id.0, r.session_hash[..16]);
}
#[test]
fn record_roundtrips_through_json() {
let r = builder().finalize();
let json = serde_json::to_string(&r).unwrap();
let back: SessionRecord = serde_json::from_str(&json).unwrap();
assert_eq!(r, back);
}
#[test]
fn eeg_presence_changes_witness() {
let a = builder().finalize();
let b = builder()
.with_eeg(EegMeasurement {
gamma_power_gain: 0.4,
phase_locking_value: 0.6,
artifact_score: 0.03,
})
.finalize();
assert_ne!(a.session_hash, b.session_hash);
}
}
+274
View File
@@ -0,0 +1,274 @@
//! Synthetic response simulator — Milestone 1 (ADR-250 §21).
//!
//! `frequency_response_curve(person, state, stimulus)` — a deterministic stand-in
//! for real EEG + RuView measurements so the optimizer, safety bounds, and
//! RuVector update logic can be exercised and replayed bit-exactly **before any
//! hardware or human exposure**.
//!
//! Determinism contract (same discipline as `nvsim`): the response for a given
//! `(global_seed, person_id, session_index, stimulus)` is byte-identical across
//! runs and machines. Noise is drawn from a ChaCha20 stream seeded from a
//! SHA-256 of those inputs — no OS entropy, no wall clock.
use rand::{Rng, SeedableRng};
use rand_chacha::ChaCha20Rng;
use sha2::{Digest, Sha256};
use crate::response::{EegMeasurement, RuViewState, SleepState};
use crate::stimulus::StimulusParameters;
/// A person's hidden response physiology. Real people are not 40 Hz-identical;
/// each has a latent best frequency the optimizer must *discover* (ADR-250 §1,
/// the 2025 PLOS One 3644 Hz finding).
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct LatentPerson {
/// The individual's true peak entrainment frequency in Hz.
pub peak_hz: f64,
/// Tuning-curve width in Hz (smaller = sharper, harder to find).
pub width_hz: f64,
/// Maximum achievable gamma gain at the peak `[0,1]`.
pub max_gain: f64,
/// How quickly comfort falls as intensity rises `[0,1]`.
pub intensity_sensitivity: f64,
/// Intrinsic measurement noise level `[0,1]`.
pub noise: f64,
}
impl LatentPerson {
/// Derive a stable latent person from a pseudonymous id. Deterministic: the
/// same id always yields the same physiology, so multi-session studies are
/// reproducible.
pub fn from_id(person_id: &str) -> Self {
let h = stable_hash(&[b"latent-person", person_id.as_bytes()]);
// Map hash bytes to parameter ranges.
let u = |i: usize| (h[i] as f64) / 255.0;
Self {
peak_hz: 37.0 + u(0) * 6.0, // 37..43 Hz
width_hz: 1.5 + u(1) * 2.5, // 1.5..4.0 Hz
max_gain: 0.45 + u(2) * 0.45, // 0.45..0.90
intensity_sensitivity: 0.2 + u(3) * 0.6, // 0.2..0.8
noise: 0.02 + u(4) * 0.08, // 0.02..0.10
}
}
}
/// One simulated session outcome.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SimulatedResponse {
/// Synthetic EEG entrainment measurement.
pub eeg: EegMeasurement,
/// Updated RuView state for this session (motion/comfort feedback).
pub ruview: RuViewState,
/// Subjective comfort `[0,1]`.
pub comfort: f64,
/// Whether an adverse event occurred (rises with overstimulation).
pub adverse_event: bool,
}
/// Deterministic response simulator.
#[derive(Debug, Clone)]
pub struct ResponseSimulator {
seed: u64,
}
impl ResponseSimulator {
pub fn new(seed: u64) -> Self {
Self { seed }
}
/// Simulate a session. `session_index` makes repeated identical protocols
/// produce independent (but reproducible) noise draws.
pub fn simulate(
&self,
person: &LatentPerson,
state: &RuViewState,
stimulus: &StimulusParameters,
session_index: u64,
) -> SimulatedResponse {
let mut rng = self.session_rng(person, stimulus, session_index);
// --- Frequency tuning curve: Gaussian bump around the latent peak. ---
let df = stimulus.frequency_hz - person.peak_hz;
let tuning = (-0.5 * (df / person.width_hz).powi(2)).exp();
// --- State modulation: calm, still, awake-but-quiet entrains best. ---
let state_factor = state_modulation(state);
// --- Intensity helps gamma up to a point, then risks overstimulation. ---
let intensity = 0.5 * (stimulus.brightness_level + stimulus.volume_level);
// Mild positive contribution from intensity, saturating.
let intensity_gain = 0.6 + 0.4 * (1.0 - (-3.0 * intensity).exp());
let mut noise = || -> f64 { (rng.gen::<f64>() - 0.5) * 2.0 * person.noise };
let gamma_power_gain =
clamp01(person.max_gain * tuning * state_factor * intensity_gain + noise());
// Phase locking tracks tuning but is less intensity-dependent.
let phase_locking_value = clamp01(0.9 * tuning * state_factor + noise());
// Artifact rises with motion and intensity.
let artifact_score = clamp01(state.motion_artifact + 0.1 * intensity + noise().abs());
// --- Comfort: high intensity and long duration erode comfort. ---
let dur_load = (stimulus.duration_minutes / 15.0).clamp(0.0, 1.0);
let comfort = clamp01(
0.95 - person.intensity_sensitivity * (0.7 * intensity + 0.3 * dur_load) + noise(),
);
// --- Adverse event: rare, and only when truly overstimulated. ---
let overstim = intensity * dur_load;
let adverse_event = overstim > 0.85 && rng.gen::<f64>() < (overstim - 0.85) * 2.0;
// Feedback into RuView state: discomfort raises motion/restlessness.
let mut ruview = *state;
ruview.motion_artifact = clamp01(state.motion_artifact + (1.0 - comfort) * 0.1);
ruview.restlessness_score = clamp01(state.restlessness_score + (1.0 - comfort) * 0.15);
SimulatedResponse {
eeg: EegMeasurement {
gamma_power_gain,
phase_locking_value,
artifact_score,
},
ruview,
comfort,
adverse_event,
}
}
/// Per-session ChaCha20 stream seeded from all inputs — reproducible noise.
fn session_rng(
&self,
person: &LatentPerson,
stimulus: &StimulusParameters,
session_index: u64,
) -> ChaCha20Rng {
// Quantize stimulus to 0.1 Hz / 0.01 intensity so floating noise in the
// last bits cannot fork the stream; matches the ±0.1 Hz control spec.
let q_freq = (stimulus.frequency_hz * 10.0).round() as i64;
let q_bright = (stimulus.brightness_level * 100.0).round() as i64;
let q_vol = (stimulus.volume_level * 100.0).round() as i64;
let q_peak = (person.peak_hz * 1000.0).round() as i64;
let h = stable_hash(&[
b"session-rng",
&self.seed.to_le_bytes(),
&session_index.to_le_bytes(),
&q_freq.to_le_bytes(),
&q_bright.to_le_bytes(),
&q_vol.to_le_bytes(),
&q_peak.to_le_bytes(),
]);
let mut seed32 = [0u8; 32];
seed32.copy_from_slice(&h);
ChaCha20Rng::from_seed(seed32)
}
}
fn state_modulation(state: &RuViewState) -> f64 {
let sleep = match state.sleep_state {
SleepState::QuietWake => 1.0,
SleepState::Drowsy => 0.85,
SleepState::Asleep => 0.6,
SleepState::Active => 0.7,
SleepState::Unknown => 0.8,
};
// Stillness and breathing stability help; restlessness hurts.
let calm = 0.5 + 0.3 * state.stillness_score + 0.2 * state.breathing_stability
- 0.2 * state.restlessness_score;
(sleep * calm).clamp(0.0, 1.0)
}
#[inline]
fn clamp01(v: f64) -> f64 {
if v.is_nan() {
0.0
} else {
v.clamp(0.0, 1.0)
}
}
/// SHA-256 over concatenated byte chunks → 32 bytes. Deterministic and
/// portable; the witness foundation for the whole crate.
pub(crate) fn stable_hash(chunks: &[&[u8]]) -> [u8; 32] {
let mut hasher = Sha256::new();
for c in chunks {
hasher.update((c.len() as u64).to_le_bytes());
hasher.update(c);
}
hasher.finalize().into()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::stimulus::StimulusParameters;
#[test]
fn same_inputs_produce_identical_output() {
let sim = ResponseSimulator::new(42);
let person = LatentPerson::from_id("subject-A");
let state = RuViewState::calm_baseline();
let stim = StimulusParameters::prior();
let a = sim.simulate(&person, &state, &stim, 0);
let b = sim.simulate(&person, &state, &stim, 0);
assert_eq!(a, b);
}
#[test]
fn different_session_index_changes_noise() {
let sim = ResponseSimulator::new(42);
let person = LatentPerson::from_id("subject-A");
let state = RuViewState::calm_baseline();
let stim = StimulusParameters::prior();
let a = sim.simulate(&person, &state, &stim, 0);
let b = sim.simulate(&person, &state, &stim, 1);
assert_ne!(a.eeg.gamma_power_gain, b.eeg.gamma_power_gain);
}
#[test]
fn latent_person_is_stable_per_id() {
assert_eq!(
LatentPerson::from_id("subject-A"),
LatentPerson::from_id("subject-A")
);
assert_ne!(
LatentPerson::from_id("subject-A").peak_hz,
LatentPerson::from_id("subject-Z").peak_hz
);
}
#[test]
fn peak_frequency_entrains_better_than_band_edge() {
let sim = ResponseSimulator::new(7);
let person = LatentPerson::from_id("subject-B");
let state = RuViewState::calm_baseline();
let mut at_peak = StimulusParameters::prior();
at_peak.frequency_hz = (person.peak_hz * 10.0).round() / 10.0;
let mut at_edge = StimulusParameters::prior();
at_edge.frequency_hz = 36.0;
let r_peak = sim.simulate(&person, &state, &at_peak, 0);
let r_edge = sim.simulate(&person, &state, &at_edge, 0);
// Only assert when the latent peak is genuinely away from the edge.
if (person.peak_hz - 36.0).abs() > 1.0 {
assert!(r_peak.eeg.gamma_power_gain > r_edge.eeg.gamma_power_gain);
}
}
#[test]
fn calm_state_entrains_better_than_restless() {
let sim = ResponseSimulator::new(3);
let person = LatentPerson::from_id("subject-C");
let stim = StimulusParameters::prior();
let calm = RuViewState::calm_baseline();
let mut restless = RuViewState::calm_baseline();
restless.restlessness_score = 0.9;
restless.stillness_score = 0.2;
restless.sleep_state = SleepState::Active;
let r_calm = sim.simulate(&person, &calm, &stim, 0);
let r_restless = sim.simulate(&person, &restless, &stim, 0);
assert!(r_calm.eeg.gamma_power_gain > r_restless.eeg.gamma_power_gain);
}
}
+284
View File
@@ -0,0 +1,284 @@
//! Stimulus parameters and the safety envelope (ADR-250 §5, §12).
//!
//! The [`SafetyEnvelope`] is the load-bearing safety primitive of the whole
//! crate: **no recommendation, calibration step, or closed-loop nudge may ever
//! produce a [`StimulusParameters`] that fails [`SafetyEnvelope::validate`].**
//! Every code path that emits a stimulus setting routes through
//! [`SafetyEnvelope::clamp`] (best-effort coercion) and is asserted against
//! [`SafetyEnvelope::contains`] in tests.
use serde::{Deserialize, Serialize};
use crate::math::clamp_safe;
/// Stimulation modality (ADR-250 §5).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Modality {
/// Sound only.
Audio,
/// Light only.
Visual,
/// Combined audio-visual — GENUS-style, the preferred protocol.
AudioVisual,
}
impl Modality {
/// Canonical lowercase tag used in the session witness.
pub fn tag(self) -> &'static str {
match self {
Modality::Audio => "audio",
Modality::Visual => "visual",
Modality::AudioVisual => "audio_visual",
}
}
}
/// Duty-cycle shape (ADR-250 §5). Conservative ordering: `Continuous` first.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DutyCycle {
/// Steady stimulation for the whole session.
Continuous,
/// Amplitude ramps up/down — gentlest onset.
Ramped,
/// On/off pulsing — explored only after tolerance is established.
Pulsed,
}
impl DutyCycle {
pub fn tag(self) -> &'static str {
match self {
DutyCycle::Continuous => "continuous",
DutyCycle::Ramped => "ramped",
DutyCycle::Pulsed => "pulsed",
}
}
}
/// A concrete stimulation setting. All intensity fields are normalized `[0,1]`.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct StimulusParameters {
/// Carrier/flicker frequency in Hz (search band 3644, prior 40).
pub frequency_hz: f64,
/// Stimulation modality.
pub modality: Modality,
/// Visual brightness in `[0,1]` (capped well below unsafe flicker intensity).
pub brightness_level: f64,
/// Audio volume in `[0,1]` (comfort-bounded).
pub volume_level: f64,
/// Duty-cycle shape.
pub duty_cycle: DutyCycle,
/// Inter-modality phase offset in milliseconds.
pub phase_offset_ms: f64,
/// Session duration in minutes.
pub duration_minutes: f64,
}
impl StimulusParameters {
/// The evidence-based starting prior: 40 Hz combined audio-visual, gentle
/// intensities, continuous, short (ADR-250 §5 "Starting prior").
pub fn prior() -> Self {
Self {
frequency_hz: 40.0,
modality: Modality::AudioVisual,
brightness_level: 0.30,
volume_level: 0.28,
duty_cycle: DutyCycle::Continuous,
phase_offset_ms: 0.0,
duration_minutes: 10.0,
}
}
}
/// Reasons a [`StimulusParameters`] is rejected by the envelope. Each variant
/// is logged verbatim into the RuFlo safety trail.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", tag = "kind", content = "detail")]
pub enum EnvelopeViolation {
/// Frequency outside `[min_hz, max_hz]`.
Frequency { value: f64, min: f64, max: f64 },
/// Brightness above the hard cap.
Brightness { value: f64, cap: f64 },
/// Volume above the hard cap.
Volume { value: f64, cap: f64 },
/// Duration above the per-stage maximum.
Duration { value: f64, max: f64 },
/// A non-finite (NaN/Inf) field was supplied.
NonFinite { field: &'static str },
}
/// 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.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
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,
}
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,
}
}
/// `true` iff every field of `s` lies inside the envelope and is finite.
pub fn contains(&self, s: &StimulusParameters) -> bool {
self.validate(s).is_ok()
}
/// Validate a setting, returning every violation found (not just the first)
/// so the safety log is complete.
pub fn validate(&self, s: &StimulusParameters) -> Result<(), Vec<EnvelopeViolation>> {
let mut v = Vec::new();
for (field, val) in [
("frequency_hz", s.frequency_hz),
("brightness_level", s.brightness_level),
("volume_level", s.volume_level),
("duration_minutes", s.duration_minutes),
("phase_offset_ms", s.phase_offset_ms),
] {
if !val.is_finite() {
v.push(EnvelopeViolation::NonFinite { field });
}
}
if !v.is_empty() {
return Err(v);
}
if s.frequency_hz < self.min_hz || s.frequency_hz > self.max_hz {
v.push(EnvelopeViolation::Frequency {
value: s.frequency_hz,
min: self.min_hz,
max: self.max_hz,
});
}
if s.brightness_level > self.brightness_cap || s.brightness_level < 0.0 {
v.push(EnvelopeViolation::Brightness {
value: s.brightness_level,
cap: self.brightness_cap,
});
}
if s.volume_level > self.volume_cap || s.volume_level < 0.0 {
v.push(EnvelopeViolation::Volume {
value: s.volume_level,
cap: self.volume_cap,
});
}
if s.duration_minutes > self.max_duration_minutes || s.duration_minutes <= 0.0 {
v.push(EnvelopeViolation::Duration {
value: s.duration_minutes,
max: self.max_duration_minutes,
});
}
if v.is_empty() {
Ok(())
} else {
Err(v)
}
}
/// Best-effort coercion of `s` into the envelope. Used as a defensive final
/// stage on any emitted recommendation; coercion can only ever *reduce*
/// intensity / pull frequency inward, never expand it. The result always
/// satisfies [`contains`](Self::contains).
pub fn clamp(&self, mut s: StimulusParameters) -> StimulusParameters {
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);
// Duration must be strictly positive; floor at 1 minute.
s.duration_minutes = clamp_safe(s.duration_minutes, 1.0, self.max_duration_minutes);
s
}
/// The calibration sweep grid (ADR-250 §8 Phase 1): integer-Hz steps across
/// the band, intersected with the envelope. Returns frequencies in Hz.
pub fn calibration_frequencies(&self) -> Vec<f64> {
let lo = self.min_hz.ceil() as i32;
let hi = self.max_hz.floor() as i32;
(lo..=hi).map(|f| f as f64).collect()
}
}
#[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);
}
}