use std::collections::HashMap;
const NEUTRAL_PRIOR: f64 = 0.5;
#[derive(Debug, Clone)]
struct EmaEntry {
score: f64,
observations: u64,
}
#[derive(Debug, Clone)]
pub struct EnsembleTracker {
scores: HashMap<String, EmaEntry>,
alpha: f64,
decay: f64,
min_observations: u32,
}
impl EnsembleTracker {
#[must_use]
pub fn new(alpha: f64, decay: f64, min_observations: u32) -> Self {
Self {
scores: HashMap::new(),
alpha,
decay,
min_observations,
}
}
pub fn record(&mut self, member: &str, agreed: bool) {
let value = if agreed { 1.0 } else { 0.0 };
let entry = self.scores.entry(member.to_owned()).or_insert(EmaEntry {
score: NEUTRAL_PRIOR,
observations: 0,
});
let decayed = self.decay * entry.score + (1.0 - self.decay) * NEUTRAL_PRIOR;
entry.score = self.alpha * value + (1.0 - self.alpha) * decayed;
entry.observations += 1;
}
#[must_use]
pub fn ema(&self, member: &str) -> Option<f64> {
self.scores.get(member).and_then(|e| {
if e.observations >= u64::from(self.min_observations) {
Some(e.score)
} else {
None
}
})
}
#[must_use]
pub fn snapshot(&self) -> Vec<(String, f64, u64)> {
let mut rows: Vec<(String, f64, u64)> = self
.scores
.iter()
.map(|(member, entry)| (member.clone(), entry.score, entry.observations))
.collect();
rows.sort_unstable_by(|a, b| a.0.cmp(&b.0));
rows
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cold_start_gate_returns_none_below_min_observations() {
let mut tracker = EnsembleTracker::new(0.3, 0.95, 5);
for _ in 0..4 {
tracker.record("a", true);
}
assert_eq!(tracker.ema("a"), None);
}
#[test]
fn cold_start_gate_returns_some_at_min_observations() {
let mut tracker = EnsembleTracker::new(0.3, 0.95, 5);
for _ in 0..5 {
tracker.record("a", true);
}
assert!(tracker.ema("a").is_some());
}
#[test]
fn unobserved_member_returns_none() {
let tracker = EnsembleTracker::new(0.3, 0.95, 1);
assert_eq!(tracker.ema("never-seen"), None);
}
#[test]
fn ema_trends_toward_agreement() {
let mut tracker = EnsembleTracker::new(0.5, 1.0, 1);
for _ in 0..20 {
tracker.record("a", true);
}
let score = tracker.ema("a").unwrap();
assert!(score > 0.99, "score should converge near 1.0, got {score}");
}
#[test]
fn ema_trends_toward_disagreement() {
let mut tracker = EnsembleTracker::new(0.5, 1.0, 1);
for _ in 0..20 {
tracker.record("a", false);
}
let score = tracker.ema("a").unwrap();
assert!(score < 0.01, "score should converge near 0.0, got {score}");
}
#[test]
fn decay_pulls_stale_score_toward_neutral_prior() {
let mut tracker = EnsembleTracker::new(0.5, 0.9, 1);
tracker.record("a", true); let after_first = tracker.ema("a").unwrap();
assert!((after_first - 0.75).abs() < 1e-9);
for _ in 0..10 {
tracker.record("a", false);
}
let after_many_false = tracker.ema("a").unwrap();
assert!(after_many_false < after_first);
}
#[test]
fn observations_increment_monotonically() {
let mut tracker = EnsembleTracker::new(0.3, 0.95, 1);
tracker.record("a", true);
tracker.record("a", false);
tracker.record("a", true);
let snapshot = tracker.snapshot();
let (_, _, obs) = snapshot.iter().find(|(m, _, _)| m == "a").unwrap();
assert_eq!(*obs, 3);
}
#[test]
fn snapshot_includes_all_members() {
let mut tracker = EnsembleTracker::new(0.3, 0.95, 1);
tracker.record("a", true);
tracker.record("b", false);
let snapshot = tracker.snapshot();
assert_eq!(snapshot.len(), 2);
}
#[test]
fn snapshot_is_sorted_by_member_name() {
let mut tracker = EnsembleTracker::new(0.3, 0.95, 1);
tracker.record("zeta", true);
tracker.record("mid", false);
tracker.record("alpha", true);
let snapshot = tracker.snapshot();
let names: Vec<&str> = snapshot.iter().map(|(m, _, _)| m.as_str()).collect();
assert_eq!(names, vec!["alpha", "mid", "zeta"]);
}
#[test]
fn no_select_subset_method_exists() {
let tracker = EnsembleTracker::new(0.3, 0.95, 5);
assert!(tracker.snapshot().is_empty());
}
}