Files
ruvnet--RuView/examples/dna/src/biomarker_stream.rs
T
ruv d803bfe2b1 Squashed 'vendor/ruvector/' content from commit b64c2172
git-subtree-dir: vendor/ruvector
git-subtree-split: b64c21726f2bb37286d9ee36a7869fef60cc6900
2026-02-28 14:39:40 -05:00

678 lines
20 KiB
Rust

//! Streaming biomarker data simulator with ring buffer and anomaly detection.
//!
//! Generates synthetic biomarker readings (glucose, cholesterol, HDL, LDL,
//! triglycerides, CRP) with configurable noise, drift, and anomaly injection.
//! Provides a [`StreamProcessor`] with rolling statistics, z-score anomaly
//! detection, and linear regression trend analysis over a [`RingBuffer`].
use rand::rngs::StdRng;
use rand::{Rng, SeedableRng};
use rand_distr::Normal;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
/// Configuration for simulated biomarker streams.
#[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,
}
}
}
/// A single timestamped biomarker data point.
#[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,
}
/// Fixed-capacity circular buffer backed by a flat `Vec<T>`.
///
/// Eliminates the `Option<T>` wrapper used in naive implementations,
/// halving per-slot memory for primitive types like `f64` (8 bytes vs 16).
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;
}
}
// ── Biomarker definitions ───────────────────────────────────────────────────
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,
},
];
// ── Batch generation ────────────────────────────────────────────────────────
/// Generate `count` synthetic readings per active biomarker with noise, drift,
/// and stochastic anomaly spikes.
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());
// Pre-compute distributions per biomarker (avoids Normal::new in inner loop)
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
}
// ── Statistics & results ────────────────────────────────────────────────────
/// Rolling statistics for a single biomarker stream.
#[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, // CUSUM positive direction
pub cusum_neg: f64, // CUSUM negative direction
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,
}
}
}
/// Result of processing a single reading.
pub struct ProcessingResult {
pub accepted: bool,
pub z_score: f64,
pub is_anomaly: bool,
pub current_trend: f64,
}
/// Aggregate summary across all biomarker streams.
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,
}
// ── Stream processor ────────────────────────────────────────────────────────
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; // Cumulative sum threshold for changepoint detection
const CUSUM_DRIFT: f64 = 0.5; // Allowable drift before CUSUM accumulates
/// Processes biomarker readings with per-stream ring buffers, z-score anomaly
/// detection, and trend analysis via simple linear regression.
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
};
// CUSUM changepoint detection: accumulate deviations from the mean
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),
}
}
}
// ── Helpers ─────────────────────────────────────────────────────────────────
/// Single-pass mean and sample standard deviation using Welford's online algorithm.
/// Avoids iterating the buffer twice (sum then variance) — 2x fewer cache misses.
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
}
}
// ── Tests ───────────────────────────────────────────────────────────────────
#[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)
}
// -- RingBuffer --
#[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);
}
// -- Batch generation --
#[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);
}
}
// -- StreamProcessor --
#[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));
}
// -- Anomaly detection --
#[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));
}
// 140 >> ref_high(100) + 20%*range(30)=106
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);
}
// -- Trend detection --
#[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);
}
// -- Z-score --
#[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);
}
// -- EMA --
#[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);
}
}