diff --git a/CHANGELOG.md b/CHANGELOG.md index be89c0a4..4e46272b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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:`) — 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. diff --git a/CLAUDE.md b/CLAUDE.md index 7eac3df9..503131bb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 | diff --git a/docs/adr/ADR-251-gamma-clinical-dashboard.md b/docs/adr/ADR-251-gamma-clinical-dashboard.md new file mode 100644 index 00000000..2c0cccce --- /dev/null +++ b/docs/adr/ADR-251-gamma-clinical-dashboard.md @@ -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` | diff --git a/v2/Cargo.lock b/v2/Cargo.lock index dbac3724..56fd5fb6 100644 --- a/v2/Cargo.lock +++ b/v2/Cargo.lock @@ -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" diff --git a/v2/Cargo.toml b/v2/Cargo.toml index 43289531..3c5aa1b9 100644 --- a/v2/Cargo.toml +++ b/v2/Cargo.toml @@ -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`. diff --git a/v2/crates/ruview-gamma-clinic/Cargo.toml b/v2/crates/ruview-gamma-clinic/Cargo.toml new file mode 100644 index 00000000..c901dd6d --- /dev/null +++ b/v2/crates/ruview-gamma-clinic/Cargo.toml @@ -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" diff --git a/v2/crates/ruview-gamma-clinic/src/dashboard.html b/v2/crates/ruview-gamma-clinic/src/dashboard.html new file mode 100644 index 00000000..09ad0f8a --- /dev/null +++ b/v2/crates/ruview-gamma-clinic/src/dashboard.html @@ -0,0 +1,141 @@ + + + + +RuView Gamma — Clinical Dashboard (research use only) + + + +
+

RuView Gamma — Clinical Dashboard

+ read-only · pseudonymous · research use only — not a medical device + checking… +
+
+ +
+

Frequency response map

+

Session trend (entrainment ─ / comfort ╌ / safety stops ●)

+

Program acceptance (claims released only by the gate)

+ + +
programgainstopsadherencerepeat ±Hzverdictreleased claim
+
+
+
+ + + + diff --git a/v2/crates/ruview-gamma-clinic/src/lib.rs b/v2/crates/ruview-gamma-clinic/src/lib.rs new file mode 100644 index 00000000..91cff404 --- /dev/null +++ b/v2/crates/ruview-gamma-clinic/src/lib.rs @@ -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> { +//! 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 { + let profile = gov.export_anonymized_profile(); + let tag = profile.profile_tag.clone(); + store.append(ClinicRecord::Profile(profile))?; + + let known: std::collections::BTreeSet = 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); + } +} diff --git a/v2/crates/ruview-gamma-clinic/src/main.rs b/v2/crates/ruview-gamma-clinic/src/main.rs new file mode 100644 index 00000000..bd76947e --- /dev/null +++ b/v2/crates/ruview-gamma-clinic/src/main.rs @@ -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> { + 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(()) +} diff --git a/v2/crates/ruview-gamma-clinic/src/server.rs b/v2/crates/ruview-gamma-clinic/src/server.rs new file mode 100644 index 00000000..fde37cce --- /dev/null +++ b/v2/crates/ruview-gamma-clinic/src/server.rs @@ -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>; + +/// 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, +} + +#[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, +} + +#[derive(Debug, Serialize)] +struct Cluster { + members: Vec, +} + +/// 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) -> Json> { + 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::() / 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, + Path(tag): Path, +) -> Result, 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) -> Json { + 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 = (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) -> 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) -> 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() + ); + } + } +} diff --git a/v2/crates/ruview-gamma-clinic/src/store.rs b/v2/crates/ruview-gamma-clinic/src/store.rs new file mode 100644 index 00000000..6a620225 --- /dev/null +++ b/v2/crates/ruview-gamma-clinic/src/store.rs @@ -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, + /// 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, +} + +/// 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>, + /// Latest acceptance verdict per program. + acceptance: BTreeMap, +} + +/// 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) -> Result { + 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 = 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 { + let mut tags: Vec = 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 { + &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); + } +}