//! Report builder: aggregates per-group metrics from signal data. //! //! Scans `UserSignalIndex` for each user in the experiment to compute //! CTR, completion rate, and return rate per group. The builder is //! read-only -- it does not write to any storage. use std::sync::Arc; use crate::entities::UserSignalIndex; use crate::signals::SignalLedger; use super::{ ExperimentConfig, ExperimentGroup, ExperimentReport, GroupMetrics, MetricLift, assign_group, }; // ── ReportBuilder ──────────────────────────────────────────────────────────── /// Builds an `ExperimentReport` by scanning signal data for a set of users. pub struct ReportBuilder<'a> { ledger: &'a Arc, user_signal_index: &'a UserSignalIndex, } impl<'a> ReportBuilder<'a> { /// Create a new report builder with references to signal infrastructure. #[must_use] pub const fn new( ledger: &'a Arc, user_signal_index: &'a UserSignalIndex, ) -> Self { Self { ledger, user_signal_index, } } /// Build the experiment report for the given user population. /// /// Each user in `user_ids` is assigned to treatment or control via /// `assign_group`. Metrics are aggregated per group from signal data. /// /// # Errors /// /// Returns `TidalError::InvalidInput` if the config fails validation. pub fn build( &self, config: &ExperimentConfig, user_ids: &[u64], ) -> crate::Result { config.validate()?; // Partition users into groups. let mut treatment_users: Vec = Vec::new(); let mut control_users: Vec = Vec::new(); for &uid in user_ids { match assign_group(uid, &config.experiment_id, config.treatment_fraction) { ExperimentGroup::Treatment => treatment_users.push(uid), ExperimentGroup::Control => control_users.push(uid), } } // Aggregate metrics per group. let treatment_metrics = self.aggregate_group_metrics(&treatment_users, config); let control_metrics = self.aggregate_group_metrics(&control_users, config); let lift = MetricLift::compute(&treatment_metrics, &control_metrics); Ok(ExperimentReport { experiment_id: config.experiment_id.clone(), treatment_users: treatment_users.len(), control_users: control_users.len(), treatment_metrics, control_metrics, lift, }) } /// Aggregate engagement metrics for a group of users. /// /// Uses `UserSignalIndex::user_signal_count` to sum signal counts per user /// across all entities, and `user_activity_split` for return rate. fn aggregate_group_metrics(&self, user_ids: &[u64], config: &ExperimentConfig) -> GroupMetrics { let mut total_views: u64 = 0; let mut total_clicks: u64 = 0; let mut total_completions: u64 = 0; let mut returned_users: u64 = 0; let mut eligible_for_return: u64 = 0; // Resolve signal type IDs once. let view_type_id = self.ledger.resolve_signal_type("view").ok(); let click_type_ids: Vec<_> = config .click_signals .iter() .filter_map(|s| self.ledger.resolve_signal_type(s).ok()) .collect(); let completion_type_ids: Vec<_> = config .completion_signals .iter() .filter_map(|s| self.ledger.resolve_signal_type(s).ok()) .collect(); let now_ns = crate::schema::Timestamp::now().as_nanos(); for &user_id in user_ids { // Sum views for this user across all entities. if let Some(view_tid) = view_type_id { total_views += self .user_signal_index .user_signal_count(user_id, view_tid, now_ns); } // Sum clicks for this user. for &click_tid in &click_type_ids { total_clicks += self .user_signal_index .user_signal_count(user_id, click_tid, now_ns); } // Sum completions for this user. for &comp_tid in &completion_type_ids { total_completions += self .user_signal_index .user_signal_count(user_id, comp_tid, now_ns); } // Return rate: user has old activity AND recent activity. let (has_old, has_recent) = self.user_signal_index.user_activity_split(user_id, now_ns); if has_old { eligible_for_return += 1; if has_recent { returned_users += 1; } } } #[allow(clippy::cast_precision_loss)] let ctr = if total_views > 0 { total_clicks as f64 / total_views as f64 } else { 0.0 }; #[allow(clippy::cast_precision_loss)] let completion_rate = if total_views > 0 { total_completions as f64 / total_views as f64 } else { 0.0 }; #[allow(clippy::cast_precision_loss)] let return_rate = if eligible_for_return > 0 { returned_users as f64 / eligible_for_return as f64 } else { 0.0 }; GroupMetrics { ctr, completion_rate, return_rate, total_views, total_clicks, total_completions, } } }