1use crate::engagement::linear_regression_slope;
22use crate::error::AnalyticsError;
23use crate::session::{build_playback_map, ViewerSession};
24
25#[derive(Debug, Clone)]
32pub struct FunnelMilestone {
33 pub name: String,
34 pub position_ms: u64,
36}
37
38#[derive(Debug, Clone)]
40pub struct FunnelStep {
41 pub milestone_name: String,
42 pub position_ms: u64,
43 pub viewers_reached: u32,
45 pub conversion_from_prev: f32,
47 pub overall_rate: f32,
49}
50
51#[derive(Debug, Clone)]
53pub struct FunnelResult {
54 pub steps: Vec<FunnelStep>,
55 pub total_starters: u32,
56}
57
58impl FunnelResult {
59 pub fn completion_rate(&self) -> f32 {
61 self.steps.last().map(|s| s.overall_rate).unwrap_or(0.0)
62 }
63
64 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
84pub 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 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#[derive(Debug, Clone)]
152pub struct ChurnConfig {
153 pub min_data_points: usize,
155 pub decline_slope_threshold: f32,
158 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, low_engagement_threshold: 0.2,
168 }
169 }
170}
171
172#[derive(Debug, Clone, Copy, PartialEq, Eq)]
174pub enum ChurnRisk {
175 Low,
177 Medium,
179 High,
181}
182
183#[derive(Debug, Clone)]
185pub struct ChurnAssessment {
186 pub viewer_id: String,
187 pub risk: ChurnRisk,
188 pub engagement_slope: f32,
190 pub latest_score: f32,
192}
193
194pub 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#[derive(Debug, Clone)]
242pub struct LoyaltyWeights {
243 pub recency: f32,
245 pub frequency: f32,
247 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#[derive(Debug, Clone)]
263pub struct LoyaltyComponents {
264 pub recency_score: f32,
266 pub frequency_score: f32,
268 pub duration_score: f32,
270}
271
272#[derive(Debug, Clone)]
274pub struct LoyaltyScore {
275 pub viewer_id: String,
276 pub score: f32,
278 pub components: LoyaltyComponents,
279}
280
281pub 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 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 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 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#[derive(Debug, Clone)]
360pub struct SessionEvent {
361 pub user_id: String,
363 pub event_type: String,
365 pub timestamp_ms: u64,
367}
368
369#[derive(Debug, Clone)]
371pub struct FunnelStepDef {
372 pub name: String,
374 pub event_type: String,
376}
377
378#[derive(Debug, Clone)]
380pub struct FunnelDefinition {
381 pub steps: Vec<FunnelStepDef>,
383 pub max_time_between_steps_ms: u64,
387}
388
389#[derive(Debug, Clone)]
391pub struct FunnelReport {
392 pub step_completions: Vec<u64>,
395 pub conversion_rates: Vec<f64>,
398 pub drop_offs: Vec<f64>,
400}
401
402impl FunnelReport {
403 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
416pub struct FunnelAnalyzer;
418
419impl FunnelAnalyzer {
420 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 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 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 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 step_idx = 0;
470 last_step_ts = None;
471 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 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#[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 #[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 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 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 #[test]
609 fn churn_high_risk_strong_decline() {
610 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 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)]; 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)) .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 #[test]
656 fn loyalty_perfect_viewer() {
657 let now_ms = 10 * 86_400_000i64; let starts: Vec<i64> = (0..10).map(|i| now_ms - i * 3_600_000).collect();
660 let durations = vec![1_800_000u64; 10]; 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 let starts = vec![now_ms - 60 * 86_400_000];
686 let durations = vec![60_000u64]; 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 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 ];
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, );
830 let events = vec![
831 ev("u1", "view", 0),
832 ev("u1", "purchase", 100_000), ];
834 let report = FunnelAnalyzer::analyze(&events, &def);
835 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 ev("u3", "view", 0),
858 ];
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 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), ev("u1", "scroll", 2_000), 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 let events = vec![ev("u1", "y", 0)];
935 let report = FunnelAnalyzer::analyze(&events, &def);
936 assert_eq!(report.step_completions[0], 0);
938 assert_eq!(report.conversion_rates[1], 0.0);
939 }
940}