From d6662f735cf61468d22608b951b2ba495948a12b Mon Sep 17 00:00:00 2001 From: ruv Date: Mon, 3 Aug 2026 13:15:43 -0400 Subject: [PATCH] feat: establish NeuroSleep safety foundation --- crates/helix-demo/src/lib.rs | 8 +- crates/helix-evolve/examples/evolve_full.rs | 7 +- crates/helix-evolve/src/lib.rs | 2 +- crates/helix-focus/src/lib.rs | 36 +++- crates/helix-numeric/src/lib.rs | 90 +++++--- crates/helix-numeric/tests/properties.rs | 6 +- crates/helix-pipeline/src/lib.rs | 48 +++-- crates/helix-pipeline/tests/pipeline.rs | 52 ++++- crates/helix-provenance/src/lib.rs | 19 ++ crates/helix-timeline/src/lib.rs | 80 +++++-- ...signed-neurosleep-qeeg-phenotype-bridge.md | 196 ++++++++++++++++++ docs/adr/README.md | 3 +- ui/app.js | 9 +- 13 files changed, 463 insertions(+), 93 deletions(-) create mode 100644 docs/adr/ADR-051-signed-neurosleep-qeeg-phenotype-bridge.md diff --git a/crates/helix-demo/src/lib.rs b/crates/helix-demo/src/lib.rs index d9e2785..7dd9ba1 100644 --- a/crates/helix-demo/src/lib.rs +++ b/crates/helix-demo/src/lib.rs @@ -1796,8 +1796,10 @@ mod tests { let AnswerOutcome::Answered(ans) = analyze(&req, &builtin_registry_v1()).unwrap() else { panic!("ferritin should produce a grounded answer, not an abstention"); }; - // Recovering (rising) but the claim is grounded in all three real records. - assert_eq!(ans.trend.direction, TrendDirection::Rising); + // The point values remain grounded, but three draws cannot establish an + // ADR-007 trend (minimum five). + assert_eq!(ans.trend.direction, None); + assert!(ans.trend.trend_abstention.is_some()); assert_eq!(ans.trend.sample_size, 3); assert_eq!(ans.claims.len(), 1); assert_eq!(ans.claims[0].evidence().len(), 3); @@ -1824,7 +1826,7 @@ mod tests { let AnswerOutcome::Answered(ans) = analyze(&req, &builtin_registry_v1()).unwrap() else { panic!("deep sleep should answer"); }; - assert_eq!(ans.trend.direction, TrendDirection::Rising); + assert_eq!(ans.trend.direction, Some(TrendDirection::Rising)); } #[test] diff --git a/crates/helix-evolve/examples/evolve_full.rs b/crates/helix-evolve/examples/evolve_full.rs index 8defe3f..d5bc2f5 100644 --- a/crates/helix-evolve/examples/evolve_full.rs +++ b/crates/helix-evolve/examples/evolve_full.rs @@ -351,9 +351,10 @@ fn classify(p: &Params, c: &EvalCase, reg: &helix_escalation::ThresholdRegistry) (AnswerOutcome::Abstained(_), Expected::Abstained) => ("abstained".into(), true), (AnswerOutcome::Abstained(_), Expected::Answered(_)) => ("abstained".into(), false), (AnswerOutcome::Answered(_), Expected::Abstained) => ("answered".into(), false), - (AnswerOutcome::Answered(a), Expected::Answered(w)) => { - (format!("{:?}", a.trend.direction), a.trend.direction == *w) - } + (AnswerOutcome::Answered(a), Expected::Answered(w)) => ( + format!("{:?}", a.trend.direction), + a.trend.direction == Some(*w), + ), }; (got, ok) } diff --git a/crates/helix-evolve/src/lib.rs b/crates/helix-evolve/src/lib.rs index c91e808..7ffdba7 100644 --- a/crates/helix-evolve/src/lib.rs +++ b/crates/helix-evolve/src/lib.rs @@ -146,7 +146,7 @@ pub fn fitness(p: &Params, cases: &[EvalCase], registry: &ThresholdRegistry) -> f.score -= P_OVER_CAUTIOUS; } (Expected::Answered(want), AnswerOutcome::Answered(got)) => { - if got.trend.direction == *want { + if got.trend.direction == Some(*want) { f.grounded_correct += 1; f.score += W_GROUNDED; } else { diff --git a/crates/helix-focus/src/lib.rs b/crates/helix-focus/src/lib.rs index 6b30005..9db25e9 100644 --- a/crates/helix-focus/src/lib.rs +++ b/crates/helix-focus/src/lib.rs @@ -12,7 +12,7 @@ use serde::{Deserialize, Serialize}; -use helix_numeric::{slope_per_day, Point}; +use helix_numeric::{slope_per_day, Point, MIN_TREND_OBSERVATIONS}; use helix_provenance::{EpochMillis, ProvRecord, RangePosition}; /// Why a concept was surfaced as a focus area. @@ -78,7 +78,7 @@ fn latest<'a>(recs: &'a [&ProvRecord]) -> &'a ProvRecord { /// further out as worsening. Here we conservatively flag a sustained move toward /// (or further past) the nearest breached bound. fn adverse_slope(recs: &[&ProvRecord], band: f64) -> Option { - if recs.len() < 3 { + if recs.len() < MIN_TREND_OBSERVATIONS { return None; } let mut pts: Vec = recs @@ -210,11 +210,13 @@ mod tests { #[test] fn worsening_out_of_range_is_elevated() { - // ferritin below range and still falling across 3 draws + // ferritin below range and still falling across five draws (ADR-007). let recs = vec![ - rec("a", "2276-4", "Ferritin", 60, 33.0, 30.0, 400.0), - rec("b", "2276-4", "Ferritin", 30, 28.0, 30.0, 400.0), - rec("c", "2276-4", "Ferritin", 0, 22.0, 30.0, 400.0), + rec("a", "2276-4", "Ferritin", 120, 36.0, 30.0, 400.0), + rec("b", "2276-4", "Ferritin", 90, 33.0, 30.0, 400.0), + rec("c", "2276-4", "Ferritin", 60, 30.0, 30.0, 400.0), + rec("d", "2276-4", "Ferritin", 30, 27.0, 30.0, 400.0), + rec("e", "2276-4", "Ferritin", 0, 22.0, 30.0, 400.0), ]; let out = select_focus(&recs, 1000 * DAY, &FocusConfig::default()); assert_eq!(out[0].reason, FocusReason::WorseningTrend); @@ -227,6 +229,20 @@ mod tests { assert!(select_focus(&recs, 1000 * DAY, &FocusConfig::default()).is_empty()); } + #[test] + fn four_points_cannot_be_promoted_to_worsening_trend() { + let recs = vec![ + rec("a", "2276-4", "Ferritin", 90, 33.0, 30.0, 400.0), + rec("b", "2276-4", "Ferritin", 60, 29.0, 30.0, 400.0), + rec("c", "2276-4", "Ferritin", 30, 25.0, 30.0, 400.0), + rec("d", "2276-4", "Ferritin", 0, 20.0, 30.0, 400.0), + ]; + let out = select_focus(&recs, 1000 * DAY, &FocusConfig::default()); + assert_eq!(out[0].reason, FocusReason::OutOfRange); + assert_eq!(out[0].severity, Severity::Watch); + assert!(!out[0].message.contains("trending")); + } + #[test] fn stale_critical_marker_prompts_retest() { let cfg = FocusConfig { @@ -245,9 +261,11 @@ mod tests { fn ranking_puts_elevated_first() { let recs = vec![ rec("p", "x", "Calm marker", 0, 9.0, 3.0, 10.0), // in range - rec("a", "y", "Bad-a", 60, 33.0, 30.0, 400.0), - rec("b", "y", "Bad-a", 30, 28.0, 30.0, 400.0), - rec("c", "y", "Bad-a", 0, 22.0, 30.0, 400.0), // worsening → elevated + rec("a", "y", "Bad-a", 120, 36.0, 30.0, 400.0), + rec("b", "y", "Bad-a", 90, 33.0, 30.0, 400.0), + rec("c", "y", "Bad-a", 60, 30.0, 30.0, 400.0), + rec("d", "y", "Bad-a", 30, 27.0, 30.0, 400.0), + rec("e", "y", "Bad-a", 0, 22.0, 30.0, 400.0), // worsening → elevated ]; let out = select_focus(&recs, 1000 * DAY, &FocusConfig::default()); assert_eq!(out[0].severity, Severity::Elevated); diff --git a/crates/helix-numeric/src/lib.rs b/crates/helix-numeric/src/lib.rs index 1464993..2e0dcf8 100644 --- a/crates/helix-numeric/src/lib.rs +++ b/crates/helix-numeric/src/lib.rs @@ -19,6 +19,27 @@ use thiserror::Error; /// the wall clock). pub type EpochMillis = i64; +/// ADR-007 policy minima. Keeping these in the numeric authority prevents +/// downstream consumers from silently reintroducing weaker local thresholds. +pub const MIN_TREND_OBSERVATIONS: usize = 5; +pub const MIN_CORRELATION_PAIRS: usize = 20; +pub const MIN_CHANGE_POINT_SEGMENT: usize = 10; + +/// A typed, serializable explanation for an unavailable numeric result. +/// Consumers carry this next to an absent statistic instead of coercing the +/// absence into a valid `flat`/`stable` result. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "reason", rename_all = "snake_case")] +pub enum NumericAbstention { + InsufficientObservations { needed: usize, got: usize }, +} + +impl NumericAbstention { + pub const fn insufficient(needed: usize, got: usize) -> Self { + Self::InsufficientObservations { needed, got } + } +} + /// One observation in a series. #[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] pub struct Point { @@ -106,11 +127,12 @@ pub fn percent_change(series: &[Point]) -> Result { } /// Ordinary least-squares slope of value vs. time, in **units per day**. -/// Positive == trending up. Minimum: 2 points; errors if all timestamps equal. +/// Positive == trending up. Minimum: 5 points (ADR-007); errors if all +/// timestamps equal. pub fn slope_per_day(series: &[Point]) -> Result { - if series.len() < 2 { + if series.len() < MIN_TREND_OBSERVATIONS { return Err(NumericError::TooFewPoints { - needed: 2, + needed: MIN_TREND_OBSERVATIONS, got: series.len(), }); } @@ -202,7 +224,7 @@ pub fn range_crossings( } /// Pearson correlation between two equal-length, index-aligned series of -/// values. Minimum: 3 pairs (below that, correlation is not meaningful). +/// values. Minimum: 20 aligned pairs (ADR-007). /// Returns a value in `[-1.0, 1.0]`. pub fn pearson(a: &[f64], b: &[f64]) -> Result { if a.len() != b.len() { @@ -211,9 +233,9 @@ pub fn pearson(a: &[f64], b: &[f64]) -> Result { got: a.len().min(b.len()), }); } - if a.len() < 3 { + if a.len() < MIN_CORRELATION_PAIRS { return Err(NumericError::TooFewPoints { - needed: 3, + needed: MIN_CORRELATION_PAIRS, got: a.len(), }); } @@ -247,11 +269,11 @@ pub struct ChangePoint { } /// Detects the single most significant change-point via the maximum absolute -/// CUSUM deviation from the global mean. Minimum: 4 points (need ≥2 on each -/// side of a split). Returns `Ok(None)` when no interior split improves on a -/// flat series. +/// CUSUM deviation from the global mean. ADR-007 requires at least 10 points on +/// each side of a candidate split. Returns `Ok(None)` when no eligible split +/// improves on a flat series. pub fn change_point(series: &[Point]) -> Result, NumericError> { - const MIN: usize = 4; + const MIN: usize = MIN_CHANGE_POINT_SEGMENT * 2; if series.len() < MIN { return Err(NumericError::TooFewPoints { needed: MIN, @@ -267,13 +289,16 @@ pub fn change_point(series: &[Point]) -> Result, NumericErro let mut best_abs = 0.0; for (i, p) in series.iter().enumerate().take(n - 1) { cusum += p.value - global_mean; - if cusum.abs() > best_abs { + let split = i + 1; + if split >= MIN_CHANGE_POINT_SEGMENT + && n - split >= MIN_CHANGE_POINT_SEGMENT + && cusum.abs() > best_abs + { best_abs = cusum.abs(); best_idx = i; } } - // Require at least 2 points on each side. - if best_idx < 1 || best_idx > n - 3 { + if best_abs == 0.0 { return Ok(None); } let split = best_idx + 1; @@ -395,7 +420,7 @@ mod tests { #[test] fn slope_is_units_per_day() { // +2 per day, exactly. - let s = series(&[(0, 0.0), (1, 2.0), (2, 4.0), (3, 6.0)]); + let s = series(&[(0, 0.0), (1, 2.0), (2, 4.0), (3, 6.0), (4, 8.0)]); assert!((slope_per_day(&s).unwrap() - 2.0).abs() < 1e-9); assert_eq!(trend_direction(2.0, 0.1), TrendDirection::Rising); } @@ -405,7 +430,10 @@ mod tests { let s = series(&[(0, 1.0)]); assert_eq!( slope_per_day(&s), - Err(NumericError::TooFewPoints { needed: 2, got: 1 }) + Err(NumericError::TooFewPoints { + needed: MIN_TREND_OBSERVATIONS, + got: 1 + }) ); } @@ -434,41 +462,43 @@ mod tests { #[test] fn pearson_perfect_positive() { - let a = [1.0, 2.0, 3.0, 4.0]; - let b = [2.0, 4.0, 6.0, 8.0]; + let a: Vec = (1..=20).map(f64::from).collect(); + let b: Vec = a.iter().map(|x| x * 2.0).collect(); assert!((pearson(&a, &b).unwrap() - 1.0).abs() < 1e-12); } #[test] - fn pearson_needs_three_pairs() { + fn pearson_needs_twenty_pairs() { assert_eq!( pearson(&[1.0, 2.0], &[1.0, 2.0]), - Err(NumericError::TooFewPoints { needed: 3, got: 2 }) + Err(NumericError::TooFewPoints { + needed: MIN_CORRELATION_PAIRS, + got: 2 + }) ); } #[test] fn change_point_finds_level_shift() { - // flat at 10 then flat at 20. - let s = series(&[ - (0, 10.0), - (1, 10.0), - (2, 10.0), - (3, 20.0), - (4, 20.0), - (5, 20.0), - ]); + // Ten observations at 10 then ten at 20. + let values: Vec<(i64, f64)> = (0..20) + .map(|day| (day, if day < 10 { 10.0 } else { 20.0 })) + .collect(); + let s = series(&values); let cp = change_point(&s).unwrap().expect("a change-point"); assert!((cp.mean_before - 10.0).abs() < 1e-9); assert!((cp.mean_after - 20.0).abs() < 1e-9); } #[test] - fn change_point_needs_four_points() { + fn change_point_needs_ten_points_per_segment() { let s = series(&[(0, 1.0), (1, 2.0), (2, 3.0)]); assert_eq!( change_point(&s), - Err(NumericError::TooFewPoints { needed: 4, got: 3 }) + Err(NumericError::TooFewPoints { + needed: MIN_CHANGE_POINT_SEGMENT * 2, + got: 3 + }) ); } } diff --git a/crates/helix-numeric/tests/properties.rs b/crates/helix-numeric/tests/properties.rs index c6b12c5..46aadaa 100644 --- a/crates/helix-numeric/tests/properties.rs +++ b/crates/helix-numeric/tests/properties.rs @@ -36,14 +36,14 @@ proptest! { /// slope is finite, and a strictly-increasing series yields a positive slope. #[test] - fn slope_finite_and_signed(series in ordered_series(2..50)) { + fn slope_finite_and_signed(series in ordered_series(5..50)) { let s = slope_per_day(&series).unwrap(); prop_assert!(s.is_finite()); } /// A monotonically increasing series always has non-negative slope. #[test] - fn monotone_increasing_has_nonneg_slope(start in -1000.0f64..1000.0, step in 0.0f64..100.0, n in 2usize..40) { + fn monotone_increasing_has_nonneg_slope(start in -1000.0f64..1000.0, step in 0.0f64..100.0, n in 5usize..40) { let series: Vec = (0..n) .map(|i| Point::new(i as i64 * DAY, start + step * i as f64)) .collect(); @@ -53,7 +53,7 @@ proptest! { /// pearson is always within [-1, 1] for any valid finite input. #[test] - fn pearson_in_unit_interval(a in prop::collection::vec(-1e3f64..1e3, 3..40)) { + fn pearson_in_unit_interval(a in prop::collection::vec(-1e3f64..1e3, 20..40)) { // pair each a[i] with a noisy transform; correlation must stay bounded. let b: Vec = a.iter().enumerate().map(|(i, x)| x * 2.0 + (i as f64).cos()).collect(); if let Ok(r) = pearson(&a, &b) { diff --git a/crates/helix-pipeline/src/lib.rs b/crates/helix-pipeline/src/lib.rs index 3475089..8334d77 100644 --- a/crates/helix-pipeline/src/lib.rs +++ b/crates/helix-pipeline/src/lib.rs @@ -23,8 +23,8 @@ use thiserror::Error; use helix_escalation::{EscalationLevel, EscalationResult, ThresholdRegistry}; use helix_evidence::{assess, AnswerVerdict, EvidenceTier, GapNotice, TieredRecommendation}; use helix_numeric::{ - self as num, range_crossings, slope_per_day, trend_direction, Point, RangeCrossing, - TrendDirection, + self as num, range_crossings, slope_per_day, trend_direction, NumericAbstention, Point, + RangeCrossing, TrendDirection, MIN_TREND_OBSERVATIONS, }; use helix_provenance::{ground, DraftClaim, EpochMillis, GroundedClaim, ProvRecord, RecordId}; @@ -58,9 +58,11 @@ pub struct TrendFacts { pub latest_value: f64, pub latest_at: EpochMillis, pub mean: f64, - /// `None` when there are fewer than 2 points (trend undefined). + /// `None` when there are fewer than five points (ADR-007 trend undefined). pub slope_per_day: Option, - pub direction: TrendDirection, + pub direction: Option, + /// Present exactly when trend fields abstain for insufficient observations. + pub trend_abstention: Option, pub percent_change: Option, pub crossings: Vec, pub sample_size: usize, @@ -160,7 +162,7 @@ pub fn analyze( // 3. Deterministic numerics (ADR-007). let series = series_of(req.records); let mean = num::mean(&series)?; - let (slope, direction, pct) = if series.len() >= 2 { + let (slope, direction, trend_abstention) = if series.len() >= MIN_TREND_OBSERVATIONS { let s = slope_per_day(&series)?; // Prefer the scale-invariant relative band (ADR-036) when configured and a // reference range is available; otherwise the absolute band. @@ -171,9 +173,21 @@ pub fn analyze( } _ => trend_direction(s, req.flat_band_per_day), }; - (Some(s), dir, num::percent_change(&series).ok()) + (Some(s), Some(dir), None) } else { - (None, TrendDirection::Flat, None) + ( + None, + None, + Some(NumericAbstention::insufficient( + MIN_TREND_OBSERVATIONS, + series.len(), + )), + ) + }; + let pct = if series.len() >= 2 { + num::percent_change(&series).ok() + } else { + None }; let crossings = if series.len() >= 2 { range_crossings(&series, req.reference_low, req.reference_high)? @@ -186,6 +200,7 @@ pub fn analyze( mean, slope_per_day: slope, direction, + trend_abstention, percent_change: pct, crossings, sample_size: series.len(), @@ -194,9 +209,10 @@ pub fn analyze( // 4. Ground every claim (ADR-005). let cites: Vec = req.records.iter().map(|r| r.id.clone()).collect(); let dir_word = match trend.direction { - TrendDirection::Rising => "trending up", - TrendDirection::Falling => "trending down", - TrendDirection::Flat => "stable", + Some(TrendDirection::Rising) => "trending up", + Some(TrendDirection::Falling) => "trending down", + Some(TrendDirection::Flat) => "stable", + None => "not yet supported by enough readings for a trend", }; let claim_text = format!( "Your {} is {} {} and {} over your last {} reading(s).", @@ -209,13 +225,17 @@ pub fn analyze( let recommendation = if escalation.suppress_optimization { None } else { - Some(TieredRecommendation::new( - format!( + let text = match trend.direction { + Some(_) => format!( "Track {} on your next panel to confirm the {} trend.", latest.concept, dir_word ), - EvidenceTier::YourData, - )) + None => format!( + "Add another {} measurement before interpreting a trend.", + latest.concept + ), + }; + Some(TieredRecommendation::new(text, EvidenceTier::YourData)) }; Ok(AnswerOutcome::Answered(Box::new(GroundedAnswer { diff --git a/crates/helix-pipeline/tests/pipeline.rs b/crates/helix-pipeline/tests/pipeline.rs index 057925d..21a7646 100644 --- a/crates/helix-pipeline/tests/pipeline.rs +++ b/crates/helix-pipeline/tests/pipeline.rs @@ -48,8 +48,10 @@ fn fresh_falling_ferritin_yields_grounded_cited_trended_answer() { let reg = builtin_registry_v1(); let records = vec![ ferritin("f1", 100, 45.0), - ferritin("f2", 130, 33.0), - ferritin("f3", 160, 28.0), + ferritin("f2", 115, 39.0), + ferritin("f3", 130, 34.0), + ferritin("f4", 145, 31.0), + ferritin("f5", 160, 28.0), ]; let req = AnalyzeRequest { concept_code: "2276-4", @@ -73,14 +75,14 @@ fn fresh_falling_ferritin_yields_grounded_cited_trended_answer() { assert!(ans.recommendation.is_some()); // Deterministic trend: falling, with a range crossing into Below. - assert_eq!(ans.trend.direction, TrendDirection::Falling); - assert_eq!(ans.trend.sample_size, 3); + assert_eq!(ans.trend.direction, Some(TrendDirection::Falling)); + assert_eq!(ans.trend.sample_size, 5); assert!(ans.trend.slope_per_day.unwrap() < 0.0); assert_eq!(ans.trend.crossings.len(), 1); // 33 -> 28 crosses below 30 - // Grounded: exactly one claim, backed by all three real records. + // Grounded: exactly one claim, backed by all five real records. assert_eq!(ans.claims.len(), 1); - assert_eq!(ans.claims[0].evidence().len(), 3); + assert_eq!(ans.claims[0].evidence().len(), 5); assert!(ans.claims[0].text().contains("trending down")); // Serializable end to end (UI / audit). @@ -88,6 +90,44 @@ fn fresh_falling_ferritin_yields_grounded_cited_trended_answer() { assert!(json.contains("Ferritin")); } +#[test] +fn four_readings_answer_point_value_but_abstain_from_trend() { + let reg = builtin_registry_v1(); + let records = vec![ + ferritin("f1", 100, 45.0), + ferritin("f2", 120, 40.0), + ferritin("f3", 140, 35.0), + ferritin("f4", 160, 35.0), + ]; + let req = AnalyzeRequest { + concept_code: "2276-4", + records: &records, + now: 161 * DAY, + staleness_window_days: 365, + confidence_floor: 0.5, + reference_low: Some(30.0), + reference_high: Some(400.0), + flat_band_per_day: 0.01, + flat_band_frac: 0.0, + }; + let AnswerOutcome::Answered(ans) = analyze(&req, ®).unwrap() else { + panic!("point value should remain answerable"); + }; + assert_eq!(ans.trend.direction, None); + assert_eq!( + ans.trend.trend_abstention, + Some(helix_numeric::NumericAbstention::InsufficientObservations { needed: 5, got: 4 }) + ); + assert!(ans.claims[0].text().contains("not yet supported")); + assert_eq!( + ans.recommendation.as_ref().map(|r| r.text.as_str()), + Some("Add another Ferritin measurement before interpreting a trend.") + ); + let json = serde_json::to_string(&ans.trend).unwrap(); + assert!(json.contains("insufficient_observations")); + assert!(!json.contains("\"direction\":\"flat\"")); +} + #[test] fn stale_data_abstains_with_gap_notice() { let reg = builtin_registry_v1(); diff --git a/crates/helix-provenance/src/lib.rs b/crates/helix-provenance/src/lib.rs index 3defb5e..b411143 100644 --- a/crates/helix-provenance/src/lib.rs +++ b/crates/helix-provenance/src/lib.rs @@ -36,6 +36,10 @@ pub enum MeasurementMethod { OcrExtraction, /// Wearable / device telemetry. Device, + /// Direct electrical measurement of physiological activity (for example, + /// EEG). Added for provenance v2 consumers; existing serialized method + /// names remain unchanged and continue to deserialize identically. + Electrophysiology, /// Contactless ambient sensing (Cognitum Seed, ADR-014) — screening grade. AmbientSensing, /// Entered by the user by hand. @@ -316,4 +320,19 @@ mod tests { assert!(json.contains("Ferritin")); assert!(json.contains("2276-4")); } + + #[test] + fn electrophysiology_has_a_distinct_stable_wire_value() { + let json = serde_json::to_string(&MeasurementMethod::Electrophysiology).unwrap(); + assert_eq!(json, "\"electrophysiology\""); + assert_eq!( + serde_json::from_str::(&json).unwrap(), + MeasurementMethod::Electrophysiology + ); + // Existing provenance-v1 wire values remain unchanged. + assert_eq!( + serde_json::to_string(&MeasurementMethod::Device).unwrap(), + "\"device\"" + ); + } } diff --git a/crates/helix-timeline/src/lib.rs b/crates/helix-timeline/src/lib.rs index fbe6f8e..ec7ba39 100644 --- a/crates/helix-timeline/src/lib.rs +++ b/crates/helix-timeline/src/lib.rs @@ -11,7 +11,10 @@ use serde::{Deserialize, Serialize}; -use helix_numeric::{change_point, slope_per_day, trend_direction, Point, TrendDirection}; +use helix_numeric::{ + change_point, slope_per_day, trend_direction, NumericAbstention, Point, TrendDirection, + MIN_CHANGE_POINT_SEGMENT, MIN_TREND_OBSERVATIONS, +}; use helix_score::{compose, SubScore}; /// A dated snapshot of the subsystem sub-scores (the inputs available at `at`). @@ -35,10 +38,12 @@ pub struct ScorePoint { pub struct Timeline { pub points: Vec, /// Overall direction across the series (units = score points/day). - pub direction: TrendDirection, + pub direction: Option, pub slope_per_day: Option, + pub trend_abstention: Option, /// Most significant change-point timestamp, if any. pub change_point_at: Option, + pub change_point_abstention: Option, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -82,25 +87,41 @@ pub fn build_timeline( // Trend + change-point over the value series (deterministic, ADR-007). let series: Vec = points.iter().map(|p| Point::new(p.at, p.value)).collect(); - let (slope, direction) = if series.len() >= 2 { + let (slope, direction, trend_abstention) = if series.len() >= MIN_TREND_OBSERVATIONS { match slope_per_day(&series) { - Ok(s) => (Some(s), trend_direction(s, flat_band)), - Err(_) => (None, TrendDirection::Flat), + Ok(s) => (Some(s), Some(trend_direction(s, flat_band)), None), + Err(_) => (None, None, None), } } else { - (None, TrendDirection::Flat) + ( + None, + None, + Some(NumericAbstention::insufficient( + MIN_TREND_OBSERVATIONS, + series.len(), + )), + ) }; - let change_point_at = if series.len() >= 4 { - change_point(&series).ok().flatten().map(|cp| cp.at) + let change_point_min = MIN_CHANGE_POINT_SEGMENT * 2; + let (change_point_at, change_point_abstention) = if series.len() >= change_point_min { + (change_point(&series).ok().flatten().map(|cp| cp.at), None) } else { - None + ( + None, + Some(NumericAbstention::insufficient( + change_point_min, + series.len(), + )), + ) }; Ok(Timeline { points, direction, slope_per_day: slope, + trend_abstention, change_point_at, + change_point_abstention, }) } @@ -140,25 +161,31 @@ mod tests { #[test] fn rising_series_is_rising() { - let tl = build_timeline(vec![snap(0, 60.0), snap(10, 70.0), snap(20, 80.0)], 0.01).unwrap(); - assert_eq!(tl.direction, TrendDirection::Rising); - assert!(tl.slope_per_day.unwrap() > 0.0); - } - - #[test] - fn detects_change_point() { - // flat at 60 then jumps to 85 let tl = build_timeline( vec![ snap(0, 60.0), - snap(10, 61.0), - snap(20, 84.0), - snap(30, 85.0), + snap(10, 65.0), + snap(20, 70.0), + snap(30, 75.0), + snap(40, 80.0), ], 0.01, ) .unwrap(); + assert_eq!(tl.direction, Some(TrendDirection::Rising)); + assert!(tl.slope_per_day.unwrap() > 0.0); + assert!(tl.trend_abstention.is_none()); + } + + #[test] + fn detects_change_point() { + // Ten low observations then ten high observations. + let snapshots = (0..20) + .map(|day| snap(day, if day < 10 { 60.0 } else { 85.0 })) + .collect(); + let tl = build_timeline(snapshots, 0.01).unwrap(); assert!(tl.change_point_at.is_some()); + assert!(tl.change_point_abstention.is_none()); } #[test] @@ -171,4 +198,17 @@ mod tests { let tl = build_timeline(vec![snap(20, 80.0), snap(0, 60.0)], 0.01).unwrap(); assert!(tl.points[0].at < tl.points[1].at); } + + #[test] + fn insufficient_points_serialize_as_abstention_not_flat() { + let tl = build_timeline(vec![snap(0, 60.0), snap(10, 60.0)], 0.01).unwrap(); + assert_eq!(tl.direction, None); + assert_eq!( + tl.trend_abstention, + Some(NumericAbstention::InsufficientObservations { needed: 5, got: 2 }) + ); + let json = serde_json::to_string(&tl).unwrap(); + assert!(json.contains("insufficient_observations")); + assert!(!json.contains("\"direction\":\"flat\"")); + } } diff --git a/docs/adr/ADR-051-signed-neurosleep-qeeg-phenotype-bridge.md b/docs/adr/ADR-051-signed-neurosleep-qeeg-phenotype-bridge.md new file mode 100644 index 0000000..8084f1f --- /dev/null +++ b/docs/adr/ADR-051-signed-neurosleep-qeeg-phenotype-bridge.md @@ -0,0 +1,196 @@ +# ADR-051: Signed NeuroSleep qEEG Phenotype Bridge from rUv Neural to Helix + +**Status**: Proposed +**Date**: 2026-08-03 +**Decision scope**: `ruvnet/ruv-neural` and `ruvnet/helix` +**Canonical owner**: Helix +**Upstream companion**: `ruv-neural/docs/adr/0015-neurosleep-qeeg-export.md` +**Related**: ADR-001, ADR-003, ADR-005–010, ADR-013, ADR-018, ADR-020, ADR-026, +ADR-036, ADR-050 + +## Context + +Constantino and colleagues reported NREM loss, altered delta/theta power, reduced theta +coherence, a reduced aperiodic exponent, and EEG slowing in APP/PS1 mice. Microglial +depletion restored sleep in that model without clearing amyloid. This is preclinical animal +evidence: it supplies neither a validated human mapping from EEG to microglial state nor +human diagnostic sensitivity, specificity, predictive value, or treatment efficacy. + +rUv Neural already owns EEG acquisition abstractions, signal preprocessing, Welch PSD, +band power, coherence, sleep-state types, and evidence generation. Helix owns local health +provenance, deterministic longitudinal analysis, evidence qualification, abstention, and +user-facing claims. The existing loose `NeuralSession` adapter is not suitable: it accepts +unknown metric keys, does not cryptographically verify trusted origin, discards the real +upstream envelope, and labels EEG as ambient sensing. + +At the audited bases (`ruv-neural` `caaa14144a70829293737b0ca717ebc818fcc523`, Helix +`87ff0e151a3e8e1b52ac9e07cc03673668ada756`), the generic numeric implementation also +disagreed with ADR-007 by admitting trends, correlations, and change-points below their +documented sample minima. Those numeric invariants are an independent prerequisite. + +## Decision + +Implement a local-first, research-only, one-way NeuroSleep observation rail. rUv Neural +owns bounded import, preprocessing, sleep-state-aware qEEG, artifact masking, numeric +quality, method manifests, canonical payload hashing, and signing. Helix independently +verifies the exact released upstream contract, trusted signer identity, consent, replay, +quality, and compatibility before storing derived research features and producing +deterministic longitudinal observations. + +Ship it in a physically separate offline research build. Runtime flags stage import, +shadow analysis, research UI, and optional indexing inside that build; flags alone are not +the safety or regulatory boundary. NeuroSleep types and values have no path to a hosted +language model, composite health score, Focus Areas, red-flag escalation, general +recommendations, protocol selection, stimulation loop, or actuator. + +The allowed output is a measured change from a compatible personal baseline with explicit +measurement support and interpretation maturity. Constantino-linked interpretation remains +`preclinical_mouse_model`. Helix must not infer or estimate Alzheimer disease, mild +cognitive impairment, amyloid burden, microglial activation, neuroinflammation, treatment +response, or clinical risk, and must not recommend a drug, supplement, sleep intervention, +gamma entrainment, or other treatment from this evidence. Deterministic approved templates, +not an LLM, generate clinically adjacent copy. + +The MetaHarness/Darwin optimization surfaces, including `helix-evolve`, may test stable +generic parameters but must not mutate NeuroSleep contracts, trust rules, quality gates, +sample-count minima, compatibility rules, translational labels, forbidden-claim rules, or +the observation-to-actuation boundary. + +## Contract and trust boundary + +Authoritative Rust types live in `ruv-neural-core`; Helix consumes an exact released +version and must not maintain handwritten duplicate wire structs. Version one carries a +signed payload containing study-scoped pseudonymous subject and recording identifiers, +time bounds, nonce, consent scope, source digest and size, acquisition metadata, algorithm +manifest, quality, stage summary, stage-specific qEEG, a compatibility fingerprint, and +literature context. + +Every payload field is included in an RFC 8785 canonical JSON SHA-256 digest. Ed25519 signs +`ruv-neural/neurosleep/1\0 || signer_key_id || \0 || payload_sha256`; key identifiers +containing NUL are rejected. A public key embedded in a bundle is never a trust root. +Helix resolves the signer from an enrolled personal-device, laboratory, or study trust +profile and checks revocation at the verification time. Unknown fields, schemas, metrics, +units, species, extractor digests, and untrusted keys are quarantined or rejected, never +coerced. + +Ingestion is fail closed and atomic. The payload digest, recording identifier, and nonce +form the idempotency/replay key: identical reimport returns the existing result; reuse with +a different payload is rejected. A one-byte change creates no provenance record, time +series point, or index reference. Legacy `RUVN-*` records remain `legacy_unverified` and +are excluded from NeuroSleep analytics. + +`MeasurementMethod::Electrophysiology` is the provenance-v2 method. A compatibility path +may use `Device` plus required `AcquisitionModality::Eeg`; EEG is never ambient sensing. +Acquisition confidence and interpretation maturity are distinct fields. + +## Numeric, method, and storage invariants + +The longitudinal statistical unit is one valid night, never an epoch. Generic ADR-007 +gates are trend >= 5 observations, correlation >= 20 aligned observations, and change +point >= 10 observations on each side. NeuroSleep adds stricter gates: >= 7 compatible +valid nights for baseline, >= 14 before a user-facing direction/slope, >= 20 for +correlation, and >= 10 per change-point side. Insufficiency is a typed abstention and must +never serialize as `flat`, `stable`, or a zero. + +Helix trends only identical compatibility fingerprints unless a separately validated bridge +connects them. The fingerprint binds device and modality, channels/reference/montage, +sampling rate and firmware, every DSP and artifact parameter, stage source/model, crate and +source versions, extractor/configuration digests, and schema version. A method change, +missing stage provenance, excessive artifact, low fit quality, or insufficient compatible +nights abstains. + +Raw EEG/EOG/EMG and epoch arrays remain encrypted locally and never enter Helix language +model context, browser logs, telemetry, federation, or the signed derived bundle. Helix +stores verified scalar features and the evidence envelope in a sealed study-scoped exact +time series. Raw neural data, identifiers, phenotype labels, and numeric vectors do not +enter an unencrypted RVF or semantic index. A dedicated RuVector namespace is a separate +capability gate requiring encryption, authorization, reference-only retrieval, and +plaintext-leakage tests. + +## DSP and sufficiency profile + +Version one imports bounded EDF/EDF+ sessions through an `EpochSource`; BrainVision remains +compatible and author data may use a separate adapter. Expert hypnograms are preferred. +Heart-rate/motion proxy staging may label context but cannot support paper-equivalent qEEG. +Automated EEG/EMG staging is a separate experimental feature. + +The initial closed metric registry includes sleep-state durations/bouts; absolute and +relative delta/theta power; relevant alpha power; theta center frequency and power; +pairwise full-band/theta coherence; aperiodic exponent/offset and fit error; and artifact +burden, all with fixed units and finite-or-typed-null values. Artifact-affected epochs are +masked/rejected, never cross-channel interpolated; coherence pairs use the same synchronous +mask. + +The paper-compatible profile uses 10-second, 2,500-sample epochs after explicit resampling +to 250 Hz and records all known differences from the publication. Unspecified coherence, +relative-power, and FOOOF details are implementation choices until obtained from the +authors. Wake-only aperiodic fits abstain below the frozen quality gates. Engineering +coverage and stage-duration minima are sufficiency gates, not clinical thresholds. + +## Security, privacy, and regulatory boundary + +Raw neural waveforms are P0 restricted; derived nightly features and method metadata are P1 +sensitive. Signer keys, identity linkage, consent, revocation, and recovery material remain +separately protected. Processing is purpose- and study-scoped, deny-by-default, with +withdrawal, retention, access, export, and deletion audit outcomes that contain reason codes +but no neural values. + +The first release is observational research software, not an Alzheimer detector or a +treatment surface. Any human disease screening/prediction track requires counsel-reviewed +device classification, a regulated build, quality and cybersecurity lifecycles, and locked +prospective external validation. The existing 40 Hz stimulation loop remains separate; +the cited mouse study used CSF1R-mediated depletion and does not establish gamma entrainment +efficacy. Pexidartinib and other interventions are outside this decision. + +## Delivery and rollback + +Delivery order is: Helix H0 numeric/provenance safety foundation; upstream N1 contract and +trusted signing fixtures; N2 bounded I/O; N3 DSP/profile and parity; upstream alpha release; +Helix H1 verified ingestion/sealed storage; H2 native/WASM derived-bundle parity; H3 escaped +research UI; then integrated validation. Optional encrypted RuVector work cannot block the +typed time-series path. + +Four research-build flags default off: `neurosleep.import.v1`, `neurosleep.shadow.v1`, +`neurosleep.research_ui.v1`, and `neurosleep.rvf.v1`. Rollback disables the affected flag, +stops new acceptance, and suppresses derived analytics without destructive migration; +versioned evidence remains retained under policy and is never silently reinterpreted. + +## Acceptance evidence + +- **AT-01–03**: synthetic/reference PSD, band power, FOOOF, theta peak, and coherence parity; + fit/mask failures are typed nulls or abstentions. +- **AT-04**: all 197 Sleep-EDF Expanded recordings deterministically succeed or return a + bounded rejection without panic, unbounded allocation, or partial write. This validates + mechanics, not disease accuracy. +- **AT-05–07**: every analytic/method field is bound; untrusted/revoked keys fail; reimport + is idempotent and nonce/recording conflicts fail. +- **AT-08–09**: fingerprints segment comparisons; <14 compatible valid nights abstains; + correlation requires 20 nights and change-points 10 per side. +- **AT-10**: dependency and end-to-end negative tests prove no score, Focus Area, + escalation, recommendation, disease claim, hosted model, protocol, or actuator path. +- **AT-11**: frozen eight-hour and 24-hour fixtures meet declared native latency/memory and + bundle-size targets. +- **AT-12**: formatting, lint, workspace, WASM, contract, tamper, fuzz, consent, + authorization, leakage, UI-injection, dependency-audit, and native/WASM byte-parity gates + pass in both repositories. + +Final acceptance is a clean Helix research installation that verifies a trusted bounded +fixture bundle field-for-field, stores only derived research features, abstains through the +compatible-night minimum, then displays a reproducible baseline change with a preclinical +caveat, while rejecting tamper, untrusted origin, replay, incompatible method, insufficient +nights, and every diagnostic or therapeutic claim. + +## Consequences + +This establishes one numeric authority, local neural-data custody, cryptographically +verifiable provenance, and method-aware longitudinal research output. Costs are coordinated +cross-repository releases, parser/DSP maintenance, conservative early abstention, and no +near-term disease claim. A future human-validation or regulated-product ADR must revisit +intended use, cohorts, confounders, retention jurisdictions, and clinical ownership. + +## References + +- [Constantino et al. study](https://pubmed.ncbi.nlm.nih.gov/42252510/) +- [Sleep-EDF Expanded](https://physionet.org/content/sleep-edfx/1.0.0/) +- [FDA Clinical Decision Support Software guidance](https://www.fda.gov/regulatory-information/search-fda-guidance-documents/clinical-decision-support-software) +- [FDA pexidartinib prescribing information](https://www.accessdata.fda.gov/drugsatfda_docs/label/2025/211810s013lbl.pdf) diff --git a/docs/adr/README.md b/docs/adr/README.md index 3f47319..b0c4031 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -58,9 +58,10 @@ The product spec these decisions implement is [`../Helix-PHI-ADR-Product-Spec.md | [035](ADR-035-darwin-parameter-evolution.md) | Darwin-Style Parameter Evolution (safety-frozen) | Self-optimization / safety | | [036](ADR-036-scale-invariant-trend-band.md) | Scale-Invariant (Reference-Range-Relative) Trend Dead-Band | Accuracy / numerics | | [050](ADR-050-openmed-local-clinical-text-privacy-gate.md) | OpenMed Local Clinical-Text Privacy Gate | Privacy / on-device NLP | +| [051](ADR-051-signed-neurosleep-qeeg-phenotype-bridge.md) | Signed NeuroSleep qEEG Phenotype Bridge from rUv Neural to Helix | Research neurophysiology / safety | ## Status -35 Proposed + ADR-036 Accepted (v1.0.0). They are derived from the v1.0.0 product spec and +ADR-001–035 and ADR-051 are Proposed; ADR-036 and ADR-050 are Accepted. They are derived from the v1.0.0 product spec and grounded by multi-source research; they have not yet been ratified against an implementation or reviewed by regulatory counsel / a clinical advisory board. diff --git a/ui/app.js b/ui/app.js index 7a2f1a6..601a052 100644 --- a/ui/app.js +++ b/ui/app.js @@ -251,7 +251,8 @@ function renderBriefing() { const el = document.getElementById("briefing"); if (out.outcome === "answered" && out.claims?.length) { const tr = out.trend; - const dirWord = { rising: "slowly recovering", falling: "still trending down", flat: "holding steady" }[tr.direction] || tr.direction; + const dirWord = { rising: "slowly recovering", falling: "still trending down", flat: "holding steady" }[tr.direction] + || "not yet supported by enough readings for a trend"; const e = out.claims[0].evidence[out.claims[0].evidence.length - 1]; const belowRange = e.reference_range && e.reference_range.low != null && e.value < e.reference_range.low; el.innerHTML = `${belowRange ? "Low ferritin may be behind your afternoon energy dips" : "Your ferritin looks on track"} — @@ -325,7 +326,8 @@ function renderAnswer(out, q) { `; } const tr = a.trend; - const dir = { rising: "trending up", falling: "trending down", flat: "stable" }[tr.direction]; + const dir = { rising: "trending up", falling: "trending down", flat: "stable" }[tr.direction] + || "insufficient readings for a trend"; const claim = a.claims[0]; const cites = claim.evidence .map((e) => `
• ${e.concept} ${e.value} ${e.unit} — ${e.source}, ${fmtDate(e.measured_at)}
`) @@ -494,7 +496,8 @@ function sparkline(tl) { const area = `${line} L${sx(x1).toFixed(1)},${h - pad} L${sx(x0).toFixed(1)},${h - pad} Z`; const dots = pts.map((p) => ``).join(""); const cp = tl.change_point_at ? `` : ""; - const dir = { rising: "▲ improving", falling: "▼ slipping", flat: "→ steady" }[tl.direction] || ""; + const dir = { rising: "▲ improving", falling: "▼ slipping", flat: "→ steady" }[tl.direction] + || "trend unavailable"; return `