mirror of
https://github.com/ruvnet/RuView
synced 2026-07-20 17:03:24 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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 36–44 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 | 36–44 Hz | published exploratory range |
|
||||
| Starting prior | 40 Hz | strongest preclinical literature |
|
||||
| Modality | audio, visual, combined | combined preferred (GENUS-style) |
|
||||
| Brightness | bounded low–moderate | avoid unsafe flicker intensity |
|
||||
| Volume | bounded low–moderate | 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 36–44 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.
|
||||
|
||||
## 9–12. 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
@@ -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"
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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 2–4).
|
||||
[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
|
||||
@@ -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 2–4).
|
||||
|
||||
## 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 36–44 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);
|
||||
@@ -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 Sherman–Morrison 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 Sherman–Morrison:
|
||||
/// 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));
|
||||
}
|
||||
}
|
||||
@@ -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 36–44 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);
|
||||
}
|
||||
}
|
||||
@@ -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(−‖a−b‖²/(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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 36–44 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);
|
||||
}
|
||||
}
|
||||
@@ -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 36–44, 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user