use crate::error::AnalyticsError;
use std::collections::HashMap;
#[derive(Debug, Clone, PartialEq)]
pub struct CtrStats {
pub item_id: String,
pub impressions: u64,
pub clicks: u64,
}
impl CtrStats {
#[must_use]
pub fn ctr(&self) -> f64 {
if self.impressions == 0 {
0.0
} else {
self.clicks as f64 / self.impressions as f64
}
}
#[must_use]
pub fn wilson_interval(&self, z: f64) -> (f64, f64) {
let n = self.impressions as f64;
if n == 0.0 {
return (0.0, 0.0);
}
let p = self.clicks as f64 / n;
let z2 = z * z;
let denom = 1.0 + z2 / n;
let centre = (p + z2 / (2.0 * n)) / denom;
let margin = (z / denom) * ((p * (1.0 - p) / n) + z2 / (4.0 * n * n)).sqrt();
((centre - margin).max(0.0), (centre + margin).min(1.0))
}
#[must_use]
pub fn is_untracked(&self) -> bool {
self.impressions == 0
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct CtrVariant {
pub item_id: String,
pub ctr: f64,
pub ci_lower: f64,
pub ci_upper: f64,
pub impressions: u64,
pub clicks: u64,
}
#[derive(Debug, Default, Clone)]
pub struct CtrTracker {
data: HashMap<String, CtrStats>,
}
impl CtrTracker {
#[must_use]
pub fn new() -> Self {
Self {
data: HashMap::new(),
}
}
pub fn record_impression(&mut self, item_id: &str) {
let entry = self
.data
.entry(item_id.to_owned())
.or_insert_with(|| CtrStats {
item_id: item_id.to_owned(),
impressions: 0,
clicks: 0,
});
entry.impressions = entry.impressions.saturating_add(1);
}
pub fn record_impressions(&mut self, item_id: &str, count: u64) {
let entry = self
.data
.entry(item_id.to_owned())
.or_insert_with(|| CtrStats {
item_id: item_id.to_owned(),
impressions: 0,
clicks: 0,
});
entry.impressions = entry.impressions.saturating_add(count);
}
pub fn record_click(&mut self, item_id: &str) {
let entry = self
.data
.entry(item_id.to_owned())
.or_insert_with(|| CtrStats {
item_id: item_id.to_owned(),
impressions: 0,
clicks: 0,
});
entry.clicks = entry.clicks.saturating_add(1);
}
pub fn record_clicks(&mut self, item_id: &str, count: u64) {
let entry = self
.data
.entry(item_id.to_owned())
.or_insert_with(|| CtrStats {
item_id: item_id.to_owned(),
impressions: 0,
clicks: 0,
});
entry.clicks = entry.clicks.saturating_add(count);
}
#[must_use]
pub fn stats(&self, item_id: &str) -> Option<&CtrStats> {
self.data.get(item_id)
}
pub fn ctr(&self, item_id: &str) -> Result<f64, AnalyticsError> {
self.data
.get(item_id)
.map(CtrStats::ctr)
.ok_or_else(|| AnalyticsError::InvalidInput(format!("item '{item_id}' not tracked")))
}
#[must_use]
pub fn ranked(&self) -> Vec<CtrVariant> {
let z = 1.960_f64; let mut variants: Vec<CtrVariant> = self
.data
.values()
.map(|s| {
let (lo, hi) = s.wilson_interval(z);
CtrVariant {
item_id: s.item_id.clone(),
ctr: s.ctr(),
ci_lower: lo,
ci_upper: hi,
impressions: s.impressions,
clicks: s.clicks,
}
})
.collect();
variants.sort_by(|a, b| {
b.ctr
.partial_cmp(&a.ctr)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| b.impressions.cmp(&a.impressions))
});
variants
}
pub fn winner(&self, min_impressions: u64) -> Result<CtrVariant, AnalyticsError> {
let z = 1.960_f64;
self.data
.values()
.filter(|s| s.impressions >= min_impressions)
.max_by(|a, b| {
a.ctr()
.partial_cmp(&b.ctr())
.unwrap_or(std::cmp::Ordering::Equal)
})
.map(|s| {
let (lo, hi) = s.wilson_interval(z);
CtrVariant {
item_id: s.item_id.clone(),
ctr: s.ctr(),
ci_lower: lo,
ci_upper: hi,
impressions: s.impressions,
clicks: s.clicks,
}
})
.ok_or_else(|| {
AnalyticsError::InsufficientData(format!(
"no item has ≥ {min_impressions} impressions"
))
})
}
#[must_use]
pub fn item_count(&self) -> usize {
self.data.len()
}
pub fn reset(&mut self) {
self.data.clear();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ctr_zero_when_no_impressions() {
let s = CtrStats {
item_id: "x".into(),
impressions: 0,
clicks: 0,
};
assert_eq!(s.ctr(), 0.0);
assert!(s.is_untracked());
}
#[test]
fn ctr_computed_correctly() {
let s = CtrStats {
item_id: "a".into(),
impressions: 4,
clicks: 1,
};
assert!((s.ctr() - 0.25).abs() < 1e-9);
}
#[test]
fn wilson_interval_symmetry_at_50_percent() {
let s = CtrStats {
item_id: "sym".into(),
impressions: 100,
clicks: 50,
};
let (lo, hi) = s.wilson_interval(1.960);
let dist_lo = 0.5 - lo;
let dist_hi = hi - 0.5;
assert!((dist_lo - dist_hi).abs() < 0.01, "lo={lo:.4}, hi={hi:.4}");
}
#[test]
fn wilson_interval_empty_item() {
let s = CtrStats {
item_id: "empty".into(),
impressions: 0,
clicks: 0,
};
let (lo, hi) = s.wilson_interval(1.960);
assert_eq!((lo, hi), (0.0, 0.0));
}
#[test]
fn wilson_interval_bounds_in_range() {
let s = CtrStats {
item_id: "b".into(),
impressions: 200,
clicks: 30,
};
let (lo, hi) = s.wilson_interval(1.960);
assert!(lo >= 0.0 && lo <= 1.0, "lower={lo}");
assert!(hi >= 0.0 && hi <= 1.0, "upper={hi}");
assert!(lo < hi, "lower must be < upper");
}
#[test]
fn record_impression_and_click() {
let mut t = CtrTracker::new();
t.record_impression("thumb_a");
t.record_impression("thumb_a");
t.record_click("thumb_a");
let s = t.stats("thumb_a").expect("exists");
assert_eq!(s.impressions, 2);
assert_eq!(s.clicks, 1);
assert!((s.ctr() - 0.5).abs() < 1e-9);
}
#[test]
fn stats_unknown_item_returns_none() {
let t = CtrTracker::new();
assert!(t.stats("unknown").is_none());
}
#[test]
fn ctr_unknown_item_errors() {
let t = CtrTracker::new();
assert!(t.ctr("ghost").is_err());
}
#[test]
fn ranked_orders_by_ctr_descending() {
let mut t = CtrTracker::new();
t.record_impressions("low", 100);
t.record_clicks("low", 5);
t.record_impressions("high", 100);
t.record_clicks("high", 20);
let ranked = t.ranked();
assert_eq!(ranked[0].item_id, "high");
assert_eq!(ranked[1].item_id, "low");
}
#[test]
fn winner_selects_best_item() {
let mut t = CtrTracker::new();
t.record_impressions("a", 1000);
t.record_clicks("a", 50);
t.record_impressions("b", 1000);
t.record_clicks("b", 200);
let w = t.winner(100).expect("winner found");
assert_eq!(w.item_id, "b");
assert!((w.ctr - 0.2).abs() < 1e-9);
}
#[test]
fn winner_errors_when_min_impressions_not_met() {
let mut t = CtrTracker::new();
t.record_impressions("tiny", 5);
t.record_clicks("tiny", 1);
assert!(t.winner(100).is_err());
}
#[test]
fn item_count_and_reset() {
let mut t = CtrTracker::new();
t.record_impression("x");
t.record_impression("y");
assert_eq!(t.item_count(), 2);
t.reset();
assert_eq!(t.item_count(), 0);
}
#[test]
fn bulk_impression_and_click_recording() {
let mut t = CtrTracker::new();
t.record_impressions("bulk", 500);
t.record_clicks("bulk", 100);
let s = t.stats("bulk").expect("exists");
assert_eq!(s.impressions, 500);
assert_eq!(s.clicks, 100);
assert!((s.ctr() - 0.2).abs() < 1e-9);
}
#[test]
fn click_without_impression_allowed() {
let mut t = CtrTracker::new();
t.record_click("deep_link"); let s = t.stats("deep_link").expect("exists");
assert_eq!(s.impressions, 0);
assert_eq!(s.clicks, 1);
assert_eq!(s.ctr(), 0.0); }
#[test]
fn ranked_ci_bounds_populated() {
let mut t = CtrTracker::new();
t.record_impressions("item", 200);
t.record_clicks("item", 40);
let ranked = t.ranked();
assert_eq!(ranked.len(), 1);
let v = &ranked[0];
assert!(v.ci_lower < v.ctr);
assert!(v.ci_upper > v.ctr);
}
}