mirror of
https://github.com/ruvnet/RuView
synced 2026-06-24 12:43:18 +00:00
feat(clinic): ADR-251 clinical dashboard + hash-chained RuVector store
Read-only research/clinical instrumentation over the ADR-250 adaptive-gamma platform, closing two operational gaps: no durable cohort memory, no clinician surface. Store (store.rs): append-only JSONL holding anonymized profiles, witnessed session summaries, and acceptance verdicts. Every line is hash-chained (entry_hash = SHA-256(prev_hash || raw record bytes)), so any retroactive edit, deletion, or reorder breaks the chain — the store fails closed (refuses to open tampered data) and rebuilds the RuVector kNN/clustering layer on open so cohort warm-start survives restarts. The chain hashes the exact on-disk bytes via serde_json RawValue, because serde_json's default float parse is lossy and re-serialization is not byte-stable (this bit the first cut: a 9-session ingest self-corrupted on reopen). Dashboard (server.rs + embedded dependency-free dashboard.html): Axum surface with GET routes for participants, per-participant frequency-response map + session trend with safety-stop markers, cohort clusters, per-program acceptance verdicts, and a live chain-integrity badge. Strictly read-only — a test asserts no route accepts POST. Claim discipline inherited: acceptance payloads carry AcceptanceReport::released_claim (NO_CLAIM on failure), never a raw program claim. gamma-clinic binary; ingest_governor bridges the live ADR-250 loop into the store (pseudonymous, dedup by witness hash). Pseudonymity asserted: the person_id never reaches disk. 20 tests (13 store/lib + 7 server) + 1 doctest; live binary smoke-tested. Workspace gate: 2,935 passed / 0 failed. 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-clinic` crate (ADR-251) — clinical dashboard + persistent hash-chained RuVector store.** Read-only research/clinical instrumentation over the ADR-250 platform, closing two operational gaps (no durable cohort memory; no clinician surface). **Store** (`store.rs`): append-only JSONL holding profiles, witnessed session summaries, and acceptance verdicts, each line hash-chained `entry_hash = SHA-256(prev ‖ raw record bytes)` so any retroactive edit/deletion/reorder breaks the chain — the store *fails closed* (refuses to open tampered data) and rebuilds the RuVector kNN/clustering layer on open so warm-start survives restarts. (Hashes the exact on-disk bytes via `RawValue`, since serde_json's default float parse is lossy and re-serialization isn't byte-stable.) **Dashboard** (`server.rs` + embedded dependency-free `dashboard.html`): Axum surface with `GET` routes for participants, per-participant frequency-response map + session trend (safety-stop markers), cohort clusters, per-program acceptance verdicts, and a live chain-integrity badge — **strictly read-only** (a test asserts no route accepts POST). Claim discipline inherited: acceptance payloads carry `AcceptanceReport::released_claim` (the gate's output, `NO_CLAIM` on failure), never a raw program claim. `gamma-clinic` binary; `ingest_governor` bridges the live ADR-250 loop into the store (pseudonymous, dedup by witness hash). 20 tests (13 store/lib + 7 server) + 1 doctest; pseudonymity asserted (the `person_id` never reaches disk).
|
||||
- **Opt-in FFT operator for the CIR ISTA solver (8–14× measured).** Φ is a sub-DFT, so each ISTA mat-vec can run as one length-G FFT (O(G log G)) instead of a dense O(K·G) product. New `CirConfig::fft_operator` (default **false** — the dense path stays the bit-exact witness default; the FFT evaluates the same sums in a different order, so enabling it shifts float results and requires regenerating any pinned witness). `FftOperator` (rustfft, planned once at construction, scratch reused across the ISTA loop) dispatches inside `ista_solve`; warm-start/Lipschitz stay dense at construction. Measured (criterion, same run): ht20 2.22 ms → 265 µs (**8.4×**), ht40 10.26 ms → 717 µs (**14.3×**); the real HE40 grid (K=484, G=1452) scales further. 3 new tests: FFT↔dense matvec equivalence to float tolerance (ht20 + he40 grids), end-to-end dominant-tap agreement on a single-path frame, and all default configs keep FFT off. New `cir_estimate_fft` bench group. Closes the trust-chain gap where an ~11 KB per-room LoRA adapter (ADR-150 §3.4) could silently change inference without the witness noticing. `StreamingEngine::set_room_adapter(AdapterInfo)` pins the adapter's content-derived id into provenance `model_version` (`rfenc-v1+adapter:<id>`) — and therefore into the BLAKE3 witness — so swapping or clearing adapter weights always shifts the witness (engine test proves base → adapter → other-adapter → cleared all witness differently, and cleared == base). New `RecalibrationAdvisor` recommends re-running the ADR-135 baseline / refitting the adapter on sustained low fusion coherence (streak threshold, default 60 cycles ≈ 3 s at 20 Hz) or an ADR-142 change-point; surfaced as `TrustedOutput::recalibration_recommended` and stored on the sensing-server `AppState` alongside the witness. Bridge plumbing: `EngineBridge::{set_room_adapter, clear_room_adapter}` + live-path test that the adapter id flows into the live witness. Engine 15 tests, bridge 7 tests. *Scope note: this is the deployable provenance/trigger half of the "retrained model" roadmap item — fitting the adapter itself runs in the existing external calibration service (`aether-arena/calibration/`), and a trained RF-encoder checkpoint still does not exist in-tree.*
|
||||
- **`esp32-gamma-stim` firmware — ESP32 gamma stimulation actuator (ADR-250 §21 M2 device harness).** The hardware side of `ruview-gamma`: an ESP32 driving an LED + audio flicker at a commanded 36–44 Hz envelope with a hardware emergency stop. Split into a **pure, host-tested safety core** (`main/stim_core.{h,c}` — envelope validation mirroring `SafetyEnvelope::conservative()`, a latched START/STOP/e-stop state machine, exact integer timing math in millihertz so the ±0.1 Hz HIL target is exact, and a line-protocol parser; **15 host tests pass under gcc, no ESP-IDF needed**) and a thin **ESP-IDF binding** (`main/main.c` — GPTimer ISR, LEDC PWM for LED+audio, sync-out GPIO for logic-analyzer capture, e-stop GPIO ISR that kills outputs in microseconds, USB-CDC console). Defense in depth: the device re-enforces the safety envelope independently of the Rust host, so a buggy/compromised host still cannot command an out-of-envelope output. Emits a canonical integer `SESSION {...}` record per run for witness-hash reproduction. Maps 1:1 to the five `hil::verify_hil` targets. Kconfig pin config, 4 MB single-app, radio-off deterministic actuator profile.
|
||||
- **`ruview-gamma` claim-gate invariant + hardware-in-the-loop contract.** Centralized the claim release rule into a single `acceptance::claim_allowed(entrainment, safety, adherence, repeatability)` (strict AND of all four) used by every path, with a test proving every 3-of-4 subset is denied — no path can weaken the gate. New `hil` module: `verify_hil` grades a captured actuator bench measurement against fixed targets (LED frequency ±0.1 Hz, audio-visual sync < 5 ms, stop-signal→actuator-off < 100 ms, session-hash reproducibility 100%, EEG entrainment lift ≥ 20% over fixed 40 Hz) — the next acceptance milestone for a real LED+speaker (e.g. ESP32) actuator; all failure modes fail closed (missing stop measurement, no replay, any hash mismatch). README gains the benchmark table and the "governed personalization engine that refuses to overpromise" positioning. 9 new tests; crate now 97 + 1 doctest; pinned witness preserved.
|
||||
|
||||
@@ -22,7 +22,8 @@ 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 Sensory Neuromodulation platform (ADR-250) — governed, deterministic, safety-constrained personal neural-rhythm optimization. 7-program `NeuroProgram` catalog (Alzheimer's research, post-stroke cognition, sleep, attention/WM, mood/arousal, home wellness, trial infrastructure), each with its own safety envelope/prior/objective/state-gating/evidence-level/gated-claim. RuView sensing + RuVector cross-person self-learning (cohort warm-start, drift, clustering) + RuFlo audit. Executable acceptance gate (entrainment/safety/adherence/repeatability) releases a claim only when passed. Standalone leaf, ChaCha20 + SHA-256 witness. Research platform, not a medical device. |
|
||||
| `ruview-gamma` | Adaptive Sensory Neuromodulation platform (ADR-250) — governed, deterministic, safety-constrained personal neural-rhythm optimization. 7-program `NeuroProgram` catalog (Alzheimer's research, post-stroke cognition, sleep, attention/WM, mood/arousal, home wellness, trial infrastructure), each with its own safety envelope/prior/objective/state-gating/evidence-level/gated-claim. RuView sensing + RuVector cross-person self-learning (cohort warm-start, drift, clustering) + RuFlo audit. Executable acceptance gate (entrainment/safety/adherence/repeatability) releases a claim only
|
||||
| `ruview-gamma-clinic` | Clinical dashboard + persistent hash-chained RuVector store (ADR-251) — read-only Axum instrumentation over `ruview-gamma`: participant frequency-response maps, session trends with safety-stop markers, cohort clusters, per-program acceptance verdicts carrying only gate-released claims, live chain-integrity badge. Append-only JSONL, tamper-evident `entry_hash = SHA-256(prev ‖ raw record bytes)` (fails closed). `gamma-clinic` binary. Research tooling, not a medical device. |
|
||||
|
||||
### RuvSense Modules (`signal/src/ruvsense/`)
|
||||
| Module | Purpose |
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
# ADR-251: Clinical Dashboard + Persistent RuVector Store for Adaptive Gamma
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Proposed |
|
||||
| **Date** | 2026-06-11 |
|
||||
| **Owner** | RuView, RuVector, RuFlo clinical systems |
|
||||
| **Decision type** | Architecture, clinical tooling |
|
||||
| **Relates to** | ADR-250 (Adaptive Gamma Entrainment — platform, programs, acceptance gate) |
|
||||
| **Codebase target** | `v2/crates/ruview-gamma-clinic` (this ADR's implementation) |
|
||||
|
||||
> **Not a medical device.** Read-only research/clinical *instrumentation* over
|
||||
> the ADR-250 platform. It surfaces only gated claims and witnessed records; it
|
||||
> can neither start stimulation nor widen any safety envelope.
|
||||
|
||||
## 1. Context
|
||||
|
||||
ADR-250 shipped the governed adaptive-neuromodulation engine: per-program
|
||||
safety envelopes, the RuVector self-learning layer (anonymized profiles, kNN
|
||||
warm-start, drift detection, clustering), witnessed session records, and the
|
||||
executable acceptance gate. Two operational gaps remain for clinical use:
|
||||
|
||||
1. **No durable store.** `ProfileStore` and the governor's audit log are
|
||||
in-memory; a clinic restart loses cohort memory, and there is no
|
||||
tamper-evident persistence matching the platform's proof discipline.
|
||||
2. **No clinician surface.** RuFlo's clinician export exists as a struct
|
||||
(`ClinicianReport`), but there is no way for a clinician/trial monitor to
|
||||
*see* a participant's frequency-response curve, session trend, safety
|
||||
events, drift status, or a program's acceptance verdict.
|
||||
|
||||
## 2. Decision
|
||||
|
||||
Build `ruview-gamma-clinic`, a separate crate (keeping `ruview-gamma` a
|
||||
dependency-light deterministic leaf) with two components:
|
||||
|
||||
### 2.1 Persistent RuVector store (`store.rs`)
|
||||
|
||||
Append-only JSON-lines file per clinic, holding three record kinds —
|
||||
anonymized profiles, witnessed session summaries, and acceptance reports —
|
||||
each line **hash-chained** (`entry_hash = SHA-256(prev_hash ‖ canonical_json)`)
|
||||
so any retroactive edit breaks the chain (`verify_chain()`); the RuVector
|
||||
in-memory layer (`ProfileStore` kNN, clustering) is rebuilt from the file at
|
||||
open. Pseudonymity is preserved: records carry only the one-way profile tags
|
||||
from ADR-250 §10.
|
||||
|
||||
### 2.2 Read-only clinical dashboard (`server.rs` + embedded `dashboard.html`)
|
||||
|
||||
Axum surface, **strictly read-only** (no POST mutates stimulation state):
|
||||
|
||||
| Route | Payload |
|
||||
|-------|---------|
|
||||
| `GET /` | embedded single-file HTML dashboard (no build step; SVG charts) |
|
||||
| `GET /api/clinic/participants` | tag, session count, mean entrainment, safety stops, adverse flag, drift status |
|
||||
| `GET /api/clinic/participants/{tag}` | response vector, frequency→score curve, session trend |
|
||||
| `GET /api/clinic/cohort` | deterministic k-means clusters over the stored profiles |
|
||||
| `GET /api/clinic/acceptance` | per-program acceptance reports with the **gated** claim |
|
||||
| `GET /api/clinic/integrity` | hash-chain verification result + record count |
|
||||
|
||||
**Claim discipline is inherited, not re-implemented:** acceptance payloads
|
||||
embed `AcceptanceReport::released_claim` (the gate's output), never the
|
||||
program's raw claim. The dashboard renders what the gate released — nothing
|
||||
stronger.
|
||||
|
||||
### 2.3 Visualization (embedded, dependency-free)
|
||||
|
||||
One static HTML file (`include_str!`) with vanilla JS + inline SVG:
|
||||
participant list → per-participant frequency-response curve (the personal
|
||||
response map), entrainment/comfort session trend, safety-event markers, cohort
|
||||
cluster table, and an integrity badge (green only when `verify_chain` passes).
|
||||
No JS framework, no CDN, no build step — auditable by reading one file.
|
||||
|
||||
## 3. Consequences
|
||||
|
||||
- Clinic restarts no longer lose cohort memory; warm-start works across runs.
|
||||
- Tampering with stored records is detectable by one endpoint call.
|
||||
- A clinician can inspect a trial without shell access; the surface cannot
|
||||
actuate anything.
|
||||
- The store is JSONL, not a database server: at research-cohort scale this is
|
||||
deliberate (greppable, diffable, witness-friendly). An HNSW/ruvector-crate
|
||||
backend remains the drop-in path past ~10⁵ profiles (ADR-250 §10).
|
||||
|
||||
## 4. Acceptance criteria (tested)
|
||||
|
||||
| Criterion | Test |
|
||||
|-----------|------|
|
||||
| Store round-trips all three record kinds across reopen | `store::tests` |
|
||||
| Hash chain detects any line edit/deletion/reorder | `tampered_chain_is_detected` |
|
||||
| kNN warm-start works from a reloaded store | `knn_survives_reload` |
|
||||
| Every API route serves and is read-only | `server::tests` (oneshot) |
|
||||
| Acceptance payload carries only the gated claim | `acceptance_payload_uses_gated_claim` |
|
||||
| Dashboard HTML embeds and serves | `dashboard_html_served` |
|
||||
Generated
+15
@@ -7470,6 +7470,21 @@ dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruview-gamma-clinic"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"ruview-gamma",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tower 0.4.13",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruview-swarm"
|
||||
version = "0.1.0"
|
||||
|
||||
@@ -72,6 +72,7 @@ members = [
|
||||
"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)
|
||||
"crates/ruview-gamma-clinic", # ADR-251 — clinical dashboard + hash-chained RuVector store
|
||||
]
|
||||
# ADR-040: WASM edge crate targets wasm32-unknown-unknown (no_std),
|
||||
# excluded from workspace to avoid breaking `cargo test --workspace`.
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
[package]
|
||||
name = "ruview-gamma-clinic"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
description = "Clinical dashboard + persistent hash-chained RuVector store for the Adaptive Gamma platform (ADR-251). Read-only instrumentation over ruview-gamma: participant response maps, session trends, cohort clusters, acceptance verdicts with gated claims. Research tooling, not a medical device."
|
||||
repository.workspace = true
|
||||
publish = false
|
||||
|
||||
[[bin]]
|
||||
name = "gamma-clinic"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
ruview-gamma = { version = "0.3.0", path = "../ruview-gamma" }
|
||||
|
||||
axum = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
# raw_value: the hash chain covers the exact on-disk record bytes (re-serializing
|
||||
# floats is not byte-stable — serde_json's default float parse is fast/lossy).
|
||||
serde_json = { workspace = true, features = ["raw_value"] }
|
||||
sha2 = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tower = { workspace = true }
|
||||
tempfile = "3"
|
||||
@@ -0,0 +1,141 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>RuView Gamma — Clinical Dashboard (research use only)</title>
|
||||
<style>
|
||||
:root { --bg:#0e1116; --card:#161b22; --ink:#e6edf3; --dim:#8b949e;
|
||||
--ok:#3fb950; --warn:#d29922; --bad:#f85149; --acc:#58a6ff; }
|
||||
body { background:var(--bg); color:var(--ink); font:14px/1.5 system-ui,sans-serif; margin:0; }
|
||||
header { padding:14px 22px; border-bottom:1px solid #21262d; display:flex; gap:14px; align-items:baseline; }
|
||||
header h1 { font-size:17px; margin:0; }
|
||||
header .claim { color:var(--dim); font-size:12px; }
|
||||
#integrity { margin-left:auto; font-size:12px; padding:3px 10px; border-radius:10px; }
|
||||
.ok { background:#12361f; color:var(--ok); }
|
||||
.bad { background:#3d1214; color:var(--bad); }
|
||||
main { display:grid; grid-template-columns:260px 1fr; gap:16px; padding:16px 22px; }
|
||||
.card { background:var(--card); border:1px solid #21262d; border-radius:8px; padding:14px; margin-bottom:16px; }
|
||||
.card h2 { font-size:13px; margin:0 0 10px; color:var(--dim); text-transform:uppercase; letter-spacing:.06em; }
|
||||
#participants li { list-style:none; padding:6px 8px; border-radius:6px; cursor:pointer; font-family:ui-monospace,monospace; font-size:13px; }
|
||||
#participants li:hover, #participants li.sel { background:#1f2630; color:var(--acc); }
|
||||
#participants ul { margin:0; padding:0; }
|
||||
table { width:100%; border-collapse:collapse; font-size:13px; }
|
||||
td,th { padding:5px 8px; border-bottom:1px solid #21262d; text-align:left; }
|
||||
th { color:var(--dim); font-weight:500; }
|
||||
.pass { color:var(--ok); } .fail { color:var(--bad); }
|
||||
svg { width:100%; height:180px; }
|
||||
.axis { stroke:#30363d; stroke-width:1; }
|
||||
.curve { fill:none; stroke:var(--acc); stroke-width:2; }
|
||||
.trend { fill:none; stroke:var(--ok); stroke-width:2; }
|
||||
.comfort { fill:none; stroke:var(--warn); stroke-width:1.5; stroke-dasharray:4 3; }
|
||||
.pt { fill:var(--acc); }
|
||||
.stop { fill:var(--bad); }
|
||||
.lbl { fill:var(--dim); font-size:10px; }
|
||||
footer { color:var(--dim); font-size:11px; padding:10px 22px; border-top:1px solid #21262d; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>RuView Gamma — Clinical Dashboard</h1>
|
||||
<span class="claim">read-only · pseudonymous · research use only — not a medical device</span>
|
||||
<span id="integrity">checking…</span>
|
||||
</header>
|
||||
<main>
|
||||
<aside>
|
||||
<div class="card"><h2>Participants</h2><div id="participants"><ul></ul></div></div>
|
||||
<div class="card"><h2>Cohort clusters</h2><div id="cohort"></div></div>
|
||||
</aside>
|
||||
<section>
|
||||
<div class="card"><h2>Frequency response map <span id="ptag"></span></h2><svg id="freqmap"></svg></div>
|
||||
<div class="card"><h2>Session trend (entrainment ─ / comfort ╌ / safety stops ●)</h2><svg id="trend"></svg></div>
|
||||
<div class="card"><h2>Program acceptance (claims released only by the gate)</h2>
|
||||
<table id="acceptance"><thead><tr>
|
||||
<th>program</th><th>gain</th><th>stops</th><th>adherence</th><th>repeat ±Hz</th><th>verdict</th><th>released claim</th>
|
||||
</tr></thead><tbody></tbody></table>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<footer>ADR-251 · every record hash-chained; integrity badge is green only when the on-disk chain verifies · claims pass through the ADR-250 acceptance gate, never around it</footer>
|
||||
<script>
|
||||
const $ = (s) => document.querySelector(s);
|
||||
const J = (u) => fetch(u).then(r => r.json());
|
||||
|
||||
function line(svg, pts, cls, w=560, h=170, pad=28) {
|
||||
if (!pts.length) return;
|
||||
const xs = pts.map(p=>p[0]), ys = pts.map(p=>p[1]);
|
||||
const x0=Math.min(...xs), x1=Math.max(...xs), y0=Math.min(0,...ys), y1=Math.max(1e-9,...ys);
|
||||
const X = v => pad + (v-x0)/((x1-x0)||1)*(w-2*pad);
|
||||
const Y = v => h-pad - (v-y0)/((y1-y0)||1)*(h-2*pad);
|
||||
const d = pts.map((p,i)=>(i?'L':'M')+X(p[0]).toFixed(1)+','+Y(p[1]).toFixed(1)).join(' ');
|
||||
const el = document.createElementNS('http://www.w3.org/2000/svg','path');
|
||||
el.setAttribute('d', d); el.setAttribute('class', cls); svg.appendChild(el);
|
||||
return {X, Y};
|
||||
}
|
||||
function axes(svg, w=560, h=170, pad=28) {
|
||||
svg.innerHTML = '';
|
||||
const ax = document.createElementNS('http://www.w3.org/2000/svg','path');
|
||||
ax.setAttribute('d', `M${pad},${pad} L${pad},${h-pad} L${w-pad},${h-pad}`);
|
||||
ax.setAttribute('class','axis'); svg.appendChild(ax);
|
||||
}
|
||||
function label(svg, x, y, text) {
|
||||
const t = document.createElementNS('http://www.w3.org/2000/svg','text');
|
||||
t.setAttribute('x',x); t.setAttribute('y',y); t.setAttribute('class','lbl');
|
||||
t.textContent = text; svg.appendChild(t);
|
||||
}
|
||||
|
||||
async function loadParticipant(tag) {
|
||||
$('#ptag').textContent = '— ' + tag;
|
||||
document.querySelectorAll('#participants li').forEach(li =>
|
||||
li.classList.toggle('sel', li.textContent.startsWith(tag)));
|
||||
const d = await J('/api/clinic/participants/' + tag);
|
||||
const fm = $('#freqmap'); axes(fm);
|
||||
line(fm, d.frequency_curve, 'curve');
|
||||
d.frequency_curve.forEach(p => {});
|
||||
label(fm, 30, 14, 'score vs frequency (Hz)');
|
||||
const tr = $('#trend'); axes(tr);
|
||||
const ent = d.sessions.map((s,i)=>[i, s.entrainment_score]);
|
||||
const com = d.sessions.map((s,i)=>[i, s.comfort]);
|
||||
const m = line(tr, ent, 'trend');
|
||||
line(tr, com, 'comfort');
|
||||
if (m) d.sessions.forEach((s,i) => {
|
||||
if (!s.safety_pass) {
|
||||
const c = document.createElementNS('http://www.w3.org/2000/svg','circle');
|
||||
c.setAttribute('cx', m.X(i)); c.setAttribute('cy', m.Y(s.entrainment_score));
|
||||
c.setAttribute('r', 4); c.setAttribute('class','stop'); tr.appendChild(c);
|
||||
}
|
||||
});
|
||||
label(tr, 30, 14, d.sessions.length + ' sessions');
|
||||
}
|
||||
|
||||
async function init() {
|
||||
const integ = await J('/api/clinic/integrity');
|
||||
const b = $('#integrity');
|
||||
b.textContent = integ.valid ? `chain ok · ${integ.records} records` : `CHAIN BROKEN @ line ${integ.broken_at}`;
|
||||
b.className = integ.valid ? 'ok' : 'bad';
|
||||
|
||||
const ps = await J('/api/clinic/participants');
|
||||
const ul = $('#participants ul');
|
||||
ps.forEach(p => {
|
||||
const li = document.createElement('li');
|
||||
li.textContent = `${p.tag} · ${p.sessions} ses · ${p.safety_stops ? p.safety_stops + ' stops' : 'clean'}`;
|
||||
li.onclick = () => loadParticipant(p.tag);
|
||||
ul.appendChild(li);
|
||||
});
|
||||
if (ps.length) loadParticipant(ps[0].tag);
|
||||
|
||||
const co = await J('/api/clinic/cohort');
|
||||
$('#cohort').innerHTML = co.clusters.map((c,i) =>
|
||||
`<div>cluster ${i}: <b>${c.members.length}</b> · ${c.members.join(', ')}</div>`).join('') || '<i>no profiles</i>';
|
||||
|
||||
const acc = await J('/api/clinic/acceptance');
|
||||
$('#acceptance tbody').innerHTML = acc.map(a => `<tr>
|
||||
<td>${a.program_id}</td><td>${(a.entrainment_gain*100).toFixed(1)}%</td>
|
||||
<td>${(a.safety_stop_rate*100).toFixed(1)}%</td><td>${(a.mean_adherence*100).toFixed(0)}%</td>
|
||||
<td>${a.repeatability_band_hz.toFixed(2)}</td>
|
||||
<td class="${a.overall_pass?'pass':'fail'}">${a.overall_pass?'PASS':'WITHHELD'}</td>
|
||||
<td>${a.released_claim}</td></tr>`).join('');
|
||||
}
|
||||
init();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,128 @@
|
||||
//! # ruview-gamma-clinic — Clinical dashboard + persistent RuVector store (ADR-251)
|
||||
//!
|
||||
//! Read-only research/clinical instrumentation over the ADR-250 adaptive-gamma
|
||||
//! platform: a **hash-chained JSONL store** (profiles, witnessed session
|
||||
//! summaries, acceptance verdicts — any retroactive edit breaks the chain and
|
||||
//! the store refuses to open) plus an **axum dashboard** (participant response
|
||||
//! maps, session trends with safety-stop markers, cohort clusters, per-program
|
||||
//! acceptance verdicts carrying only gate-released claims, and a live
|
||||
//! chain-integrity badge).
|
||||
//!
|
||||
//! > **Not a medical device.** This surface can neither start stimulation nor
|
||||
//! > widen a safety envelope — there are no mutating routes (tested). It
|
||||
//! > renders what the ADR-250 acceptance gate released, nothing stronger.
|
||||
//!
|
||||
//! ## Quick start
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use std::sync::Arc;
|
||||
//! use tokio::sync::RwLock;
|
||||
//! use ruview_gamma_clinic::{store::ClinicStore, server::router};
|
||||
//!
|
||||
//! # async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
//! let store = Arc::new(RwLock::new(ClinicStore::open("clinic.jsonl")?));
|
||||
//! let app = router(store);
|
||||
//! let listener = tokio::net::TcpListener::bind("127.0.0.1:8090").await?;
|
||||
//! axum::serve(listener, app).await?;
|
||||
//! # Ok(())
|
||||
//! # }
|
||||
//! ```
|
||||
|
||||
pub mod server;
|
||||
pub mod store;
|
||||
|
||||
use ruview_gamma::ruflo::RufloGovernor;
|
||||
use store::{ClinicRecord, ClinicStore, SessionSummary, StoreError};
|
||||
|
||||
/// Ingest a governor's current state into the store: upsert the anonymized
|
||||
/// profile and append any sessions not yet persisted (deduplicated by the
|
||||
/// session witness hash). Returns how many new sessions were appended.
|
||||
///
|
||||
/// This is the bridge from the live ADR-250 loop to the durable clinic record:
|
||||
/// call it after a session (or batch) completes.
|
||||
///
|
||||
/// # Errors
|
||||
/// Propagates [`StoreError`] from the underlying append.
|
||||
pub fn ingest_governor(
|
||||
store: &mut ClinicStore,
|
||||
gov: &RufloGovernor,
|
||||
program_id: &str,
|
||||
) -> Result<usize, StoreError> {
|
||||
let profile = gov.export_anonymized_profile();
|
||||
let tag = profile.profile_tag.clone();
|
||||
store.append(ClinicRecord::Profile(profile))?;
|
||||
|
||||
let known: std::collections::BTreeSet<String> = store
|
||||
.sessions_for(&tag)
|
||||
.iter()
|
||||
.map(|s| s.session_hash.clone())
|
||||
.collect();
|
||||
|
||||
let mut appended = 0usize;
|
||||
for rec in gov.audit_log() {
|
||||
if known.contains(&rec.session_hash) {
|
||||
continue;
|
||||
}
|
||||
store.append(ClinicRecord::Session(SessionSummary {
|
||||
profile_tag: tag.clone(),
|
||||
program_id: program_id.to_string(),
|
||||
frequency_hz: rec.stimulus.frequency_hz,
|
||||
entrainment_score: rec.outcome.entrainment_score,
|
||||
comfort: rec.subjective.comfort,
|
||||
safety_pass: rec.outcome.safety_pass,
|
||||
session_hash: rec.session_hash.clone(),
|
||||
timestamp_ms: rec.timestamp_ms,
|
||||
}))?;
|
||||
appended += 1;
|
||||
}
|
||||
Ok(appended)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ruview_gamma::response::RuViewState;
|
||||
use ruview_gamma::ruflo::Consent;
|
||||
use ruview_gamma::simulator::{LatentPerson, ResponseSimulator};
|
||||
use ruview_gamma::stimulus::SafetyEnvelope;
|
||||
|
||||
/// End-to-end: a governed calibration run lands in the store with the
|
||||
/// pseudonymous tag, witnessed hashes, and an intact chain — and re-ingest
|
||||
/// is idempotent (witness-hash dedup).
|
||||
#[test]
|
||||
fn governor_ingest_roundtrip_and_idempotence() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("clinic.jsonl");
|
||||
let mut store = ClinicStore::open(&path).unwrap();
|
||||
|
||||
let mut gov = RufloGovernor::enroll(
|
||||
"subject-secret-001",
|
||||
SafetyEnvelope::conservative(),
|
||||
&[],
|
||||
Consent::Granted,
|
||||
)
|
||||
.unwrap();
|
||||
let sim = ResponseSimulator::new(42);
|
||||
let latent = LatentPerson::from_id("subject-secret-001");
|
||||
gov.run_calibration(&sim, &latent, &RuViewState::calm_baseline(), 5.0, 0)
|
||||
.unwrap();
|
||||
|
||||
let n = ingest_governor(&mut store, &gov, "alzheimers-research").unwrap();
|
||||
assert_eq!(n, 9); // the 36..44 Hz sweep
|
||||
|
||||
// Pseudonymity: the person_id never appears anywhere in the file.
|
||||
let raw = std::fs::read_to_string(&path).unwrap();
|
||||
assert!(!raw.contains("subject-secret-001"));
|
||||
|
||||
// Re-ingest appends nothing new (dedup by witness hash).
|
||||
let again = ingest_governor(&mut store, &gov, "alzheimers-research").unwrap();
|
||||
assert_eq!(again, 0);
|
||||
|
||||
// Chain stays valid across reopen, with sessions queryable by tag.
|
||||
let reopened = ClinicStore::open(&path).unwrap();
|
||||
assert!(reopened.verify_chain().valid);
|
||||
let tags = reopened.participant_tags();
|
||||
assert_eq!(tags.len(), 1);
|
||||
assert_eq!(reopened.sessions_for(&tags[0]).len(), 9);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
//! `gamma-clinic` — serve the ADR-251 clinical dashboard over a store file.
|
||||
//!
|
||||
//! Usage: `gamma-clinic [STORE_PATH] [BIND_ADDR]`
|
||||
//! Defaults: `clinic.jsonl`, `127.0.0.1:8090`. Read-only surface.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use ruview_gamma_clinic::server::router;
|
||||
use ruview_gamma_clinic::store::ClinicStore;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut args = std::env::args().skip(1);
|
||||
let store_path = args.next().unwrap_or_else(|| "clinic.jsonl".to_string());
|
||||
let bind = args.next().unwrap_or_else(|| "127.0.0.1:8090".to_string());
|
||||
|
||||
// Fails closed on a tampered chain — refuses to serve doctored data.
|
||||
let store = ClinicStore::open(&store_path)?;
|
||||
let status = store.verify_chain();
|
||||
println!(
|
||||
"gamma-clinic: store={store_path} records={} chain={}",
|
||||
status.records,
|
||||
if status.valid { "ok" } else { "BROKEN" }
|
||||
);
|
||||
|
||||
let app = router(Arc::new(RwLock::new(store)));
|
||||
let listener = tokio::net::TcpListener::bind(&bind).await?;
|
||||
println!("gamma-clinic: dashboard at http://{bind}/ (read-only; research use only)");
|
||||
axum::serve(listener, app).await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
//! Read-only clinical dashboard API (ADR-251 §2.2).
|
||||
//!
|
||||
//! Strictly observational: no route mutates stimulation state, widens an
|
||||
//! envelope, or writes to the store. Claim discipline is inherited — the
|
||||
//! acceptance payload carries the gate's `released_claim` verbatim (which is
|
||||
//! `NO_CLAIM` for any program that has not passed), never a raw program claim.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::{Path, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{Html, IntoResponse, Json};
|
||||
use axum::routing::get;
|
||||
use axum::Router;
|
||||
use serde::Serialize;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use crate::store::ClinicStore;
|
||||
|
||||
/// Shared, read-locked store handle.
|
||||
pub type SharedStore = Arc<RwLock<ClinicStore>>;
|
||||
|
||||
/// The embedded single-file dashboard (no build step, no CDN — auditable by
|
||||
/// reading one file).
|
||||
pub const DASHBOARD_HTML: &str = include_str!("dashboard.html");
|
||||
|
||||
/// Participant list row.
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ParticipantRow {
|
||||
tag: String,
|
||||
sessions: usize,
|
||||
mean_entrainment: f64,
|
||||
safety_stops: usize,
|
||||
drift_flagged: bool,
|
||||
}
|
||||
|
||||
/// Per-participant detail: response map + session trend.
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ParticipantDetail {
|
||||
tag: String,
|
||||
/// `(frequency_hz, score)` points sorted by frequency — the personal
|
||||
/// response map rendered by the dashboard.
|
||||
frequency_curve: Vec<(f64, f64)>,
|
||||
sessions: Vec<SessionPoint>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct SessionPoint {
|
||||
frequency_hz: f64,
|
||||
entrainment_score: f64,
|
||||
comfort: f64,
|
||||
safety_pass: bool,
|
||||
session_hash: String,
|
||||
timestamp_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct CohortView {
|
||||
clusters: Vec<Cluster>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct Cluster {
|
||||
members: Vec<String>,
|
||||
}
|
||||
|
||||
/// Build the dashboard router over a shared store.
|
||||
pub fn router(store: SharedStore) -> Router {
|
||||
Router::new()
|
||||
.route("/", get(dashboard))
|
||||
.route("/api/clinic/participants", get(participants))
|
||||
.route("/api/clinic/participants/:tag", get(participant_detail))
|
||||
.route("/api/clinic/cohort", get(cohort))
|
||||
.route("/api/clinic/acceptance", get(acceptance))
|
||||
.route("/api/clinic/integrity", get(integrity))
|
||||
.with_state(store)
|
||||
}
|
||||
|
||||
async fn dashboard() -> Html<&'static str> {
|
||||
Html(DASHBOARD_HTML)
|
||||
}
|
||||
|
||||
async fn participants(State(store): State<SharedStore>) -> Json<Vec<ParticipantRow>> {
|
||||
let s = store.read().await;
|
||||
let rows = s
|
||||
.participant_tags()
|
||||
.into_iter()
|
||||
.map(|tag| {
|
||||
let sessions = s.sessions_for(&tag);
|
||||
let n = sessions.len();
|
||||
let mean = if n > 0 {
|
||||
sessions.iter().map(|x| x.entrainment_score).sum::<f64>() / n as f64
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let stops = sessions.iter().filter(|x| !x.safety_pass).count();
|
||||
// Adverse flag from the stored vector (index 19 is sticky).
|
||||
let drift_flagged = s
|
||||
.profile_for(&tag)
|
||||
.map(|p| p.vector[19] >= 1.0)
|
||||
.unwrap_or(false);
|
||||
ParticipantRow {
|
||||
tag,
|
||||
sessions: n,
|
||||
mean_entrainment: mean,
|
||||
safety_stops: stops,
|
||||
drift_flagged,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Json(rows)
|
||||
}
|
||||
|
||||
async fn participant_detail(
|
||||
State(store): State<SharedStore>,
|
||||
Path(tag): Path<String>,
|
||||
) -> Result<Json<ParticipantDetail>, StatusCode> {
|
||||
let s = store.read().await;
|
||||
let sessions = s.sessions_for(&tag);
|
||||
let profile = s.profile_for(&tag);
|
||||
if sessions.is_empty() && profile.is_none() {
|
||||
return Err(StatusCode::NOT_FOUND);
|
||||
}
|
||||
// Frequency curve: prefer the stored profile's transferable map; fall back
|
||||
// to per-session (frequency, score) points.
|
||||
let mut frequency_curve: Vec<(f64, f64)> = match profile {
|
||||
Some(p) if !p.frequency_scores.is_empty() => p.frequency_scores.clone(),
|
||||
_ => sessions
|
||||
.iter()
|
||||
.map(|x| (x.frequency_hz, x.entrainment_score))
|
||||
.collect(),
|
||||
};
|
||||
frequency_curve.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
|
||||
Ok(Json(ParticipantDetail {
|
||||
tag,
|
||||
frequency_curve,
|
||||
sessions: sessions
|
||||
.iter()
|
||||
.map(|x| SessionPoint {
|
||||
frequency_hz: x.frequency_hz,
|
||||
entrainment_score: x.entrainment_score,
|
||||
comfort: x.comfort,
|
||||
safety_pass: x.safety_pass,
|
||||
session_hash: x.session_hash.clone(),
|
||||
timestamp_ms: x.timestamp_ms,
|
||||
})
|
||||
.collect(),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn cohort(State(store): State<SharedStore>) -> Json<CohortView> {
|
||||
let s = store.read().await;
|
||||
let profiles = s.profiles();
|
||||
let n = profiles.len();
|
||||
if n == 0 {
|
||||
return Json(CohortView { clusters: Vec::new() });
|
||||
}
|
||||
let k = 3.min(n);
|
||||
let assign = profiles.cluster(k, 10);
|
||||
let mut clusters: Vec<Cluster> = (0..k).map(|_| Cluster { members: Vec::new() }).collect();
|
||||
for (i, &c) in assign.iter().enumerate() {
|
||||
if let Some(p) = profiles.profile(i) {
|
||||
clusters[c].members.push(p.profile_tag.clone());
|
||||
}
|
||||
}
|
||||
clusters.retain(|c| !c.members.is_empty());
|
||||
Json(CohortView { clusters })
|
||||
}
|
||||
|
||||
async fn acceptance(State(store): State<SharedStore>) -> impl IntoResponse {
|
||||
let s = store.read().await;
|
||||
// Serialize the stored summaries directly — `released_claim` is the gate's
|
||||
// output, recorded at evaluation time; this surface never reconstructs or
|
||||
// upgrades a claim.
|
||||
let list: Vec<_> = s.acceptance_reports().values().cloned().collect();
|
||||
Json(list)
|
||||
}
|
||||
|
||||
async fn integrity(State(store): State<SharedStore>) -> impl IntoResponse {
|
||||
let s = store.read().await;
|
||||
Json(s.verify_chain())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::store::{AcceptanceSummary, ClinicRecord, SessionSummary};
|
||||
use axum::body::Body;
|
||||
use axum::http::Request;
|
||||
use ruview_gamma::acceptance::NO_CLAIM;
|
||||
use ruview_gamma::ruvector::{AnonymizedProfile, VECTOR_DIM};
|
||||
use tower::ServiceExt;
|
||||
|
||||
async fn body_json(res: axum::response::Response) -> serde_json::Value {
|
||||
let bytes = axum::body::to_bytes(res.into_body(), 1 << 20).await.unwrap();
|
||||
serde_json::from_slice(&bytes).unwrap()
|
||||
}
|
||||
|
||||
fn seeded_store() -> SharedStore {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("clinic.jsonl");
|
||||
let mut s = ClinicStore::open(&path).unwrap();
|
||||
let mut vector = [0.5; VECTOR_DIM];
|
||||
vector[11] = 39.0;
|
||||
s.append(ClinicRecord::Profile(AnonymizedProfile {
|
||||
profile_tag: "tag-a".into(),
|
||||
vector,
|
||||
frequency_scores: vec![(38.0, 0.5), (39.0, 0.8), (40.0, 0.6)],
|
||||
}))
|
||||
.unwrap();
|
||||
for (hz, score, pass) in [(38.0, 0.5, true), (39.0, 0.8, true), (40.0, 0.6, false)] {
|
||||
s.append(ClinicRecord::Session(SessionSummary {
|
||||
profile_tag: "tag-a".into(),
|
||||
program_id: "alzheimers-research".into(),
|
||||
frequency_hz: hz,
|
||||
entrainment_score: score,
|
||||
comfort: 0.85,
|
||||
safety_pass: pass,
|
||||
session_hash: "cd".repeat(32),
|
||||
timestamp_ms: 1,
|
||||
}))
|
||||
.unwrap();
|
||||
}
|
||||
// One passed and one withheld acceptance verdict.
|
||||
s.append(ClinicRecord::Acceptance(AcceptanceSummary {
|
||||
program_id: "attention-working-memory".into(),
|
||||
entrainment_gain: 0.3,
|
||||
safety_stop_rate: 0.0,
|
||||
mean_adherence: 0.95,
|
||||
repeatability_band_hz: 0.8,
|
||||
overall_pass: true,
|
||||
released_claim: "personalized frequency-response discovery".into(),
|
||||
}))
|
||||
.unwrap();
|
||||
s.append(ClinicRecord::Acceptance(AcceptanceSummary {
|
||||
program_id: "home-wellness".into(),
|
||||
entrainment_gain: 0.05,
|
||||
safety_stop_rate: 0.0,
|
||||
mean_adherence: 0.9,
|
||||
repeatability_band_hz: 3.0,
|
||||
overall_pass: false,
|
||||
released_claim: NO_CLAIM.into(),
|
||||
}))
|
||||
.unwrap();
|
||||
// Leak the tempdir so the file outlives the test router.
|
||||
std::mem::forget(dir);
|
||||
Arc::new(RwLock::new(s))
|
||||
}
|
||||
|
||||
async fn get(router: &Router, uri: &str) -> axum::response::Response {
|
||||
router
|
||||
.clone()
|
||||
.oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dashboard_html_served() {
|
||||
let r = router(seeded_store());
|
||||
let res = get(&r, "/").await;
|
||||
assert_eq!(res.status(), StatusCode::OK);
|
||||
let bytes = axum::body::to_bytes(res.into_body(), 1 << 20).await.unwrap();
|
||||
let html = String::from_utf8(bytes.to_vec()).unwrap();
|
||||
assert!(html.contains("Clinical Dashboard"));
|
||||
assert!(html.contains("research use only"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn participants_lists_sessions_and_stops() {
|
||||
let r = router(seeded_store());
|
||||
let v = body_json(get(&r, "/api/clinic/participants").await).await;
|
||||
assert_eq!(v[0]["tag"], "tag-a");
|
||||
assert_eq!(v[0]["sessions"], 3);
|
||||
assert_eq!(v[0]["safety_stops"], 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn participant_detail_serves_sorted_frequency_curve() {
|
||||
let r = router(seeded_store());
|
||||
let v = body_json(get(&r, "/api/clinic/participants/tag-a").await).await;
|
||||
let curve = v["frequency_curve"].as_array().unwrap();
|
||||
assert_eq!(curve.len(), 3);
|
||||
// Sorted ascending by frequency.
|
||||
assert!(curve[0][0].as_f64().unwrap() < curve[2][0].as_f64().unwrap());
|
||||
assert_eq!(v["sessions"].as_array().unwrap().len(), 3);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unknown_participant_is_404() {
|
||||
let r = router(seeded_store());
|
||||
assert_eq!(get(&r, "/api/clinic/participants/nobody").await.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn acceptance_payload_uses_gated_claim() {
|
||||
let r = router(seeded_store());
|
||||
let v = body_json(get(&r, "/api/clinic/acceptance").await).await;
|
||||
let list = v.as_array().unwrap();
|
||||
assert_eq!(list.len(), 2);
|
||||
// The failed program surfaces NO_CLAIM verbatim — never its raw claim.
|
||||
let withheld = list.iter().find(|a| a["program_id"] == "home-wellness").unwrap();
|
||||
assert_eq!(withheld["overall_pass"], false);
|
||||
assert_eq!(withheld["released_claim"], NO_CLAIM);
|
||||
let passed = list.iter().find(|a| a["program_id"] == "attention-working-memory").unwrap();
|
||||
assert_eq!(passed["released_claim"], "personalized frequency-response discovery");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cohort_and_integrity_endpoints_respond() {
|
||||
let r = router(seeded_store());
|
||||
let co = body_json(get(&r, "/api/clinic/cohort").await).await;
|
||||
assert!(co["clusters"].as_array().unwrap().len() >= 1);
|
||||
let integ = body_json(get(&r, "/api/clinic/integrity").await).await;
|
||||
assert_eq!(integ["valid"], true);
|
||||
assert_eq!(integ["records"], 6);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn surface_is_read_only_no_mutating_routes() {
|
||||
// POST to every route must not be routable (405/404), proving the
|
||||
// surface cannot actuate or write.
|
||||
let r = router(seeded_store());
|
||||
for uri in [
|
||||
"/",
|
||||
"/api/clinic/participants",
|
||||
"/api/clinic/participants/tag-a",
|
||||
"/api/clinic/cohort",
|
||||
"/api/clinic/acceptance",
|
||||
"/api/clinic/integrity",
|
||||
] {
|
||||
let res = r
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri(uri)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
res.status() == StatusCode::METHOD_NOT_ALLOWED
|
||||
|| res.status() == StatusCode::NOT_FOUND,
|
||||
"POST {uri} unexpectedly routable: {}",
|
||||
res.status()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
//! Persistent, hash-chained RuVector store (ADR-251 §2.1).
|
||||
//!
|
||||
//! Append-only JSON-lines file holding three record kinds — anonymized
|
||||
//! profiles, witnessed session summaries, and acceptance reports. Every line
|
||||
//! is hash-chained: `entry_hash = SHA-256(prev_hash ‖ canonical_record_json)`,
|
||||
//! so any retroactive edit, deletion, or reorder breaks [`ClinicStore::verify_chain`].
|
||||
//! The RuVector in-memory layer (kNN, clustering) is rebuilt from the file on
|
||||
//! [`ClinicStore::open`], so cohort warm-start survives restarts.
|
||||
//!
|
||||
//! Pseudonymity: records carry only the one-way profile tags from ADR-250 §10
|
||||
//! — never a `person_id`, never raw sensor data.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use ruview_gamma::ruvector::{AnonymizedProfile, ProfileStore};
|
||||
|
||||
/// Store errors.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum StoreError {
|
||||
/// Filesystem failure.
|
||||
#[error("io error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
/// A line failed to parse.
|
||||
#[error("corrupt record at line {line}: {reason}")]
|
||||
Corrupt { line: usize, reason: String },
|
||||
}
|
||||
|
||||
/// One witnessed session summary, as persisted for the dashboard. A projection
|
||||
/// of `ruview_gamma::session::SessionRecord` keyed by the one-way profile tag.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SessionSummary {
|
||||
/// One-way profile tag (never a person_id).
|
||||
pub profile_tag: String,
|
||||
/// Program the session ran under.
|
||||
pub program_id: String,
|
||||
/// Stimulation frequency (Hz).
|
||||
pub frequency_hz: f64,
|
||||
/// Safe-entrainment score for the session.
|
||||
pub entrainment_score: f64,
|
||||
/// Participant comfort `[0,1]`.
|
||||
pub comfort: f64,
|
||||
/// Whether the session passed without a safety stop.
|
||||
pub safety_pass: bool,
|
||||
/// The session's witness hash (hex SHA-256 from the RuFlo builder).
|
||||
pub session_hash: String,
|
||||
/// Caller-supplied epoch milliseconds.
|
||||
pub timestamp_ms: u64,
|
||||
}
|
||||
|
||||
/// One persisted acceptance verdict (the gate's output, never the raw claim).
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct AcceptanceSummary {
|
||||
/// Program graded.
|
||||
pub program_id: String,
|
||||
/// Measured entrainment gain vs the fixed prior.
|
||||
pub entrainment_gain: f64,
|
||||
/// Measured safety-stop rate.
|
||||
pub safety_stop_rate: f64,
|
||||
/// Measured mean adherence.
|
||||
pub mean_adherence: f64,
|
||||
/// Optimal-frequency spread across repeats (Hz).
|
||||
pub repeatability_band_hz: f64,
|
||||
/// Whether all four criteria passed.
|
||||
pub overall_pass: bool,
|
||||
/// The claim **as released by the gate** (`NO_CLAIM` on failure).
|
||||
pub released_claim: String,
|
||||
}
|
||||
|
||||
/// A store record: exactly one of the three kinds per line.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum ClinicRecord {
|
||||
/// Anonymized responder profile (upserted by tag on load).
|
||||
Profile(AnonymizedProfile),
|
||||
/// Witnessed session summary.
|
||||
Session(SessionSummary),
|
||||
/// Acceptance verdict for a program.
|
||||
Acceptance(AcceptanceSummary),
|
||||
}
|
||||
|
||||
/// One persisted line: the record plus its chain hash.
|
||||
///
|
||||
/// `record` is kept as a [`serde_json::value::RawValue`] so the chain hashes
|
||||
/// the **exact bytes on disk**. Re-serializing a parsed record is not
|
||||
/// byte-stable: serde_json's default float parsing is fast-but-lossy (±1 ulp;
|
||||
/// exact parsing is behind its `float_roundtrip` feature), so
|
||||
/// `to_string(from_str(x))` can differ from `x` for long float literals —
|
||||
/// hash-by-reserialization would self-corrupt.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct ChainedLine {
|
||||
record: Box<serde_json::value::RawValue>,
|
||||
/// hex SHA-256(prev_hash ‖ raw record json bytes)
|
||||
entry_hash: String,
|
||||
}
|
||||
|
||||
/// Result of an integrity check.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
pub struct ChainStatus {
|
||||
/// Whether every entry hash verified.
|
||||
pub valid: bool,
|
||||
/// Number of records in the chain.
|
||||
pub records: usize,
|
||||
/// First broken line (1-based), if any.
|
||||
pub broken_at: Option<usize>,
|
||||
}
|
||||
|
||||
/// The persistent clinic store: hash-chained JSONL on disk + the RuVector
|
||||
/// in-memory layer rebuilt on open.
|
||||
pub struct ClinicStore {
|
||||
path: PathBuf,
|
||||
/// Last entry hash (hex) — the chain head.
|
||||
head: String,
|
||||
/// RuVector layer over the loaded profiles (kNN, clustering).
|
||||
profiles: ProfileStore,
|
||||
/// Session summaries by profile tag, in append order.
|
||||
sessions: BTreeMap<String, Vec<SessionSummary>>,
|
||||
/// Latest acceptance verdict per program.
|
||||
acceptance: BTreeMap<String, AcceptanceSummary>,
|
||||
}
|
||||
|
||||
/// Chain-genesis constant (the `prev_hash` of the first record).
|
||||
const GENESIS: &str = "ruview-gamma-clinic-genesis-v1";
|
||||
|
||||
fn entry_hash(prev: &str, record_json: &str) -> String {
|
||||
let mut h = Sha256::new();
|
||||
h.update(prev.as_bytes());
|
||||
h.update(record_json.as_bytes());
|
||||
let d = h.finalize();
|
||||
let mut s = String::with_capacity(64);
|
||||
for b in d {
|
||||
s.push_str(&format!("{b:02x}"));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
impl ClinicStore {
|
||||
/// Open (or create) a store at `path`, replaying and verifying every line.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`StoreError::Corrupt`] if a line fails to parse or breaks the chain —
|
||||
/// fail closed: a tampered store refuses to open rather than silently
|
||||
/// serving doctored data.
|
||||
pub fn open(path: impl AsRef<Path>) -> Result<Self, StoreError> {
|
||||
let path = path.as_ref().to_path_buf();
|
||||
let mut store = Self {
|
||||
path: path.clone(),
|
||||
head: GENESIS.to_string(),
|
||||
profiles: ProfileStore::new(),
|
||||
sessions: BTreeMap::new(),
|
||||
acceptance: BTreeMap::new(),
|
||||
};
|
||||
if !path.exists() {
|
||||
return Ok(store);
|
||||
}
|
||||
let file = File::open(&path)?;
|
||||
for (i, line) in BufReader::new(file).lines().enumerate() {
|
||||
let line = line?;
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let chained: ChainedLine =
|
||||
serde_json::from_str(&line).map_err(|e| StoreError::Corrupt {
|
||||
line: i + 1,
|
||||
reason: e.to_string(),
|
||||
})?;
|
||||
// Hash the exact raw bytes from disk — never a re-serialization.
|
||||
let expect = entry_hash(&store.head, chained.record.get());
|
||||
if expect != chained.entry_hash {
|
||||
return Err(StoreError::Corrupt {
|
||||
line: i + 1,
|
||||
reason: "hash chain broken".into(),
|
||||
});
|
||||
}
|
||||
let record: ClinicRecord =
|
||||
serde_json::from_str(chained.record.get()).map_err(|e| StoreError::Corrupt {
|
||||
line: i + 1,
|
||||
reason: e.to_string(),
|
||||
})?;
|
||||
store.head = chained.entry_hash;
|
||||
store.apply(record);
|
||||
}
|
||||
Ok(store)
|
||||
}
|
||||
|
||||
/// Apply a record to the in-memory views.
|
||||
fn apply(&mut self, record: ClinicRecord) {
|
||||
match record {
|
||||
ClinicRecord::Profile(p) => self.profiles.upsert(p),
|
||||
ClinicRecord::Session(s) => {
|
||||
self.sessions.entry(s.profile_tag.clone()).or_default().push(s)
|
||||
}
|
||||
ClinicRecord::Acceptance(a) => {
|
||||
self.acceptance.insert(a.program_id.clone(), a);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Append a record: chain-hash its exact serialized bytes, write the line,
|
||||
/// update memory.
|
||||
pub fn append(&mut self, record: ClinicRecord) -> Result<(), StoreError> {
|
||||
let record_json = serde_json::to_string(&record)
|
||||
.map_err(|e| StoreError::Corrupt { line: 0, reason: e.to_string() })?;
|
||||
let hash = entry_hash(&self.head, &record_json);
|
||||
let raw = serde_json::value::RawValue::from_string(record_json)
|
||||
.map_err(|e| StoreError::Corrupt { line: 0, reason: e.to_string() })?;
|
||||
let line = serde_json::to_string(&ChainedLine {
|
||||
record: raw,
|
||||
entry_hash: hash.clone(),
|
||||
})
|
||||
.map_err(|e| StoreError::Corrupt { line: 0, reason: e.to_string() })?;
|
||||
let mut f = OpenOptions::new().create(true).append(true).open(&self.path)?;
|
||||
f.write_all(line.as_bytes())?;
|
||||
f.write_all(b"\n")?;
|
||||
self.head = hash;
|
||||
self.apply(record);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Re-read the file from disk and verify the whole chain (tamper check).
|
||||
pub fn verify_chain(&self) -> ChainStatus {
|
||||
let file = match File::open(&self.path) {
|
||||
Ok(f) => f,
|
||||
Err(_) => {
|
||||
return ChainStatus { valid: true, records: 0, broken_at: None };
|
||||
}
|
||||
};
|
||||
let mut prev = GENESIS.to_string();
|
||||
let mut n = 0usize;
|
||||
for (i, line) in BufReader::new(file).lines().enumerate() {
|
||||
let Ok(line) = line else {
|
||||
return ChainStatus { valid: false, records: n, broken_at: Some(i + 1) };
|
||||
};
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let parsed: Result<ChainedLine, _> = serde_json::from_str(&line);
|
||||
let Ok(chained) = parsed else {
|
||||
return ChainStatus { valid: false, records: n, broken_at: Some(i + 1) };
|
||||
};
|
||||
if entry_hash(&prev, chained.record.get()) != chained.entry_hash {
|
||||
return ChainStatus { valid: false, records: n, broken_at: Some(i + 1) };
|
||||
}
|
||||
prev = chained.entry_hash;
|
||||
n += 1;
|
||||
}
|
||||
ChainStatus { valid: true, records: n, broken_at: None }
|
||||
}
|
||||
|
||||
/// The RuVector layer over loaded profiles (kNN / warm-start / clustering).
|
||||
pub fn profiles(&self) -> &ProfileStore {
|
||||
&self.profiles
|
||||
}
|
||||
|
||||
/// Sessions for one profile tag, in append order.
|
||||
pub fn sessions_for(&self, tag: &str) -> &[SessionSummary] {
|
||||
self.sessions.get(tag).map(Vec::as_slice).unwrap_or(&[])
|
||||
}
|
||||
|
||||
/// All profile tags with at least one session or profile, sorted.
|
||||
pub fn participant_tags(&self) -> Vec<String> {
|
||||
let mut tags: Vec<String> = self.sessions.keys().cloned().collect();
|
||||
for i in 0..self.profiles.len() {
|
||||
if let Some(p) = self.profiles.profile(i) {
|
||||
if !tags.contains(&p.profile_tag) {
|
||||
tags.push(p.profile_tag.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
tags.sort();
|
||||
tags
|
||||
}
|
||||
|
||||
/// The stored profile for a tag, if any.
|
||||
pub fn profile_for(&self, tag: &str) -> Option<&AnonymizedProfile> {
|
||||
(0..self.profiles.len())
|
||||
.filter_map(|i| self.profiles.profile(i))
|
||||
.find(|p| p.profile_tag == tag)
|
||||
}
|
||||
|
||||
/// Latest acceptance verdicts, keyed by program id.
|
||||
pub fn acceptance_reports(&self) -> &BTreeMap<String, AcceptanceSummary> {
|
||||
&self.acceptance
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ruview_gamma::ruvector::VECTOR_DIM;
|
||||
|
||||
fn profile(tag: &str, peak: f64) -> AnonymizedProfile {
|
||||
let mut vector = [0.5; VECTOR_DIM];
|
||||
vector[5] = 13.0;
|
||||
vector[11] = peak;
|
||||
AnonymizedProfile {
|
||||
profile_tag: tag.into(),
|
||||
vector,
|
||||
frequency_scores: vec![(peak - 1.0, 0.5), (peak, 0.8), (peak + 1.0, 0.5)],
|
||||
}
|
||||
}
|
||||
|
||||
fn session(tag: &str, hz: f64, score: f64) -> SessionSummary {
|
||||
SessionSummary {
|
||||
profile_tag: tag.into(),
|
||||
program_id: "alzheimers-research".into(),
|
||||
frequency_hz: hz,
|
||||
entrainment_score: score,
|
||||
comfort: 0.9,
|
||||
safety_pass: true,
|
||||
session_hash: "ab".repeat(32),
|
||||
timestamp_ms: 1_700_000_000_000,
|
||||
}
|
||||
}
|
||||
|
||||
fn tmp() -> (tempfile::TempDir, PathBuf) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("clinic.jsonl");
|
||||
(dir, path)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrips_all_record_kinds_across_reopen() {
|
||||
let (_d, path) = tmp();
|
||||
{
|
||||
let mut s = ClinicStore::open(&path).unwrap();
|
||||
s.append(ClinicRecord::Profile(profile("tag-a", 39.0))).unwrap();
|
||||
s.append(ClinicRecord::Session(session("tag-a", 39.0, 0.7))).unwrap();
|
||||
s.append(ClinicRecord::Session(session("tag-a", 39.5, 0.75))).unwrap();
|
||||
s.append(ClinicRecord::Acceptance(AcceptanceSummary {
|
||||
program_id: "sleep-optimization".into(),
|
||||
entrainment_gain: 0.25,
|
||||
safety_stop_rate: 0.0,
|
||||
mean_adherence: 0.95,
|
||||
repeatability_band_hz: 1.0,
|
||||
overall_pass: true,
|
||||
released_claim: "sleep-state-timed entrainment optimization".into(),
|
||||
}))
|
||||
.unwrap();
|
||||
}
|
||||
let s = ClinicStore::open(&path).unwrap();
|
||||
assert_eq!(s.participant_tags(), vec!["tag-a".to_string()]);
|
||||
assert_eq!(s.sessions_for("tag-a").len(), 2);
|
||||
assert_eq!(s.profile_for("tag-a").unwrap().frequency_scores.len(), 3);
|
||||
assert!(s.acceptance_reports().contains_key("sleep-optimization"));
|
||||
let st = s.verify_chain();
|
||||
assert!(st.valid);
|
||||
assert_eq!(st.records, 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tampered_chain_is_detected_and_refuses_open() {
|
||||
let (_d, path) = tmp();
|
||||
{
|
||||
let mut s = ClinicStore::open(&path).unwrap();
|
||||
s.append(ClinicRecord::Session(session("tag-a", 40.0, 0.6))).unwrap();
|
||||
s.append(ClinicRecord::Session(session("tag-a", 41.0, 0.7))).unwrap();
|
||||
}
|
||||
// Doctor the first line's score 0.6 -> 0.9 (a retroactive edit).
|
||||
let text = std::fs::read_to_string(&path).unwrap();
|
||||
let doctored = text.replacen("0.6", "0.9", 1);
|
||||
assert_ne!(text, doctored);
|
||||
std::fs::write(&path, doctored).unwrap();
|
||||
// Open fails closed…
|
||||
assert!(matches!(
|
||||
ClinicStore::open(&path),
|
||||
Err(StoreError::Corrupt { line: 1, .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_a_line_breaks_the_chain() {
|
||||
let (_d, path) = tmp();
|
||||
{
|
||||
let mut s = ClinicStore::open(&path).unwrap();
|
||||
s.append(ClinicRecord::Session(session("a", 40.0, 0.5))).unwrap();
|
||||
s.append(ClinicRecord::Session(session("a", 41.0, 0.6))).unwrap();
|
||||
s.append(ClinicRecord::Session(session("a", 42.0, 0.7))).unwrap();
|
||||
}
|
||||
let text = std::fs::read_to_string(&path).unwrap();
|
||||
let pruned: Vec<&str> = text.lines().enumerate().filter(|(i, _)| *i != 1).map(|(_, l)| l).collect();
|
||||
std::fs::write(&path, pruned.join("\n")).unwrap();
|
||||
assert!(ClinicStore::open(&path).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn knn_survives_reload() {
|
||||
let (_d, path) = tmp();
|
||||
{
|
||||
let mut s = ClinicStore::open(&path).unwrap();
|
||||
s.append(ClinicRecord::Profile(profile("lo", 37.0))).unwrap();
|
||||
s.append(ClinicRecord::Profile(profile("hi", 43.0))).unwrap();
|
||||
}
|
||||
let s = ClinicStore::open(&path).unwrap();
|
||||
let mut q = [0.5; VECTOR_DIM];
|
||||
q[5] = 13.0;
|
||||
q[11] = 37.0;
|
||||
let nn = s.profiles().k_nearest(&q, 1);
|
||||
assert_eq!(s.profiles().profile(nn[0].0).unwrap().profile_tag, "lo");
|
||||
// Warm-start priors are constructible from the reloaded store.
|
||||
assert!(!s.profiles().warm_start_prior(&q, 2, 1e-4).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_store_is_valid() {
|
||||
let (_d, path) = tmp();
|
||||
let s = ClinicStore::open(&path).unwrap();
|
||||
let st = s.verify_chain();
|
||||
assert!(st.valid);
|
||||
assert_eq!(st.records, 0);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user