#![allow(dead_code)]
use std::collections::HashMap;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct ImpressionId(pub String);
impl std::fmt::Display for ImpressionId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Position(pub u32);
#[derive(Debug, Clone)]
pub struct Impression {
pub id: ImpressionId,
pub user_id: String,
pub content_id: String,
pub position: Position,
pub timestamp_ms: i64,
pub clicked: bool,
pub dwell_time_ms: u64,
}
#[derive(Debug, Clone)]
pub struct ContentMetrics {
pub content_id: String,
pub impressions: u64,
pub clicks: u64,
pub avg_position: f64,
pub avg_dwell_ms: f64,
}
impl ContentMetrics {
#[allow(clippy::cast_precision_loss)]
#[must_use]
pub fn ctr(&self) -> f64 {
if self.impressions == 0 {
return 0.0;
}
self.clicks as f64 / self.impressions as f64
}
}
#[derive(Debug, Clone)]
pub struct UserMetrics {
pub user_id: String,
pub impressions: u64,
pub clicks: u64,
pub unique_items: usize,
}
impl UserMetrics {
#[allow(clippy::cast_precision_loss)]
#[must_use]
pub fn ctr(&self) -> f64 {
if self.impressions == 0 {
return 0.0;
}
self.clicks as f64 / self.impressions as f64
}
}
#[derive(Debug, Clone, Default)]
pub struct ImpressionStats {
pub total_impressions: u64,
pub total_clicks: u64,
pub distinct_users: usize,
pub distinct_items: usize,
pub global_ctr: f64,
}
#[derive(Debug)]
pub struct ImpressionTracker {
impressions: HashMap<String, Impression>,
content_counters: HashMap<String, (u64, u64, u64, u64)>,
user_counters: HashMap<String, (u64, u64, HashMap<String, bool>)>,
next_id: u64,
}
impl ImpressionTracker {
#[must_use]
pub fn new() -> Self {
Self {
impressions: HashMap::new(),
content_counters: HashMap::new(),
user_counters: HashMap::new(),
next_id: 0,
}
}
pub fn record_impression(
&mut self,
user_id: &str,
content_id: &str,
position: u32,
timestamp_ms: i64,
) -> ImpressionId {
let id = ImpressionId(format!("imp_{}", self.next_id));
self.next_id += 1;
let impression = Impression {
id: id.clone(),
user_id: user_id.to_string(),
content_id: content_id.to_string(),
position: Position(position),
timestamp_ms,
clicked: false,
dwell_time_ms: 0,
};
let entry = self
.content_counters
.entry(content_id.to_string())
.or_insert((0, 0, 0, 0));
entry.0 += 1;
entry.2 += u64::from(position);
let user_entry = self
.user_counters
.entry(user_id.to_string())
.or_insert_with(|| (0, 0, HashMap::new()));
user_entry.0 += 1;
user_entry.2.insert(content_id.to_string(), true);
self.impressions.insert(id.0.clone(), impression);
id
}
pub fn record_click(&mut self, impression_id: &str, dwell_time_ms: u64) -> bool {
let Some(imp) = self.impressions.get_mut(impression_id) else {
return false;
};
if imp.clicked {
return false; }
imp.clicked = true;
imp.dwell_time_ms = dwell_time_ms;
if let Some(entry) = self.content_counters.get_mut(&imp.content_id) {
entry.1 += 1;
entry.3 += dwell_time_ms;
}
if let Some(user_entry) = self.user_counters.get_mut(&imp.user_id) {
user_entry.1 += 1;
}
true
}
#[allow(clippy::cast_precision_loss)]
#[must_use]
pub fn content_metrics(&self, content_id: &str) -> Option<ContentMetrics> {
let &(impressions, clicks, sum_pos, sum_dwell) = self.content_counters.get(content_id)?;
let avg_position = if impressions > 0 {
sum_pos as f64 / impressions as f64
} else {
0.0
};
let avg_dwell_ms = if clicks > 0 {
sum_dwell as f64 / clicks as f64
} else {
0.0
};
Some(ContentMetrics {
content_id: content_id.to_string(),
impressions,
clicks,
avg_position,
avg_dwell_ms,
})
}
#[must_use]
pub fn user_metrics(&self, user_id: &str) -> Option<UserMetrics> {
let (impressions, clicks, ref items) = *self.user_counters.get(user_id)?;
Some(UserMetrics {
user_id: user_id.to_string(),
impressions,
clicks,
unique_items: items.len(),
})
}
#[allow(clippy::cast_precision_loss)]
#[must_use]
pub fn global_stats(&self) -> ImpressionStats {
let total_impressions: u64 = self.content_counters.values().map(|c| c.0).sum();
let total_clicks: u64 = self.content_counters.values().map(|c| c.1).sum();
let global_ctr = if total_impressions > 0 {
total_clicks as f64 / total_impressions as f64
} else {
0.0
};
ImpressionStats {
total_impressions,
total_clicks,
distinct_users: self.user_counters.len(),
distinct_items: self.content_counters.len(),
global_ctr,
}
}
#[must_use]
pub fn total_impressions(&self) -> usize {
self.impressions.len()
}
#[allow(clippy::cast_precision_loss)]
#[must_use]
pub fn top_by_ctr(&self, min_impressions: u64, limit: usize) -> Vec<ContentMetrics> {
let mut items: Vec<ContentMetrics> = self
.content_counters
.iter()
.filter(|(_, &(imps, _, _, _))| imps >= min_impressions)
.map(|(cid, &(imps, clicks, sum_pos, sum_dwell))| {
let avg_position = if imps > 0 {
sum_pos as f64 / imps as f64
} else {
0.0
};
let avg_dwell_ms = if clicks > 0 {
sum_dwell as f64 / clicks as f64
} else {
0.0
};
ContentMetrics {
content_id: cid.clone(),
impressions: imps,
clicks,
avg_position,
avg_dwell_ms,
}
})
.collect();
items.sort_by(|a, b| {
b.ctr()
.partial_cmp(&a.ctr())
.unwrap_or(std::cmp::Ordering::Equal)
});
items.truncate(limit);
items
}
pub fn clear(&mut self) {
self.impressions.clear();
self.content_counters.clear();
self.user_counters.clear();
}
}
impl Default for ImpressionTracker {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_new_tracker_is_empty() {
let tracker = ImpressionTracker::new();
assert_eq!(tracker.total_impressions(), 0);
let stats = tracker.global_stats();
assert_eq!(stats.total_impressions, 0);
assert_eq!(stats.global_ctr, 0.0);
}
#[test]
fn test_record_impression() {
let mut tracker = ImpressionTracker::new();
let id = tracker.record_impression("user1", "video1", 0, 1000);
assert_eq!(id.to_string(), "imp_0");
assert_eq!(tracker.total_impressions(), 1);
}
#[test]
fn test_record_click_success() {
let mut tracker = ImpressionTracker::new();
let id = tracker.record_impression("user1", "video1", 0, 1000);
assert!(tracker.record_click(&id.0, 5000));
}
#[test]
fn test_record_click_nonexistent() {
let mut tracker = ImpressionTracker::new();
assert!(!tracker.record_click("nonexistent", 5000));
}
#[test]
fn test_double_click_rejected() {
let mut tracker = ImpressionTracker::new();
let id = tracker.record_impression("user1", "video1", 0, 1000);
assert!(tracker.record_click(&id.0, 5000));
assert!(!tracker.record_click(&id.0, 6000));
}
#[test]
fn test_content_metrics_ctr() {
let mut tracker = ImpressionTracker::new();
let id1 = tracker.record_impression("u1", "vid", 0, 100);
tracker.record_impression("u2", "vid", 1, 200);
tracker.record_click(&id1.0, 3000);
let metrics = tracker
.content_metrics("vid")
.expect("should succeed in test");
assert_eq!(metrics.impressions, 2);
assert_eq!(metrics.clicks, 1);
assert!((metrics.ctr() - 0.5).abs() < f64::EPSILON);
}
#[test]
fn test_content_metrics_avg_position() {
let mut tracker = ImpressionTracker::new();
tracker.record_impression("u1", "vid", 0, 100);
tracker.record_impression("u2", "vid", 4, 200);
let metrics = tracker
.content_metrics("vid")
.expect("should succeed in test");
assert!((metrics.avg_position - 2.0).abs() < f64::EPSILON);
}
#[test]
fn test_user_metrics() {
let mut tracker = ImpressionTracker::new();
let id1 = tracker.record_impression("alice", "v1", 0, 100);
tracker.record_impression("alice", "v2", 1, 200);
tracker.record_click(&id1.0, 2000);
let um = tracker
.user_metrics("alice")
.expect("should succeed in test");
assert_eq!(um.impressions, 2);
assert_eq!(um.clicks, 1);
assert_eq!(um.unique_items, 2);
assert!((um.ctr() - 0.5).abs() < f64::EPSILON);
}
#[test]
fn test_global_stats() {
let mut tracker = ImpressionTracker::new();
let id1 = tracker.record_impression("u1", "v1", 0, 100);
tracker.record_impression("u2", "v2", 1, 200);
tracker.record_impression("u1", "v2", 2, 300);
tracker.record_click(&id1.0, 1000);
let stats = tracker.global_stats();
assert_eq!(stats.total_impressions, 3);
assert_eq!(stats.total_clicks, 1);
assert_eq!(stats.distinct_users, 2);
assert_eq!(stats.distinct_items, 2);
}
#[test]
fn test_top_by_ctr() {
let mut tracker = ImpressionTracker::new();
let a1 = tracker.record_impression("u1", "video_a", 0, 100);
let a2 = tracker.record_impression("u2", "video_a", 0, 200);
tracker.record_click(&a1.0, 1000);
tracker.record_click(&a2.0, 2000);
let b1 = tracker.record_impression("u1", "video_b", 1, 300);
tracker.record_impression("u2", "video_b", 1, 400);
tracker.record_click(&b1.0, 500);
let top = tracker.top_by_ctr(2, 10);
assert_eq!(top.len(), 2);
assert_eq!(top[0].content_id, "video_a");
}
#[test]
fn test_clear() {
let mut tracker = ImpressionTracker::new();
tracker.record_impression("u1", "v1", 0, 100);
tracker.clear();
assert_eq!(tracker.total_impressions(), 0);
assert_eq!(tracker.global_stats().distinct_items, 0);
}
#[test]
fn test_content_metrics_none_for_unknown() {
let tracker = ImpressionTracker::new();
assert!(tracker.content_metrics("nonexistent").is_none());
}
#[test]
fn test_impression_id_display() {
let id = ImpressionId("imp_42".to_string());
assert_eq!(id.to_string(), "imp_42");
}
}