use serde::Serialize;
use crate::stats::mean;
pub fn edge_half_life(ic_series: &[f64]) -> Option<f64> {
let pts: Vec<(f64, f64)> = ic_series
.iter()
.enumerate()
.filter_map(|(t, &ic)| {
let a = ic.abs();
if a > 1e-9 {
Some((t as f64, a.ln()))
} else {
None
}
})
.collect();
if pts.len() < 3 {
return None;
}
let xs: Vec<f64> = pts.iter().map(|p| p.0).collect();
let ys: Vec<f64> = pts.iter().map(|p| p.1).collect();
let mx = mean(&xs);
let my = mean(&ys);
let mut num = 0.0;
let mut den = 0.0;
for (&x, &y) in xs.iter().zip(ys.iter()) {
num += (x - mx) * (y - my);
den += (x - mx) * (x - mx);
}
if den == 0.0 {
return None;
}
let slope = num / den;
if slope >= 0.0 {
return None; }
Some(std::f64::consts::LN_2 / -slope)
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
pub struct CrowdingParams {
pub theta: f64,
pub delta_max: f64,
pub curvature: f64,
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
pub struct CrowdingDecayPrior {
pub adoption: f64,
pub natural_reversion: f64,
pub crowding_decay: f64,
pub expected_half_life: Option<f64>,
}
pub fn crowding_half_life(adoption: f64, params: CrowdingParams) -> CrowdingDecayPrior {
let phi = adoption.clamp(0.0, 1.0);
let curvature = if params.curvature > 0.0 {
params.curvature
} else {
1.0
};
let delta = params.delta_max.max(0.0) * phi.powf(curvature);
let hazard = params.theta + delta;
CrowdingDecayPrior {
adoption: phi,
natural_reversion: params.theta,
crowding_decay: delta,
expected_half_life: if hazard > 0.0 {
Some(std::f64::consts::LN_2 / hazard)
} else {
None
},
}
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
pub struct DecayComparison {
pub measured_half_life: Option<f64>,
pub prior: CrowdingDecayPrior,
pub ratio: Option<f64>,
pub anomalous: bool,
}
pub fn compare_decay_to_prior(
ic_series: &[f64],
adoption: f64,
params: CrowdingParams,
anomaly_ratio: f64,
) -> DecayComparison {
let measured_half_life = edge_half_life(ic_series);
let prior = crowding_half_life(adoption, params);
let ratio = match (measured_half_life, prior.expected_half_life) {
(Some(m), Some(e)) if e > 0.0 => Some(m / e),
_ => None,
};
DecayComparison {
measured_half_life,
prior,
ratio,
anomalous: ratio.is_some_and(|r| r < anomaly_ratio),
}
}
#[cfg(test)]
mod tests {
use super::*;
const TEST_PARAMS: CrowdingParams = CrowdingParams {
theta: 0.05,
delta_max: 0.05,
curvature: 1.0,
};
#[test]
fn detects_exponential_decay() {
let ic: Vec<f64> = (0..40).map(|t| 0.2 * (-0.1 * t as f64).exp()).collect();
let hl = edge_half_life(&ic).expect("should decay");
assert!((hl - 6.93).abs() < 0.5, "half-life={hl}");
}
#[test]
fn flat_edge_has_no_decay() {
let ic = vec![0.1; 30];
assert!(edge_half_life(&ic).is_none());
}
#[test]
fn crowding_shortens_the_expected_half_life() {
let lonely = crowding_half_life(0.0, TEST_PARAMS);
let crowded = crowding_half_life(1.0, TEST_PARAMS);
assert!(
lonely.crowding_decay.abs() < 1e-12,
"nobody has arrived yet"
);
assert!((crowded.crowding_decay - 0.05).abs() < 1e-12);
assert!((lonely.expected_half_life.unwrap() - 13.8629).abs() < 1e-3);
assert!((crowded.expected_half_life.unwrap() - 6.9315).abs() < 1e-3);
}
#[test]
fn expected_half_life_is_convex_decreasing_in_adoption() {
let h0 = crowding_half_life(0.0, TEST_PARAMS)
.expected_half_life
.unwrap();
let h_mid = crowding_half_life(0.5, TEST_PARAMS)
.expected_half_life
.unwrap();
let h1 = crowding_half_life(1.0, TEST_PARAMS)
.expected_half_life
.unwrap();
assert!(h0 > h_mid && h_mid > h1, "decreasing: {h0} {h_mid} {h1}");
assert!(
h_mid < 0.5 * (h0 + h1),
"convex: midpoint {h_mid} should sit under the chord {}",
0.5 * (h0 + h1)
);
}
#[test]
fn adoption_is_clamped_rather_than_rejected() {
assert_eq!(
crowding_half_life(-3.0, TEST_PARAMS),
crowding_half_life(0.0, TEST_PARAMS)
);
assert_eq!(
crowding_half_life(4.0, TEST_PARAMS),
crowding_half_life(1.0, TEST_PARAMS)
);
}
#[test]
fn a_model_with_no_decay_has_no_half_life() {
let params = CrowdingParams {
theta: 0.0,
delta_max: 0.0,
curvature: 1.0,
};
assert!(crowding_half_life(1.0, params).expected_half_life.is_none());
}
#[test]
fn measured_matching_the_prior_is_not_anomalous() {
let ic: Vec<f64> = (0..40).map(|t| 0.2 * (-0.1 * t as f64).exp()).collect();
let c = compare_decay_to_prior(&ic, 1.0, TEST_PARAMS, 0.5);
let ratio = c.ratio.expect("both sides present");
assert!((ratio - 1.0).abs() < 0.1, "ratio={ratio}");
assert!(!c.anomalous);
}
#[test]
fn decay_faster_than_crowding_explains_is_flagged() {
let ic: Vec<f64> = (0..40).map(|t| 0.2 * (-0.1 * t as f64).exp()).collect();
let c = compare_decay_to_prior(&ic, 0.0, TEST_PARAMS, 0.6);
let ratio = c.ratio.expect("both sides present");
assert!((ratio - 0.5).abs() < 0.05, "ratio={ratio}");
assert!(c.anomalous, "twice the modelled decay rate should flag");
assert!(!compare_decay_to_prior(&ic, 0.0, TEST_PARAMS, 0.4).anomalous);
}
#[test]
fn a_non_decaying_edge_yields_no_ratio_and_no_flag() {
let ic = vec![0.1; 30];
let c = compare_decay_to_prior(&ic, 0.5, TEST_PARAMS, 0.5);
assert!(c.measured_half_life.is_none());
assert!(c.ratio.is_none());
assert!(!c.anomalous, "absence of measurement is not an anomaly");
assert!(
c.prior.expected_half_life.is_some(),
"the prior still stands"
);
}
}