#[derive(Debug, Clone, PartialEq)]
pub struct Segment {
pub id: String,
pub start_sec: f32,
pub end_sec: f32,
}
impl Segment {
pub fn new(id: impl Into<String>, start_sec: f32, end_sec: f32) -> Self {
Self {
id: id.into(),
start_sec,
end_sec,
}
}
pub fn duration_sec(&self) -> f32 {
(self.end_sec - self.start_sec).max(0.0)
}
}
#[derive(Debug, Clone)]
pub struct ViewerSegmentData {
pub session_id: String,
pub furthest_reach_sec: f32,
}
impl ViewerSegmentData {
pub fn new(session_id: impl Into<String>, furthest_reach_sec: f32) -> Self {
Self {
session_id: session_id.into(),
furthest_reach_sec,
}
}
}
#[derive(Debug, Clone)]
pub struct SegmentRetentionAnalysis {
pub segments: Vec<Segment>,
pub retention_by_segment: Vec<(String, f32)>,
}
pub fn analyze_segment_retention(
segments: &[Segment],
views: &[ViewerSegmentData],
) -> SegmentRetentionAnalysis {
if segments.is_empty() || views.is_empty() {
return SegmentRetentionAnalysis {
segments: segments.to_vec(),
retention_by_segment: Vec::new(),
};
}
let total = views.len() as f32;
let retention_by_segment = segments
.iter()
.map(|seg| {
let reached = views
.iter()
.filter(|v| v.furthest_reach_sec >= seg.end_sec)
.count() as f32;
(seg.id.clone(), reached / total)
})
.collect();
SegmentRetentionAnalysis {
segments: segments.to_vec(),
retention_by_segment,
}
}
pub fn drop_off_points(analysis: &SegmentRetentionAnalysis, threshold: f32) -> Vec<String> {
let mut result = Vec::new();
let data = &analysis.retention_by_segment;
for window in data.windows(2) {
let (_, prev_retention) = &window[0];
let (seg_id, curr_retention) = &window[1];
let drop = prev_retention - curr_retention;
if drop > threshold {
result.push(seg_id.clone());
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
fn make_segments() -> Vec<Segment> {
vec![
Segment::new("intro", 0.0, 60.0),
Segment::new("act_1", 60.0, 300.0),
Segment::new("act_2", 300.0, 600.0),
Segment::new("outro", 600.0, 660.0),
]
}
fn make_views_full_retention() -> Vec<ViewerSegmentData> {
(0..100)
.map(|i| ViewerSegmentData::new(format!("s{i}"), 660.0))
.collect()
}
fn make_views_drop_at_act2() -> Vec<ViewerSegmentData> {
let mut views: Vec<ViewerSegmentData> = (0..100)
.map(|i| ViewerSegmentData::new(format!("s{i}"), 300.0))
.collect();
for v in views.iter_mut().take(50) {
v.furthest_reach_sec = 660.0;
}
views
}
#[test]
fn analyze_segment_retention_empty_segments() {
let views = make_views_full_retention();
let result = analyze_segment_retention(&[], &views);
assert!(result.retention_by_segment.is_empty());
}
#[test]
fn analyze_segment_retention_empty_views() {
let segments = make_segments();
let result = analyze_segment_retention(&segments, &[]);
assert!(result.retention_by_segment.is_empty());
}
#[test]
fn analyze_segment_retention_full_retention() {
let segments = make_segments();
let views = make_views_full_retention();
let result = analyze_segment_retention(&segments, &views);
assert_eq!(result.retention_by_segment.len(), 4);
for (_, r) in &result.retention_by_segment {
assert!((*r - 1.0).abs() < 1e-5, "expected 1.0, got {r}");
}
}
#[test]
fn analyze_segment_retention_partial() {
let segments = make_segments();
let views = make_views_drop_at_act2();
let result = analyze_segment_retention(&segments, &views);
let intro_r = result.retention_by_segment[0].1;
let act1_r = result.retention_by_segment[1].1;
let act2_r = result.retention_by_segment[2].1;
let outro_r = result.retention_by_segment[3].1;
assert!((intro_r - 1.0).abs() < 1e-5, "intro retention={intro_r}");
assert!((act1_r - 1.0).abs() < 1e-5, "act1 retention={act1_r}");
assert!((act2_r - 0.5).abs() < 1e-5, "act2 retention={act2_r}");
assert!((outro_r - 0.5).abs() < 1e-5, "outro retention={outro_r}");
}
#[test]
fn drop_off_points_detects_large_drop() {
let segments = make_segments();
let views = make_views_drop_at_act2();
let analysis = analyze_segment_retention(&segments, &views);
let drops = drop_off_points(&analysis, 0.1);
assert!(
drops.contains(&"act_2".to_string()),
"expected act_2 in drops, got {:?}",
drops
);
}
#[test]
fn drop_off_points_no_drops_when_threshold_high() {
let segments = make_segments();
let views = make_views_drop_at_act2();
let analysis = analyze_segment_retention(&segments, &views);
let drops = drop_off_points(&analysis, 0.6);
assert!(drops.is_empty(), "expected no drops, got {:?}", drops);
}
#[test]
fn drop_off_points_empty_when_single_segment() {
let segments = vec![Segment::new("only", 0.0, 60.0)];
let views = vec![ViewerSegmentData::new("v1", 60.0)];
let analysis = analyze_segment_retention(&segments, &views);
let drops = drop_off_points(&analysis, 0.05);
assert!(drops.is_empty());
}
#[test]
fn segment_duration_sec() {
let seg = Segment::new("test", 30.0, 90.0);
assert!((seg.duration_sec() - 60.0).abs() < 1e-5);
}
}