Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions crates/helix-demo/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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]
Expand Down
7 changes: 4 additions & 3 deletions crates/helix-evolve/examples/evolve_full.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
2 changes: 1 addition & 1 deletion crates/helix-evolve/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
36 changes: 27 additions & 9 deletions crates/helix-focus/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<f64> {
if recs.len() < 3 {
if recs.len() < MIN_TREND_OBSERVATIONS {
return None;
}
let mut pts: Vec<Point> = recs
Expand Down Expand Up @@ -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);
Expand All @@ -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 {
Expand All @@ -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);
Expand Down
90 changes: 60 additions & 30 deletions crates/helix-numeric/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -106,11 +127,12 @@ pub fn percent_change(series: &[Point]) -> Result<f64, NumericError> {
}

/// 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<f64, NumericError> {
if series.len() < 2 {
if series.len() < MIN_TREND_OBSERVATIONS {
return Err(NumericError::TooFewPoints {
needed: 2,
needed: MIN_TREND_OBSERVATIONS,
got: series.len(),
});
}
Expand Down Expand Up @@ -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<f64, NumericError> {
if a.len() != b.len() {
Expand All @@ -211,9 +233,9 @@ pub fn pearson(a: &[f64], b: &[f64]) -> Result<f64, NumericError> {
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(),
});
}
Expand Down Expand Up @@ -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<Option<ChangePoint>, NumericError> {
const MIN: usize = 4;
const MIN: usize = MIN_CHANGE_POINT_SEGMENT * 2;
if series.len() < MIN {
return Err(NumericError::TooFewPoints {
needed: MIN,
Expand All @@ -267,13 +289,16 @@ pub fn change_point(series: &[Point]) -> Result<Option<ChangePoint>, 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;
Expand Down Expand Up @@ -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);
}
Expand All @@ -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
})
);
}

Expand Down Expand Up @@ -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<f64> = (1..=20).map(f64::from).collect();
let b: Vec<f64> = 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
})
);
}
}
6 changes: 3 additions & 3 deletions crates/helix-numeric/tests/properties.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Point> = (0..n)
.map(|i| Point::new(i as i64 * DAY, start + step * i as f64))
.collect();
Expand All @@ -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<f64> = a.iter().enumerate().map(|(i, x)| x * 2.0 + (i as f64).cos()).collect();
if let Ok(r) = pearson(&a, &b) {
Expand Down
Loading
Loading