mirror of
https://github.com/ruvnet/RuView
synced 2026-08-09 20:21:43 +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]
|
## [Unreleased]
|
||||||
|
|
||||||
### Added
|
### 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.*
|
- **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.
|
- **`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.
|
- **`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 |
|
| `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. |
|
| `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-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/`)
|
### RuvSense Modules (`signal/src/ruvsense/`)
|
||||||
| Module | Purpose |
|
| 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",
|
"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]]
|
[[package]]
|
||||||
name = "ruview-swarm"
|
name = "ruview-swarm"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ members = [
|
|||||||
"crates/homecore-server", # iter-9 — HOMECORE integration binary (all 8 crates wired together)
|
"crates/homecore-server", # iter-9 — HOMECORE integration binary (all 8 crates wired together)
|
||||||
"crates/ruview-swarm", # ADR-148 — drone swarm control system
|
"crates/ruview-swarm", # ADR-148 — drone swarm control system
|
||||||
"crates/ruview-gamma", # ADR-250 — adaptive gamma entrainment (governed research platform)
|
"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),
|
# ADR-040: WASM edge crate targets wasm32-unknown-unknown (no_std),
|
||||||
# excluded from workspace to avoid breaking `cargo test --workspace`.
|
# 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