Skip to main content

oximedia_analytics/
funnel.rs

1//! Funnel analysis, churn prediction, and viewer loyalty scoring.
2//!
3//! ## Funnel analysis
4//!
5//! Tracks viewer progression through ordered content milestones (e.g. intro →
6//! first chapter → halfway → completion).  For each step the funnel measures
7//! how many viewers reached it, the conversion rate from the previous step, and
8//! the drop-off.
9//!
10//! ## Churn prediction
11//!
12//! Analyses a time-series of engagement scores for a viewer and predicts
13//! whether they are at risk of churning based on a sustained decline pattern
14//! detected via linear-regression slope thresholding.
15//!
16//! ## Viewer loyalty scoring
17//!
18//! Combines recency, frequency, and duration into a single composite score
19//! in [0.0, 1.0] using configurable weights.
20
21use crate::engagement::linear_regression_slope;
22use crate::error::AnalyticsError;
23use crate::session::{build_playback_map, ViewerSession};
24
25// ─── Funnel analysis ──────────────────────────────────────────────────────────
26
27/// A single milestone in a viewer progression funnel.
28///
29/// A milestone is reached when the viewer's playback map contains a `true`
30/// entry at the given `position_ms`.
31#[derive(Debug, Clone)]
32pub struct FunnelMilestone {
33    pub name: String,
34    /// The content position (ms) that must be reached to pass this milestone.
35    pub position_ms: u64,
36}
37
38/// One step in the computed funnel.
39#[derive(Debug, Clone)]
40pub struct FunnelStep {
41    pub milestone_name: String,
42    pub position_ms: u64,
43    /// Number of viewers who reached this milestone.
44    pub viewers_reached: u32,
45    /// Conversion rate from the *previous* step (1.0 for the first step).
46    pub conversion_from_prev: f32,
47    /// Fraction of all session starters who reached this step.
48    pub overall_rate: f32,
49}
50
51/// The result of a funnel analysis.
52#[derive(Debug, Clone)]
53pub struct FunnelResult {
54    pub steps: Vec<FunnelStep>,
55    pub total_starters: u32,
56}
57
58impl FunnelResult {
59    /// Overall funnel completion rate: fraction reaching the last milestone.
60    pub fn completion_rate(&self) -> f32 {
61        self.steps.last().map(|s| s.overall_rate).unwrap_or(0.0)
62    }
63
64    /// Index of the step with the largest absolute drop-off (viewers lost).
65    pub fn biggest_drop_step(&self) -> Option<usize> {
66        if self.steps.len() < 2 {
67            return None;
68        }
69        let mut max_drop = 0u32;
70        let mut max_idx = 1usize;
71        for i in 1..self.steps.len() {
72            let drop = self.steps[i - 1]
73                .viewers_reached
74                .saturating_sub(self.steps[i].viewers_reached);
75            if drop > max_drop {
76                max_drop = drop;
77                max_idx = i;
78            }
79        }
80        Some(max_idx)
81    }
82}
83
84/// Compute a viewer funnel from a slice of sessions.
85///
86/// `milestones` must be provided in ascending `position_ms` order.  Each
87/// milestone is independent — a viewer can reach a later milestone without
88/// having reached an earlier one (this models skip behaviour).
89///
90/// Returns an error if `milestones` is empty or `sessions` is empty.
91pub fn compute_funnel(
92    sessions: &[ViewerSession],
93    milestones: &[FunnelMilestone],
94    content_duration_ms: u64,
95) -> Result<FunnelResult, AnalyticsError> {
96    if sessions.is_empty() {
97        return Err(AnalyticsError::InsufficientData(
98            "funnel requires at least one session".to_string(),
99        ));
100    }
101    if milestones.is_empty() {
102        return Err(AnalyticsError::ConfigError(
103            "funnel requires at least one milestone".to_string(),
104        ));
105    }
106
107    // Pre-build playback maps.
108    let maps: Vec<_> = sessions
109        .iter()
110        .map(|s| build_playback_map(s, content_duration_ms))
111        .collect();
112
113    let total_starters = sessions.len() as u32;
114
115    let mut steps = Vec::with_capacity(milestones.len());
116    let mut prev_viewers = total_starters;
117
118    for milestone in milestones {
119        let pos_sec = (milestone.position_ms / 1000) as usize;
120        let viewers_reached = maps
121            .iter()
122            .filter(|m| m.positions_watched.get(pos_sec).copied().unwrap_or(false))
123            .count() as u32;
124
125        let conversion_from_prev = if prev_viewers == 0 {
126            0.0
127        } else {
128            viewers_reached as f32 / prev_viewers as f32
129        };
130        let overall_rate = viewers_reached as f32 / total_starters as f32;
131
132        steps.push(FunnelStep {
133            milestone_name: milestone.name.clone(),
134            position_ms: milestone.position_ms,
135            viewers_reached,
136            conversion_from_prev,
137            overall_rate,
138        });
139        prev_viewers = viewers_reached;
140    }
141
142    Ok(FunnelResult {
143        steps,
144        total_starters,
145    })
146}
147
148// ─── Churn prediction ─────────────────────────────────────────────────────────
149
150/// Configuration for the churn prediction model.
151#[derive(Debug, Clone)]
152pub struct ChurnConfig {
153    /// Minimum number of engagement data points required.
154    pub min_data_points: usize,
155    /// Slope threshold below which a viewer is classified as churning.
156    /// Expressed in engagement-score-units per millisecond.
157    pub decline_slope_threshold: f32,
158    /// Minimum absolute engagement score below which a viewer is always at risk.
159    pub low_engagement_threshold: f32,
160}
161
162impl Default for ChurnConfig {
163    fn default() -> Self {
164        Self {
165            min_data_points: 3,
166            decline_slope_threshold: -1e-9, // negative slope in score/ms
167            low_engagement_threshold: 0.2,
168        }
169    }
170}
171
172/// Churn risk classification.
173#[derive(Debug, Clone, Copy, PartialEq, Eq)]
174pub enum ChurnRisk {
175    /// Viewer shows stable or growing engagement.
176    Low,
177    /// Viewer shows modest decline; monitor closely.
178    Medium,
179    /// Viewer shows strong decline or very low engagement; likely to churn.
180    High,
181}
182
183/// Result of a churn risk assessment for a single viewer.
184#[derive(Debug, Clone)]
185pub struct ChurnAssessment {
186    pub viewer_id: String,
187    pub risk: ChurnRisk,
188    /// Computed slope of the engagement score time-series (score/ms).
189    pub engagement_slope: f32,
190    /// Most recent engagement score.
191    pub latest_score: f32,
192}
193
194/// Predict churn risk for a viewer from their engagement score time-series.
195///
196/// `scores_over_time` is a series of `(unix_epoch_ms, engagement_score)` pairs.
197/// Scores should be in [0.0, 1.0].
198///
199/// Returns an error if there are fewer than `config.min_data_points` data points.
200pub fn predict_churn(
201    viewer_id: &str,
202    scores_over_time: &[(i64, f32)],
203    config: &ChurnConfig,
204) -> Result<ChurnAssessment, AnalyticsError> {
205    if scores_over_time.len() < config.min_data_points {
206        return Err(AnalyticsError::InsufficientData(format!(
207            "churn prediction requires at least {} data points, got {}",
208            config.min_data_points,
209            scores_over_time.len()
210        )));
211    }
212
213    let slope = linear_regression_slope(scores_over_time);
214    let latest_score = scores_over_time
215        .iter()
216        .max_by_key(|(t, _)| *t)
217        .map(|(_, s)| *s)
218        .unwrap_or(0.0);
219
220    let risk = if latest_score < config.low_engagement_threshold {
221        ChurnRisk::High
222    } else if slope < config.decline_slope_threshold * 2.0 {
223        ChurnRisk::High
224    } else if slope < config.decline_slope_threshold {
225        ChurnRisk::Medium
226    } else {
227        ChurnRisk::Low
228    };
229
230    Ok(ChurnAssessment {
231        viewer_id: viewer_id.to_string(),
232        risk,
233        engagement_slope: slope,
234        latest_score,
235    })
236}
237
238// ─── Viewer loyalty scoring ───────────────────────────────────────────────────
239
240/// Weights for the recency-frequency-duration loyalty model.
241#[derive(Debug, Clone)]
242pub struct LoyaltyWeights {
243    /// Weight for recency component (how recently did they watch).
244    pub recency: f32,
245    /// Weight for frequency component (how often do they watch).
246    pub frequency: f32,
247    /// Weight for duration component (how long do they watch per session).
248    pub duration: f32,
249}
250
251impl Default for LoyaltyWeights {
252    fn default() -> Self {
253        Self {
254            recency: 0.35,
255            frequency: 0.35,
256            duration: 0.30,
257        }
258    }
259}
260
261/// Decomposed loyalty score components.
262#[derive(Debug, Clone)]
263pub struct LoyaltyComponents {
264    /// Recency score: 1.0 if viewed within `recency_window_ms`, decaying to 0.
265    pub recency_score: f32,
266    /// Frequency score: normalised session count (capped at 1.0).
267    pub frequency_score: f32,
268    /// Duration score: avg watch duration relative to `max_duration_ms`.
269    pub duration_score: f32,
270}
271
272/// Final loyalty assessment for a viewer.
273#[derive(Debug, Clone)]
274pub struct LoyaltyScore {
275    pub viewer_id: String,
276    /// Composite loyalty score in [0.0, 1.0].
277    pub score: f32,
278    pub components: LoyaltyComponents,
279}
280
281/// Compute a loyalty score for a viewer from their session history.
282///
283/// # Parameters
284///
285/// * `viewer_id`         — identifier for the viewer.
286/// * `session_starts_ms` — Unix epoch ms timestamps of all their sessions.
287/// * `watch_durations_ms`— Watch duration in ms for each session (parallel to
288///   `session_starts_ms`).
289/// * `now_ms`            — Current wall-clock time (epoch ms), used for recency.
290/// * `recency_window_ms` — Viewing within this window scores full recency.
291/// * `freq_cap`          — Session count at which frequency score is capped at 1.0.
292/// * `max_duration_ms`   — Watch duration at which duration score is capped at 1.0.
293/// * `weights`           — Component weights (should sum to 1.0).
294///
295/// Returns an error if `session_starts_ms` and `watch_durations_ms` have
296/// different lengths.
297pub fn compute_loyalty(
298    viewer_id: &str,
299    session_starts_ms: &[i64],
300    watch_durations_ms: &[u64],
301    now_ms: i64,
302    recency_window_ms: i64,
303    freq_cap: usize,
304    max_duration_ms: u64,
305    weights: &LoyaltyWeights,
306) -> Result<LoyaltyScore, AnalyticsError> {
307    if session_starts_ms.len() != watch_durations_ms.len() {
308        return Err(AnalyticsError::ConfigError(
309            "session_starts_ms and watch_durations_ms must have equal length".to_string(),
310        ));
311    }
312
313    // Recency: time since last session, normalised against recency_window_ms.
314    let recency_score = if session_starts_ms.is_empty() {
315        0.0f32
316    } else {
317        let last_ms = session_starts_ms.iter().copied().max().unwrap_or(0);
318        let age_ms = (now_ms - last_ms).max(0) as f64;
319        let window = recency_window_ms.max(1) as f64;
320        (1.0 - (age_ms / window).min(1.0)) as f32
321    };
322
323    // Frequency: number of sessions normalised to freq_cap.
324    let frequency_score = if freq_cap == 0 {
325        0.0f32
326    } else {
327        (session_starts_ms.len() as f32 / freq_cap as f32).min(1.0)
328    };
329
330    // Duration: average watch time normalised to max_duration_ms.
331    let duration_score = if watch_durations_ms.is_empty() || max_duration_ms == 0 {
332        0.0f32
333    } else {
334        let avg_dur: f64 =
335            watch_durations_ms.iter().sum::<u64>() as f64 / watch_durations_ms.len() as f64;
336        (avg_dur / max_duration_ms as f64).min(1.0) as f32
337    };
338
339    let score = (weights.recency * recency_score
340        + weights.frequency * frequency_score
341        + weights.duration * duration_score)
342        .min(1.0)
343        .max(0.0);
344
345    Ok(LoyaltyScore {
346        viewer_id: viewer_id.to_string(),
347        score,
348        components: LoyaltyComponents {
349            recency_score,
350            frequency_score,
351            duration_score,
352        },
353    })
354}
355
356// ─── Event-driven funnel analysis ────────────────────────────────────────────
357
358/// A raw session event used for event-driven funnel analysis.
359#[derive(Debug, Clone)]
360pub struct SessionEvent {
361    /// Unique user identifier.
362    pub user_id: String,
363    /// Event type name (e.g. `"page_view"`, `"add_to_cart"`, `"purchase"`).
364    pub event_type: String,
365    /// Unix epoch milliseconds when the event occurred.
366    pub timestamp_ms: u64,
367}
368
369/// One step in a funnel definition.
370#[derive(Debug, Clone)]
371pub struct FunnelStepDef {
372    /// Human-readable name for this step.
373    pub name: String,
374    /// The `event_type` that constitutes completion of this step.
375    pub event_type: String,
376}
377
378/// Defines an ordered sequence of steps that constitute a conversion funnel.
379#[derive(Debug, Clone)]
380pub struct FunnelDefinition {
381    /// Ordered steps; users must complete them in order.
382    pub steps: Vec<FunnelStepDef>,
383    /// Maximum time allowed between consecutive steps (ms).
384    /// If a user takes longer than this between any two steps, the funnel
385    /// sequence resets from the beginning.
386    pub max_time_between_steps_ms: u64,
387}
388
389/// Output of a funnel analysis.
390#[derive(Debug, Clone)]
391pub struct FunnelReport {
392    /// Number of users who completed each step (`step_completions[i]` for step i).
393    /// Length equals the number of steps in the definition.
394    pub step_completions: Vec<u64>,
395    /// Conversion rate from the previous step to this step (1.0 for step 0).
396    /// `conversion_rates[i] = step_completions[i] / step_completions[i-1]`.
397    pub conversion_rates: Vec<f64>,
398    /// Drop-off rate at each step (`1.0 - conversion_rates[i]`; 0.0 for step 0).
399    pub drop_offs: Vec<f64>,
400}
401
402impl FunnelReport {
403    /// Overall completion rate: fraction of users who reached the final step
404    /// relative to those who reached step 0.
405    pub fn overall_completion_rate(&self) -> f64 {
406        let first = self.step_completions.first().copied().unwrap_or(0);
407        let last = self.step_completions.last().copied().unwrap_or(0);
408        if first == 0 {
409            0.0
410        } else {
411            last as f64 / first as f64
412        }
413    }
414}
415
416/// Analyses event-driven funnels from raw `SessionEvent` streams.
417pub struct FunnelAnalyzer;
418
419impl FunnelAnalyzer {
420    /// Analyse `sessions` against `definition` and return a [`FunnelReport`].
421    ///
422    /// For each user, the algorithm attempts to walk through the funnel steps
423    /// in order.  A step is completed when the user fires an event of the
424    /// required `event_type` after completing the previous step AND within
425    /// `max_time_between_steps_ms`.
426    ///
427    /// Returns an empty report (all zeros) if `definition.steps` is empty.
428    pub fn analyze(sessions: &[SessionEvent], definition: &FunnelDefinition) -> FunnelReport {
429        let n_steps = definition.steps.len();
430        if n_steps == 0 {
431            return FunnelReport {
432                step_completions: Vec::new(),
433                conversion_rates: Vec::new(),
434                drop_offs: Vec::new(),
435            };
436        }
437
438        let mut step_completions = vec![0u64; n_steps];
439
440        // Group events by user_id, sorted by timestamp.
441        let mut by_user: std::collections::HashMap<&str, Vec<&SessionEvent>> =
442            std::collections::HashMap::new();
443        for ev in sessions {
444            by_user.entry(ev.user_id.as_str()).or_default().push(ev);
445        }
446        for events in by_user.values_mut() {
447            events.sort_by_key(|e| e.timestamp_ms);
448        }
449
450        for events in by_user.values() {
451            // Walk through the funnel steps for this user.
452            let mut step_idx = 0usize;
453            let mut last_step_ts: Option<u64> = None;
454
455            for ev in events.iter() {
456                if step_idx >= n_steps {
457                    break;
458                }
459                let required = &definition.steps[step_idx].event_type;
460                if ev.event_type != *required {
461                    continue;
462                }
463                // Check time window constraint (applies from step 1 onwards).
464                if let Some(prev_ts) = last_step_ts {
465                    if ev.timestamp_ms.saturating_sub(prev_ts)
466                        > definition.max_time_between_steps_ms
467                    {
468                        // Timed out; restart from step 0.
469                        step_idx = 0;
470                        last_step_ts = None;
471                        // Check if this event matches step 0.
472                        if ev.event_type == definition.steps[0].event_type {
473                            step_completions[0] += 1;
474                            step_idx = 1;
475                            last_step_ts = Some(ev.timestamp_ms);
476                        }
477                        continue;
478                    }
479                }
480                step_completions[step_idx] += 1;
481                step_idx += 1;
482                last_step_ts = Some(ev.timestamp_ms);
483            }
484        }
485
486        // Compute conversion rates and drop-offs.
487        let mut conversion_rates = vec![0f64; n_steps];
488        let mut drop_offs = vec![0f64; n_steps];
489        conversion_rates[0] = 1.0;
490        drop_offs[0] = 0.0;
491        for i in 1..n_steps {
492            let prev = step_completions[i - 1];
493            conversion_rates[i] = if prev == 0 {
494                0.0
495            } else {
496                step_completions[i] as f64 / prev as f64
497            };
498            drop_offs[i] = 1.0 - conversion_rates[i];
499        }
500
501        FunnelReport {
502            step_completions,
503            conversion_rates,
504            drop_offs,
505        }
506    }
507}
508
509// ─── Tests ────────────────────────────────────────────────────────────────────
510
511#[cfg(test)]
512mod tests {
513    use super::*;
514    use crate::session::{PlaybackEvent, ViewerSession};
515
516    fn watch_session(id: &str, end_ms: u64, duration_ms: u64) -> ViewerSession {
517        ViewerSession {
518            session_id: id.to_string(),
519            user_id: None,
520            content_id: "c1".to_string(),
521            started_at_ms: 0,
522            events: vec![
523                PlaybackEvent::Play { timestamp_ms: 0 },
524                PlaybackEvent::End {
525                    position_ms: end_ms,
526                    watch_duration_ms: duration_ms,
527                },
528            ],
529        }
530    }
531
532    // ── compute_funnel ───────────────────────────────────────────────────────
533
534    #[test]
535    fn funnel_all_viewers_reach_all_milestones() {
536        let sessions = vec![
537            watch_session("s1", 10_000, 10_000),
538            watch_session("s2", 10_000, 10_000),
539        ];
540        let milestones = vec![
541            FunnelMilestone {
542                name: "start".to_string(),
543                position_ms: 0,
544            },
545            FunnelMilestone {
546                name: "mid".to_string(),
547                position_ms: 5_000,
548            },
549            FunnelMilestone {
550                name: "end".to_string(),
551                position_ms: 9_000,
552            },
553        ];
554        let result =
555            compute_funnel(&sessions, &milestones, 10_000).expect("compute funnel should succeed");
556        assert_eq!(result.steps.len(), 3);
557        assert_eq!(result.total_starters, 2);
558        // All 2 viewers should reach every milestone.
559        assert_eq!(result.steps[2].viewers_reached, 2);
560        assert!((result.completion_rate() - 1.0).abs() < 1e-4);
561    }
562
563    #[test]
564    fn funnel_dropout_midway() {
565        // s1 watches 0-5s; s2 and s3 watch 0-10s.
566        let sessions = vec![
567            watch_session("s1", 5_000, 5_000),
568            watch_session("s2", 10_000, 10_000),
569            watch_session("s3", 10_000, 10_000),
570        ];
571        let milestones = vec![
572            FunnelMilestone {
573                name: "intro".to_string(),
574                position_ms: 1_000,
575            },
576            FunnelMilestone {
577                name: "end".to_string(),
578                position_ms: 9_000,
579            },
580        ];
581        let result =
582            compute_funnel(&sessions, &milestones, 10_000).expect("compute funnel should succeed");
583        assert_eq!(result.steps[0].viewers_reached, 3);
584        assert_eq!(result.steps[1].viewers_reached, 2);
585        let biggest = result
586            .biggest_drop_step()
587            .expect("biggest drop step should succeed");
588        assert_eq!(biggest, 1);
589    }
590
591    #[test]
592    fn funnel_empty_sessions_returns_error() {
593        let milestones = vec![FunnelMilestone {
594            name: "start".to_string(),
595            position_ms: 0,
596        }];
597        assert!(compute_funnel(&[], &milestones, 10_000).is_err());
598    }
599
600    #[test]
601    fn funnel_empty_milestones_returns_error() {
602        let sessions = vec![watch_session("s1", 10_000, 10_000)];
603        assert!(compute_funnel(&sessions, &[], 10_000).is_err());
604    }
605
606    // ── predict_churn ────────────────────────────────────────────────────────
607
608    #[test]
609    fn churn_high_risk_strong_decline() {
610        // Strongly declining scores.
611        let scores: Vec<(i64, f32)> = (0..10)
612            .map(|i| (i as i64 * 7 * 86_400_000, 1.0 - i as f32 * 0.09))
613            .collect();
614        let config = ChurnConfig::default();
615        let result =
616            predict_churn("viewer1", &scores, &config).expect("predict churn should succeed");
617        // Slope is very negative → at least medium risk.
618        assert_ne!(result.risk, ChurnRisk::Low);
619    }
620
621    #[test]
622    fn churn_low_risk_growing_engagement() {
623        let scores: Vec<(i64, f32)> = (0..8)
624            .map(|i| (i as i64 * 86_400_000, 0.3 + i as f32 * 0.05))
625            .collect();
626        let config = ChurnConfig::default();
627        let result =
628            predict_churn("viewer2", &scores, &config).expect("predict churn should succeed");
629        assert_eq!(result.risk, ChurnRisk::Low);
630    }
631
632    #[test]
633    fn churn_insufficient_data_returns_error() {
634        let scores = vec![(0i64, 0.5f32), (1, 0.4)]; // only 2 points
635        let config = ChurnConfig {
636            min_data_points: 3,
637            ..Default::default()
638        };
639        assert!(predict_churn("v", &scores, &config).is_err());
640    }
641
642    #[test]
643    fn churn_low_engagement_always_high_risk() {
644        let scores: Vec<(i64, f32)> = (0..5)
645            .map(|i| (i as i64 * 86_400_000, 0.05)) // very low but flat
646            .collect();
647        let config = ChurnConfig::default();
648        let result =
649            predict_churn("v_low", &scores, &config).expect("predict churn should succeed");
650        assert_eq!(result.risk, ChurnRisk::High);
651    }
652
653    // ── compute_loyalty ──────────────────────────────────────────────────────
654
655    #[test]
656    fn loyalty_perfect_viewer() {
657        let now_ms = 10 * 86_400_000i64; // 10 days in
658                                         // Watched 10 times within the last day; each session 30 min.
659        let starts: Vec<i64> = (0..10).map(|i| now_ms - i * 3_600_000).collect();
660        let durations = vec![1_800_000u64; 10]; // 30 min
661        let weights = LoyaltyWeights::default();
662        let score = compute_loyalty(
663            "v1",
664            &starts,
665            &durations,
666            now_ms,
667            7 * 86_400_000,
668            10,
669            3_600_000,
670            &weights,
671        )
672        .expect("value should be present should succeed");
673        assert!(
674            score.score > 0.8,
675            "expected high loyalty, got {}",
676            score.score
677        );
678        assert!(score.components.recency_score > 0.95);
679    }
680
681    #[test]
682    fn loyalty_churned_viewer() {
683        let now_ms = 100 * 86_400_000i64;
684        // Last watched 60 days ago, only 1 session.
685        let starts = vec![now_ms - 60 * 86_400_000];
686        let durations = vec![60_000u64]; // 1 minute
687        let weights = LoyaltyWeights::default();
688        let score = compute_loyalty(
689            "v2",
690            &starts,
691            &durations,
692            now_ms,
693            7 * 86_400_000,
694            20,
695            3_600_000,
696            &weights,
697        )
698        .expect("value should be present should succeed");
699        assert!(
700            score.score < 0.3,
701            "expected low loyalty, got {}",
702            score.score
703        );
704        assert_eq!(score.components.recency_score, 0.0);
705    }
706
707    #[test]
708    fn loyalty_mismatched_lengths_error() {
709        let result = compute_loyalty(
710            "v",
711            &[0i64, 1],
712            &[1000u64],
713            1000,
714            86_400_000,
715            10,
716            3_600_000,
717            &LoyaltyWeights::default(),
718        );
719        assert!(result.is_err());
720    }
721
722    #[test]
723    fn loyalty_empty_sessions() {
724        let score = compute_loyalty(
725            "v_new",
726            &[],
727            &[],
728            0,
729            86_400_000,
730            10,
731            3_600_000,
732            &LoyaltyWeights::default(),
733        )
734        .expect("value should be present should succeed");
735        assert_eq!(score.score, 0.0);
736    }
737
738    // ── FunnelAnalyzer ────────────────────────────────────────────────────────
739
740    fn make_def(steps: &[(&str, &str)], max_gap_ms: u64) -> FunnelDefinition {
741        FunnelDefinition {
742            steps: steps
743                .iter()
744                .map(|(name, ev)| FunnelStepDef {
745                    name: name.to_string(),
746                    event_type: ev.to_string(),
747                })
748                .collect(),
749            max_time_between_steps_ms: max_gap_ms,
750        }
751    }
752
753    fn ev(user: &str, event_type: &str, ts: u64) -> SessionEvent {
754        SessionEvent {
755            user_id: user.to_string(),
756            event_type: event_type.to_string(),
757            timestamp_ms: ts,
758        }
759    }
760
761    #[test]
762    fn funnel_analyzer_empty_sessions() {
763        let def = make_def(&[("view", "view")], 60_000);
764        let report = FunnelAnalyzer::analyze(&[], &def);
765        assert_eq!(report.step_completions, vec![0]);
766        assert_eq!(report.conversion_rates, vec![1.0]);
767        assert_eq!(report.drop_offs, vec![0.0]);
768    }
769
770    #[test]
771    fn funnel_analyzer_empty_steps_returns_empty_report() {
772        let def = FunnelDefinition {
773            steps: vec![],
774            max_time_between_steps_ms: 60_000,
775        };
776        let report = FunnelAnalyzer::analyze(&[ev("u1", "view", 0)], &def);
777        assert!(report.step_completions.is_empty());
778        assert!(report.conversion_rates.is_empty());
779        assert!(report.drop_offs.is_empty());
780    }
781
782    #[test]
783    fn funnel_analyzer_single_step_single_user() {
784        let def = make_def(&[("view", "view")], 60_000);
785        let events = vec![ev("u1", "view", 1000)];
786        let report = FunnelAnalyzer::analyze(&events, &def);
787        assert_eq!(report.step_completions[0], 1);
788        assert_eq!(report.conversion_rates[0], 1.0);
789        assert_eq!(report.drop_offs[0], 0.0);
790    }
791
792    #[test]
793    fn funnel_analyzer_full_conversion_two_steps() {
794        let def = make_def(&[("view", "view"), ("purchase", "purchase")], 300_000);
795        let events = vec![
796            ev("u1", "view", 0),
797            ev("u1", "purchase", 10_000),
798            ev("u2", "view", 0),
799            ev("u2", "purchase", 20_000),
800        ];
801        let report = FunnelAnalyzer::analyze(&events, &def);
802        assert_eq!(report.step_completions[0], 2);
803        assert_eq!(report.step_completions[1], 2);
804        assert!((report.conversion_rates[1] - 1.0).abs() < 1e-9);
805        assert!(report.drop_offs[1].abs() < 1e-9);
806    }
807
808    #[test]
809    fn funnel_analyzer_partial_conversion() {
810        let def = make_def(&[("view", "view"), ("purchase", "purchase")], 300_000);
811        let events = vec![
812            ev("u1", "view", 0),
813            ev("u1", "purchase", 5_000),
814            ev("u2", "view", 0),
815            // u2 does not purchase
816        ];
817        let report = FunnelAnalyzer::analyze(&events, &def);
818        assert_eq!(report.step_completions[0], 2);
819        assert_eq!(report.step_completions[1], 1);
820        assert!((report.conversion_rates[1] - 0.5).abs() < 1e-9);
821        assert!((report.drop_offs[1] - 0.5).abs() < 1e-9);
822    }
823
824    #[test]
825    fn funnel_analyzer_time_window_exceeded_resets() {
826        let def = make_def(
827            &[("view", "view"), ("purchase", "purchase")],
828            5_000, // only 5 seconds allowed
829        );
830        let events = vec![
831            ev("u1", "view", 0),
832            ev("u1", "purchase", 100_000), // 100s later → reset
833        ];
834        let report = FunnelAnalyzer::analyze(&events, &def);
835        // u1 completed step 0 but not step 1 (time exceeded).
836        assert_eq!(report.step_completions[0], 1);
837        assert_eq!(report.step_completions[1], 0);
838    }
839
840    #[test]
841    fn funnel_analyzer_three_step_funnel() {
842        let def = make_def(
843            &[
844                ("view", "view"),
845                ("cart", "add_to_cart"),
846                ("purchase", "purchase"),
847            ],
848            600_000,
849        );
850        let events = vec![
851            ev("u1", "view", 0),
852            ev("u1", "add_to_cart", 5_000),
853            ev("u1", "purchase", 10_000),
854            ev("u2", "view", 0),
855            ev("u2", "add_to_cart", 5_000),
856            // u2 stops here
857            ev("u3", "view", 0),
858            // u3 stops at view
859        ];
860        let report = FunnelAnalyzer::analyze(&events, &def);
861        assert_eq!(report.step_completions[0], 3);
862        assert_eq!(report.step_completions[1], 2);
863        assert_eq!(report.step_completions[2], 1);
864    }
865
866    #[test]
867    fn funnel_analyzer_conversion_rates_sum_correctly() {
868        let def = make_def(&[("a", "a"), ("b", "b"), ("c", "c")], 60_000);
869        let events = vec![
870            ev("u1", "a", 0),
871            ev("u1", "b", 1_000),
872            ev("u1", "c", 2_000),
873            ev("u2", "a", 0),
874            ev("u2", "b", 1_000),
875            ev("u3", "a", 0),
876        ];
877        let report = FunnelAnalyzer::analyze(&events, &def);
878        // step 0: 3 users; step 1: 2; step 2: 1
879        assert_eq!(report.step_completions[0], 3);
880        assert!((report.conversion_rates[1] - 2.0 / 3.0).abs() < 1e-9);
881        assert!((report.conversion_rates[2] - 0.5).abs() < 1e-9);
882    }
883
884    #[test]
885    fn funnel_analyzer_overall_completion_rate() {
886        let def = make_def(&[("a", "a"), ("b", "b")], 60_000);
887        let events = vec![ev("u1", "a", 0), ev("u1", "b", 1_000), ev("u2", "a", 0)];
888        let report = FunnelAnalyzer::analyze(&events, &def);
889        assert!((report.overall_completion_rate() - 0.5).abs() < 1e-9);
890    }
891
892    #[test]
893    fn funnel_analyzer_irrelevant_events_ignored() {
894        let def = make_def(&[("view", "view"), ("buy", "purchase")], 60_000);
895        let events = vec![
896            ev("u1", "view", 0),
897            ev("u1", "click", 1_000),  // irrelevant
898            ev("u1", "scroll", 2_000), // irrelevant
899            ev("u1", "purchase", 3_000),
900        ];
901        let report = FunnelAnalyzer::analyze(&events, &def);
902        assert_eq!(report.step_completions[0], 1);
903        assert_eq!(report.step_completions[1], 1);
904    }
905
906    #[test]
907    fn funnel_analyzer_multiple_users_independent() {
908        let def = make_def(&[("start", "start"), ("end", "end")], 120_000);
909        let mut events = Vec::new();
910        for i in 0..10u64 {
911            events.push(ev(&format!("u{i}"), "start", i * 1000));
912            if i % 2 == 0 {
913                events.push(ev(&format!("u{i}"), "end", i * 1000 + 500));
914            }
915        }
916        let report = FunnelAnalyzer::analyze(&events, &def);
917        assert_eq!(report.step_completions[0], 10);
918        assert_eq!(report.step_completions[1], 5);
919    }
920
921    #[test]
922    fn funnel_analyzer_step0_first_conversion_rate_always_one() {
923        let def = make_def(&[("x", "x"), ("y", "y")], 60_000);
924        let events = vec![ev("u1", "x", 0)];
925        let report = FunnelAnalyzer::analyze(&events, &def);
926        assert_eq!(report.conversion_rates[0], 1.0);
927        assert_eq!(report.drop_offs[0], 0.0);
928    }
929
930    #[test]
931    fn funnel_analyzer_no_step0_users_conversion_rate_is_zero() {
932        let def = make_def(&[("x", "x"), ("y", "y")], 60_000);
933        // No events at all for step 0.
934        let events = vec![ev("u1", "y", 0)];
935        let report = FunnelAnalyzer::analyze(&events, &def);
936        // step 0 completions = 0; step 1 completions = 0.
937        assert_eq!(report.step_completions[0], 0);
938        assert_eq!(report.conversion_rates[1], 0.0);
939    }
940}