use rand::rngs::StdRng;
use rand::{Rng, SeedableRng};
use rand_distr::Normal;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone)]
pub struct StreamConfig {
pub base_interval_ms: u64,
pub noise_amplitude: f64,
pub drift_rate: f64,
pub anomaly_probability: f64,
pub anomaly_magnitude: f64,
pub num_biomarkers: usize,
pub window_size: usize,
}
impl Default for StreamConfig {
fn default() -> Self {
Self {
base_interval_ms: 1000,
noise_amplitude: 0.02,
drift_rate: 0.0,
anomaly_probability: 0.02,
anomaly_magnitude: 2.5,
num_biomarkers: 6,
window_size: 100,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BiomarkerReading {
pub timestamp_ms: u64,
pub biomarker_id: String,
pub value: f64,
pub reference_low: f64,
pub reference_high: f64,
pub is_anomaly: bool,
pub z_score: f64,
}
pub struct RingBuffer<T> {
buffer: Vec<T>,
head: usize,
len: usize,
capacity: usize,
}
impl<T: Clone + Default> RingBuffer<T> {
pub fn new(capacity: usize) -> Self {
assert!(capacity > 0, "RingBuffer capacity must be > 0");
Self { buffer: vec![T::default(); capacity], head: 0, len: 0, capacity }
}
pub fn push(&mut self, item: T) {
self.buffer[self.head] = item;
self.head = (self.head + 1) % self.capacity;
if self.len < self.capacity {
self.len += 1;
}
}
pub fn iter(&self) -> impl Iterator<Item = &T> {
let start = if self.len < self.capacity { 0 } else { self.head };
let (cap, len) = (self.capacity, self.len);
(0..len).map(move |i| &self.buffer[(start + i) % cap])
}
pub fn len(&self) -> usize { self.len }
pub fn is_full(&self) -> bool { self.len == self.capacity }
pub fn clear(&mut self) {
self.head = 0;
self.len = 0;
}
}
struct BiomarkerDef { id: &'static str, low: f64, high: f64 }
const BIOMARKER_DEFS: &[BiomarkerDef] = &[
BiomarkerDef { id: "glucose", low: 70.0, high: 100.0 },
BiomarkerDef { id: "cholesterol_total", low: 150.0, high: 200.0 },
BiomarkerDef { id: "hdl", low: 40.0, high: 60.0 },
BiomarkerDef { id: "ldl", low: 70.0, high: 130.0 },
BiomarkerDef { id: "triglycerides", low: 50.0, high: 150.0 },
BiomarkerDef { id: "crp", low: 0.1, high: 3.0 },
];
pub fn generate_readings(
config: &StreamConfig, count: usize, seed: u64,
) -> Vec<BiomarkerReading> {
let mut rng = StdRng::seed_from_u64(seed);
let active = &BIOMARKER_DEFS[..config.num_biomarkers.min(BIOMARKER_DEFS.len())];
let mut readings = Vec::with_capacity(count * active.len());
let dists: Vec<_> = active.iter().map(|def| {
let range = def.high - def.low;
let mid = (def.low + def.high) / 2.0;
let sigma = (config.noise_amplitude * range).max(1e-12);
let normal = Normal::new(0.0, sigma).unwrap();
let spike = Normal::new(0.0, sigma * config.anomaly_magnitude).unwrap();
(mid, range, normal, spike)
}).collect();
let mut ts: u64 = 0;
for step in 0..count {
for (j, def) in active.iter().enumerate() {
let (mid, range, ref normal, ref spike) = dists[j];
let drift = config.drift_rate * range * step as f64;
let is_anom = rng.gen::<f64>() < config.anomaly_probability;
let value = if is_anom {
(mid + rng.sample::<f64, _>(spike) + drift).max(0.0)
} else {
(mid + rng.sample::<f64, _>(normal) + drift).max(0.0)
};
readings.push(BiomarkerReading {
timestamp_ms: ts, biomarker_id: def.id.into(), value,
reference_low: def.low, reference_high: def.high,
is_anomaly: is_anom, z_score: 0.0,
});
}
ts += config.base_interval_ms;
}
readings
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StreamStats {
pub mean: f64,
pub variance: f64,
pub min: f64,
pub max: f64,
pub count: u64,
pub anomaly_rate: f64,
pub trend_slope: f64,
pub ema: f64,
pub cusum_pos: f64, pub cusum_neg: f64, pub changepoint_detected: bool,
}
impl Default for StreamStats {
fn default() -> Self {
Self {
mean: 0.0, variance: 0.0, min: f64::MAX, max: f64::MIN,
count: 0, anomaly_rate: 0.0, trend_slope: 0.0, ema: 0.0,
cusum_pos: 0.0, cusum_neg: 0.0, changepoint_detected: false,
}
}
}
pub struct ProcessingResult {
pub accepted: bool,
pub z_score: f64,
pub is_anomaly: bool,
pub current_trend: f64,
}
pub struct StreamSummary {
pub total_readings: u64,
pub anomaly_count: u64,
pub anomaly_rate: f64,
pub biomarker_stats: HashMap<String, StreamStats>,
pub throughput_readings_per_sec: f64,
}
const EMA_ALPHA: f64 = 0.1;
const Z_SCORE_THRESHOLD: f64 = 2.5;
const REF_OVERSHOOT: f64 = 0.20;
const CUSUM_THRESHOLD: f64 = 4.0; const CUSUM_DRIFT: f64 = 0.5;
pub struct StreamProcessor {
config: StreamConfig,
buffers: HashMap<String, RingBuffer<f64>>,
stats: HashMap<String, StreamStats>,
total_readings: u64,
anomaly_count: u64,
anom_per_bio: HashMap<String, u64>,
start_ts: Option<u64>,
last_ts: Option<u64>,
}
impl StreamProcessor {
pub fn new(config: StreamConfig) -> Self {
let cap = config.num_biomarkers;
Self {
config, buffers: HashMap::with_capacity(cap), stats: HashMap::with_capacity(cap),
total_readings: 0, anomaly_count: 0, anom_per_bio: HashMap::with_capacity(cap),
start_ts: None, last_ts: None,
}
}
pub fn process_reading(&mut self, reading: &BiomarkerReading) -> ProcessingResult {
let id = &reading.biomarker_id;
if self.start_ts.is_none() { self.start_ts = Some(reading.timestamp_ms); }
self.last_ts = Some(reading.timestamp_ms);
let buf = self.buffers
.entry(id.clone())
.or_insert_with(|| RingBuffer::new(self.config.window_size));
buf.push(reading.value);
self.total_readings += 1;
let (wmean, wstd) = window_mean_std(buf);
let z = if wstd > 1e-12 { (reading.value - wmean) / wstd } else { 0.0 };
let rng = reading.reference_high - reading.reference_low;
let overshoot = REF_OVERSHOOT * rng;
let oor = reading.value < (reading.reference_low - overshoot)
|| reading.value > (reading.reference_high + overshoot);
let is_anom = z.abs() > Z_SCORE_THRESHOLD || oor;
if is_anom {
self.anomaly_count += 1;
*self.anom_per_bio.entry(id.clone()).or_insert(0) += 1;
}
let slope = compute_trend_slope(buf);
let bio_anom = *self.anom_per_bio.get(id).unwrap_or(&0);
let st = self.stats.entry(id.clone()).or_default();
st.count += 1;
st.mean = wmean;
st.variance = wstd * wstd;
st.trend_slope = slope;
st.anomaly_rate = bio_anom as f64 / st.count as f64;
if reading.value < st.min { st.min = reading.value; }
if reading.value > st.max { st.max = reading.value; }
st.ema = if st.count == 1 {
reading.value
} else {
EMA_ALPHA * reading.value + (1.0 - EMA_ALPHA) * st.ema
};
if wstd > 1e-12 {
let norm_dev = (reading.value - wmean) / wstd;
st.cusum_pos = (st.cusum_pos + norm_dev - CUSUM_DRIFT).max(0.0);
st.cusum_neg = (st.cusum_neg - norm_dev - CUSUM_DRIFT).max(0.0);
st.changepoint_detected = st.cusum_pos > CUSUM_THRESHOLD || st.cusum_neg > CUSUM_THRESHOLD;
if st.changepoint_detected { st.cusum_pos = 0.0; st.cusum_neg = 0.0; }
}
ProcessingResult { accepted: true, z_score: z, is_anomaly: is_anom, current_trend: slope }
}
pub fn get_stats(&self, biomarker_id: &str) -> Option<&StreamStats> {
self.stats.get(biomarker_id)
}
pub fn summary(&self) -> StreamSummary {
let elapsed = match (self.start_ts, self.last_ts) { (Some(s), Some(e)) if e > s => (e - s) as f64, _ => 1.0 };
let ar = if self.total_readings > 0 { self.anomaly_count as f64 / self.total_readings as f64 } else { 0.0 };
StreamSummary {
total_readings: self.total_readings, anomaly_count: self.anomaly_count, anomaly_rate: ar,
biomarker_stats: self.stats.clone(),
throughput_readings_per_sec: self.total_readings as f64 / (elapsed / 1000.0),
}
}
}
fn window_mean_std(buf: &RingBuffer<f64>) -> (f64, f64) {
let n = buf.len();
if n == 0 { return (0.0, 0.0); }
let mut mean = 0.0;
let mut m2 = 0.0;
for (k, &x) in buf.iter().enumerate() {
let k1 = (k + 1) as f64;
let delta = x - mean;
mean += delta / k1;
m2 += delta * (x - mean);
}
if n < 2 { return (mean, 0.0); }
(mean, (m2 / (n - 1) as f64).sqrt())
}
fn compute_trend_slope(buf: &RingBuffer<f64>) -> f64 {
let n = buf.len();
if n < 2 { return 0.0; }
let nf = n as f64;
let xm = (nf - 1.0) / 2.0;
let (mut ys, mut xys, mut xxs) = (0.0, 0.0, 0.0);
for (i, &y) in buf.iter().enumerate() {
let x = i as f64;
ys += y; xys += x * y; xxs += x * x;
}
let ss_xy = xys - nf * xm * (ys / nf);
let ss_xx = xxs - nf * xm * xm;
if ss_xx.abs() < 1e-12 { 0.0 } else { ss_xy / ss_xx }
}
#[cfg(test)]
mod tests {
use super::*;
fn reading(ts: u64, id: &str, val: f64, lo: f64, hi: f64) -> BiomarkerReading {
BiomarkerReading {
timestamp_ms: ts, biomarker_id: id.into(), value: val,
reference_low: lo, reference_high: hi, is_anomaly: false, z_score: 0.0,
}
}
fn glucose(ts: u64, val: f64) -> BiomarkerReading { reading(ts, "glucose", val, 70.0, 100.0) }
#[test]
fn ring_buffer_push_iter_len() {
let mut rb: RingBuffer<i32> = RingBuffer::new(4);
for v in [10, 20, 30] { rb.push(v); }
assert_eq!(rb.iter().copied().collect::<Vec<_>>(), vec![10, 20, 30]);
assert_eq!(rb.len(), 3);
assert!(!rb.is_full());
}
#[test]
fn ring_buffer_overflow_keeps_newest() {
let mut rb: RingBuffer<i32> = RingBuffer::new(3);
for v in 1..=4 { rb.push(v); }
assert!(rb.is_full());
assert_eq!(rb.iter().copied().collect::<Vec<_>>(), vec![2, 3, 4]);
}
#[test]
fn ring_buffer_capacity_one() {
let mut rb: RingBuffer<i32> = RingBuffer::new(1);
rb.push(42); rb.push(99);
assert_eq!(rb.iter().copied().collect::<Vec<_>>(), vec![99]);
}
#[test]
fn ring_buffer_clear_resets() {
let mut rb: RingBuffer<i32> = RingBuffer::new(3);
rb.push(1); rb.push(2); rb.clear();
assert_eq!(rb.len(), 0);
assert!(!rb.is_full());
assert_eq!(rb.iter().count(), 0);
}
#[test]
fn generate_correct_count_and_ids() {
let cfg = StreamConfig::default();
let readings = generate_readings(&cfg, 50, 42);
assert_eq!(readings.len(), 50 * cfg.num_biomarkers);
let valid: Vec<&str> = BIOMARKER_DEFS.iter().map(|d| d.id).collect();
for r in &readings {
assert!(valid.contains(&r.biomarker_id.as_str()));
}
}
#[test]
fn generated_reference_ranges_match_defs() {
let readings = generate_readings(&StreamConfig::default(), 20, 123);
for r in &readings {
let d = BIOMARKER_DEFS.iter().find(|d| d.id == r.biomarker_id).unwrap();
assert!((r.reference_low - d.low).abs() < 1e-9);
assert!((r.reference_high - d.high).abs() < 1e-9);
}
}
#[test]
fn generated_values_non_negative() {
for r in &generate_readings(&StreamConfig::default(), 100, 999) {
assert!(r.value >= 0.0);
}
}
#[test]
fn processor_computes_stats() {
let cfg = StreamConfig { window_size: 10, ..Default::default() };
let mut p = StreamProcessor::new(cfg.clone());
for r in &generate_readings(&cfg, 20, 55) { p.process_reading(r); }
let s = p.get_stats("glucose").unwrap();
assert!(s.count > 0 && s.mean > 0.0 && s.min <= s.max);
}
#[test]
fn processor_summary_totals() {
let cfg = StreamConfig::default();
let mut p = StreamProcessor::new(cfg.clone());
for r in &generate_readings(&cfg, 30, 77) { p.process_reading(r); }
let s = p.summary();
assert_eq!(s.total_readings, 30 * cfg.num_biomarkers as u64);
assert!((0.0..=1.0).contains(&s.anomaly_rate));
}
#[test]
fn detects_z_score_anomaly() {
let mut p = StreamProcessor::new(StreamConfig { window_size: 20, ..Default::default() });
for i in 0..20 { p.process_reading(&glucose(i * 1000, 85.0)); }
let r = p.process_reading(&glucose(20_000, 300.0));
assert!(r.is_anomaly);
assert!(r.z_score.abs() > Z_SCORE_THRESHOLD);
}
#[test]
fn detects_out_of_range_anomaly() {
let mut p = StreamProcessor::new(StreamConfig { window_size: 5, ..Default::default() });
for (i, v) in [80.0, 82.0, 78.0, 84.0, 81.0].iter().enumerate() {
p.process_reading(&glucose(i as u64 * 1000, *v));
}
assert!(p.process_reading(&glucose(5000, 140.0)).is_anomaly);
}
#[test]
fn zero_anomaly_rate_for_constant_stream() {
let mut p = StreamProcessor::new(StreamConfig { window_size: 50, ..Default::default() });
for i in 0..10 { p.process_reading(&reading(i * 1000, "crp", 1.5, 0.1, 3.0)); }
assert!(p.get_stats("crp").unwrap().anomaly_rate.abs() < 1e-9);
}
#[test]
fn positive_trend_for_increasing() {
let mut p = StreamProcessor::new(StreamConfig { window_size: 20, ..Default::default() });
let mut r = ProcessingResult { accepted: true, z_score: 0.0, is_anomaly: false, current_trend: 0.0 };
for i in 0..20 { r = p.process_reading(&glucose(i * 1000, 70.0 + i as f64)); }
assert!(r.current_trend > 0.0, "got {}", r.current_trend);
}
#[test]
fn negative_trend_for_decreasing() {
let mut p = StreamProcessor::new(StreamConfig { window_size: 20, ..Default::default() });
let mut r = ProcessingResult { accepted: true, z_score: 0.0, is_anomaly: false, current_trend: 0.0 };
for i in 0..20 { r = p.process_reading(&reading(i * 1000, "hdl", 60.0 - i as f64 * 0.5, 40.0, 60.0)); }
assert!(r.current_trend < 0.0, "got {}", r.current_trend);
}
#[test]
fn exact_slope_for_linear_series() {
let mut p = StreamProcessor::new(StreamConfig { window_size: 10, ..Default::default() });
for i in 0..10 {
p.process_reading(&reading(i * 1000, "ldl", 100.0 + i as f64 * 3.0, 70.0, 130.0));
}
assert!((p.get_stats("ldl").unwrap().trend_slope - 3.0).abs() < 1e-9);
}
#[test]
fn z_score_small_for_near_mean() {
let mut p = StreamProcessor::new(StreamConfig { window_size: 10, ..Default::default() });
for (i, v) in [80.0, 82.0, 78.0, 84.0, 76.0, 86.0, 81.0, 79.0, 83.0].iter().enumerate() {
p.process_reading(&glucose(i as u64 * 1000, *v));
}
let mean = p.get_stats("glucose").unwrap().mean;
assert!(p.process_reading(&glucose(9000, mean)).z_score.abs() < 1.0);
}
#[test]
fn ema_converges_to_constant() {
let mut p = StreamProcessor::new(StreamConfig { window_size: 50, ..Default::default() });
for i in 0..50 { p.process_reading(&reading(i * 1000, "crp", 2.0, 0.1, 3.0)); }
assert!((p.get_stats("crp").unwrap().ema - 2.0).abs() < 1e-6);
}
}