Skip to main content

oximedia_analytics/
engagement.rs

1//! Engagement scoring model for media content.
2//!
3//! Computes a weighted engagement score from viewer session data, models score
4//! trends over time with linear regression, and ranks content by engagement.
5
6use crate::session::{build_playback_map, PlaybackEvent, ViewerSession};
7
8// ─── Score model ──────────────────────────────────────────────────────────────
9
10/// Decomposed components of an engagement score (each in 0.0 – 1.0).
11#[derive(Debug, Clone, PartialEq)]
12pub struct EngagementComponents {
13    /// Ratio of average watch time to content duration (capped at 1.0).
14    pub watch_time_score: f32,
15    /// Fraction of sessions that reached ≥95 % completion.
16    pub completion_score: f32,
17    /// Fraction of sessions that rewatched any segment.
18    pub rewatch_score: f32,
19    /// Placeholder for social interaction data (always 0.5 until social data
20    /// is available in sessions).
21    pub social_score: f32,
22    /// Penalty term proportional to the forward-seek rate (lower is better).
23    pub seek_forward_penalty: f32,
24}
25
26/// Weights controlling the relative importance of each engagement component.
27#[derive(Debug, Clone, PartialEq)]
28pub struct EngagementWeights {
29    pub watch_time: f32,
30    pub completion: f32,
31    pub rewatch: f32,
32    pub social: f32,
33    /// Multiplicative penalty factor for forward seeks.  A value of 1.0 means
34    /// each forward seek as a fraction of total events subtracts directly from
35    /// the score.
36    pub forward_seek_penalty: f32,
37}
38
39impl EngagementWeights {
40    /// All five components equally weighted at 0.2.
41    pub fn default() -> Self {
42        Self {
43            watch_time: 0.2,
44            completion: 0.2,
45            rewatch: 0.2,
46            social: 0.2,
47            forward_seek_penalty: 0.2,
48        }
49    }
50}
51
52/// Final engagement score for a piece of content.
53#[derive(Debug, Clone, PartialEq)]
54pub struct ContentEngagementScore {
55    pub content_id: String,
56    /// Overall score in 0.0 – 1.0.
57    pub score: f32,
58    pub components: EngagementComponents,
59}
60
61// ─── Core computation ─────────────────────────────────────────────────────────
62
63/// Compute an engagement score for a content item from its viewer sessions.
64///
65/// Returns a score of `0.0` when `sessions` is empty or `content_duration_ms`
66/// is zero.  The `content_id` is taken from the first session's `content_id`.
67pub fn compute_engagement(
68    sessions: &[ViewerSession],
69    content_duration_ms: u64,
70    weights: &EngagementWeights,
71) -> ContentEngagementScore {
72    let content_id = sessions
73        .first()
74        .map(|s| s.content_id.clone())
75        .unwrap_or_default();
76
77    if sessions.is_empty() || content_duration_ms == 0 {
78        return ContentEngagementScore {
79            content_id,
80            score: 0.0,
81            components: EngagementComponents {
82                watch_time_score: 0.0,
83                completion_score: 0.0,
84                rewatch_score: 0.0,
85                social_score: 0.5,
86                seek_forward_penalty: 0.0,
87            },
88        };
89    }
90
91    let n = sessions.len() as f64;
92    let completion_threshold_ms = (content_duration_ms as f64 * 0.95) as u64;
93
94    let mut total_watch_ms: u64 = 0;
95    let mut completion_count: u32 = 0;
96    let mut rewatch_count: u32 = 0;
97    let mut total_events: u32 = 0;
98    let mut forward_seek_count: u32 = 0;
99
100    for session in sessions {
101        // Watch time: prefer the End event's watch_duration_ms.
102        let session_watch_ms = session.events.iter().fold(0u64, |acc, e| match e {
103            PlaybackEvent::End {
104                watch_duration_ms, ..
105            } => acc.max(*watch_duration_ms),
106            _ => acc,
107        });
108        total_watch_ms += session_watch_ms;
109
110        // Completion: did the session reach ≥ 95 % of the content?
111        let map = build_playback_map(session, content_duration_ms);
112        let completion_sec = (completion_threshold_ms / 1000) as usize;
113        if map
114            .positions_watched
115            .get(completion_sec)
116            .copied()
117            .unwrap_or(false)
118        {
119            completion_count += 1;
120        }
121
122        // Rewatch: any second watched more than once means the session included a seek-back.
123        // We detect this by checking for backward seek events.
124        let has_rewatch = session
125            .events
126            .iter()
127            .any(|e| matches!(e, PlaybackEvent::Seek { from_ms, to_ms } if to_ms < from_ms));
128        if has_rewatch {
129            rewatch_count += 1;
130        }
131
132        // Forward seek penalty.
133        for event in &session.events {
134            total_events += 1;
135            if let PlaybackEvent::Seek { from_ms, to_ms } = event {
136                if to_ms > from_ms {
137                    forward_seek_count += 1;
138                }
139            }
140        }
141    }
142
143    let avg_watch_ms = total_watch_ms as f64 / n;
144    let watch_time_score = (avg_watch_ms / content_duration_ms as f64).min(1.0) as f32;
145    let completion_score = completion_count as f32 / sessions.len() as f32;
146    let rewatch_score = rewatch_count as f32 / sessions.len() as f32;
147    let social_score: f32 = 0.5; // placeholder
148
149    let seek_forward_penalty = if total_events > 0 {
150        forward_seek_count as f32 / total_events as f32
151    } else {
152        0.0
153    };
154
155    // Weighted score:
156    //   score = w_watch * watch_time_score
157    //         + w_completion * completion_score
158    //         + w_rewatch * rewatch_score
159    //         + w_social * social_score
160    //         - w_penalty * seek_forward_penalty
161    // Clamped to [0.0, 1.0].
162    let raw_score = weights.watch_time * watch_time_score
163        + weights.completion * completion_score
164        + weights.rewatch * rewatch_score
165        + weights.social * social_score
166        - weights.forward_seek_penalty * seek_forward_penalty;
167
168    let score = raw_score.max(0.0).min(1.0);
169
170    ContentEngagementScore {
171        content_id,
172        score,
173        components: EngagementComponents {
174            watch_time_score,
175            completion_score,
176            rewatch_score,
177            social_score,
178            seek_forward_penalty,
179        },
180    }
181}
182
183// ─── Trend analysis ───────────────────────────────────────────────────────────
184
185/// A time-series of engagement scores for a content item.
186#[derive(Debug, Clone)]
187pub struct EngagementTrend {
188    /// Pairs of (timestamp_ms, engagement_score).
189    pub scores_over_time: Vec<(i64, f32)>,
190}
191
192impl EngagementTrend {
193    /// Compute the linear-regression slope of the score series.
194    ///
195    /// Returns `0.0` if the series has fewer than two points or if the
196    /// denominator is zero.
197    pub fn slope(&self) -> f32 {
198        linear_regression_slope(&self.scores_over_time)
199    }
200}
201
202/// Compute the least-squares linear regression slope of the given (x, y) data.
203///
204/// `slope = (n·Σxy − Σx·Σy) / (n·Σx² − (Σx)²)`
205///
206/// Returns `0.0` when the denominator is zero (all x values identical) or when
207/// there are fewer than two data points.
208pub fn linear_regression_slope(points: &[(i64, f32)]) -> f32 {
209    let n = points.len();
210    if n < 2 {
211        return 0.0;
212    }
213
214    // Use f64 for numerical stability with large timestamp values.
215    let n_f = n as f64;
216    let mut sum_x: f64 = 0.0;
217    let mut sum_y: f64 = 0.0;
218    let mut sum_xy: f64 = 0.0;
219    let mut sum_x2: f64 = 0.0;
220
221    for &(x, y) in points {
222        let xf = x as f64;
223        let yf = y as f64;
224        sum_x += xf;
225        sum_y += yf;
226        sum_xy += xf * yf;
227        sum_x2 += xf * xf;
228    }
229
230    let denom = n_f * sum_x2 - sum_x * sum_x;
231    if denom.abs() < f64::EPSILON {
232        return 0.0;
233    }
234
235    ((n_f * sum_xy - sum_x * sum_y) / denom) as f32
236}
237
238// ─── Time-series decomposition ────────────────────────────────────────────────
239
240/// A period used for seasonal decomposition.
241#[derive(Debug, Clone, Copy, PartialEq, Eq)]
242pub enum SeasonalPeriod {
243    /// 7-day weekly seasonality.
244    Weekly,
245    /// 30-day monthly seasonality.
246    Monthly,
247    /// Custom period length (number of observations per cycle).
248    Custom(usize),
249}
250
251impl SeasonalPeriod {
252    /// Return the integer period length (number of observations per cycle).
253    pub fn length(&self) -> usize {
254        match self {
255            SeasonalPeriod::Weekly => 7,
256            SeasonalPeriod::Monthly => 30,
257            SeasonalPeriod::Custom(n) => *n,
258        }
259    }
260}
261
262/// Result of additive time-series decomposition: y = trend + seasonal + residual.
263///
264/// All three components have the same length as the input series.
265#[derive(Debug, Clone)]
266pub struct DecomposedSeries {
267    /// Smoothed trend component (centered moving average).
268    pub trend: Vec<f64>,
269    /// Seasonal component (mean deviation for each seasonal phase).
270    pub seasonal: Vec<f64>,
271    /// Residual = observed − trend − seasonal.
272    pub residual: Vec<f64>,
273    /// Original observed values.
274    pub observed: Vec<f64>,
275    /// Period used for decomposition.
276    pub period: usize,
277}
278
279/// Decompose a time-series into trend + seasonal + residual components.
280///
281/// Uses classical additive decomposition (STL-style but without LOESS):
282///
283/// 1. **Trend**: centered moving average with window = `period`.
284/// 2. **Seasonal**: for each phase position in [0, period), compute the mean
285///    of `(observed − trend)` across all cycles; then centre by subtracting
286///    the mean of the seasonal indices.
287/// 3. **Residual**: `observed − trend − seasonal`.
288///
289/// For positions at the edges of the series where the centered moving average
290/// cannot be computed, the trend is interpolated linearly.
291///
292/// Returns `None` when the series has fewer than `2 * period` points.
293pub fn decompose_time_series(
294    series: &[(i64, f32)],
295    period: SeasonalPeriod,
296) -> Option<DecomposedSeries> {
297    let n = series.len();
298    let p = period.length();
299    if p == 0 || n < 2 * p {
300        return None;
301    }
302
303    let y: Vec<f64> = series.iter().map(|&(_, v)| v as f64).collect();
304
305    // ── Step 1: Centered moving average (trend) ───────────────────────────────
306    let half = p / 2;
307    let mut trend = vec![f64::NAN; n];
308
309    for i in half..n.saturating_sub(half) {
310        let start = i.saturating_sub(half);
311        let end = (i + half + 1).min(n);
312        let window = &y[start..end];
313        trend[i] = window.iter().sum::<f64>() / window.len() as f64;
314    }
315
316    // Interpolate NaN edges linearly from the first/last computed values.
317    if let Some(first_valid) = trend.iter().position(|v| !v.is_nan()) {
318        let val = trend[first_valid];
319        for i in 0..first_valid {
320            trend[i] = val;
321        }
322    }
323    if let Some(last_valid) = trend.iter().rposition(|v| !v.is_nan()) {
324        let val = trend[last_valid];
325        for i in (last_valid + 1)..n {
326            trend[i] = val;
327        }
328    }
329    // Linear interpolation between known valid points (fill interior NaNs).
330    let mut start = None;
331    for i in 0..n {
332        if !trend[i].is_nan() {
333            if let Some(s) = start {
334                // Interpolate from s to i.
335                let t_s = trend[s];
336                let t_e = trend[i];
337                for j in (s + 1)..i {
338                    let t = (j - s) as f64 / (i - s) as f64;
339                    trend[j] = t_s + t * (t_e - t_s);
340                }
341                start = None;
342            }
343        } else if start.is_none() {
344            start = Some(if i == 0 { 0 } else { i - 1 });
345        }
346    }
347
348    // ── Step 2: Seasonal indices ──────────────────────────────────────────────
349    // detrended[i] = y[i] − trend[i]
350    let detrended: Vec<f64> = y
351        .iter()
352        .zip(trend.iter())
353        .map(|(&yi, &ti)| yi - ti)
354        .collect();
355
356    // Average detrended values for each phase position.
357    let mut phase_sums = vec![0.0f64; p];
358    let mut phase_counts = vec![0u32; p];
359    for (i, &d) in detrended.iter().enumerate() {
360        let phase = i % p;
361        phase_sums[phase] += d;
362        phase_counts[phase] += 1;
363    }
364    let mut phase_means: Vec<f64> = phase_sums
365        .iter()
366        .zip(phase_counts.iter())
367        .map(|(&s, &c)| if c > 0 { s / c as f64 } else { 0.0 })
368        .collect();
369
370    // Centre seasonal indices so they sum to zero.
371    let phase_mean: f64 = phase_means.iter().sum::<f64>() / p as f64;
372    for v in &mut phase_means {
373        *v -= phase_mean;
374    }
375
376    let seasonal: Vec<f64> = (0..n).map(|i| phase_means[i % p]).collect();
377
378    // ── Step 3: Residual ──────────────────────────────────────────────────────
379    let residual: Vec<f64> = y
380        .iter()
381        .zip(trend.iter())
382        .zip(seasonal.iter())
383        .map(|((&yi, &ti), &si)| yi - ti - si)
384        .collect();
385
386    Some(DecomposedSeries {
387        trend,
388        seasonal,
389        residual,
390        observed: y,
391        period: p,
392    })
393}
394
395// ─── Exponential Moving Average ───────────────────────────────────────────────
396
397/// Smoothing factor and configuration for exponential moving average (EMA).
398///
399/// EMA: `EMA(0) = y(0)`, `EMA(i) = alpha * y(i) + (1 - alpha) * EMA(i-1)`.
400#[derive(Debug, Clone, PartialEq)]
401pub struct EmaConfig {
402    /// Smoothing factor `alpha ∈ (0.0, 1.0]`.
403    pub alpha: f64,
404}
405
406impl EmaConfig {
407    /// Build from explicit `alpha`. Returns `None` when `alpha ∉ (0.0, 1.0]`.
408    pub fn with_alpha(alpha: f64) -> Option<Self> {
409        if alpha > 0.0 && alpha <= 1.0 {
410            Some(Self { alpha })
411        } else {
412            None
413        }
414    }
415
416    /// Build from span N using `alpha = 2 / (N + 1)`. Returns `None` for span 0.
417    pub fn from_span(span: usize) -> Option<Self> {
418        if span == 0 {
419            return None;
420        }
421        Some(Self {
422            alpha: 2.0 / (span as f64 + 1.0),
423        })
424    }
425}
426
427impl Default for EmaConfig {
428    fn default() -> Self {
429        Self { alpha: 0.2 }
430    }
431}
432
433/// Result of an EMA computation over an engagement score time-series.
434#[derive(Debug, Clone)]
435pub struct EmaResult {
436    /// EMA-smoothed values aligned 1-to-1 with the input series.
437    pub smoothed: Vec<f64>,
438    /// The smoothing factor `alpha` applied.
439    pub alpha: f64,
440    /// Linear-regression slope of the smoothed series.
441    pub trend_slope: f64,
442}
443
444impl EmaResult {
445    /// Most recent smoothed value.
446    pub fn last_smoothed(&self) -> f64 {
447        self.smoothed.last().copied().unwrap_or(0.0)
448    }
449
450    /// First smoothed value (seeded from the first observation).
451    pub fn first_smoothed(&self) -> f64 {
452        self.smoothed.first().copied().unwrap_or(0.0)
453    }
454
455    /// Infer the trend direction from the EMA's slope.
456    pub fn trend_direction(&self, epsilon: f64) -> TrendDirection {
457        TrendDirection::from_slope(self.trend_slope, epsilon)
458    }
459}
460
461/// Trend direction inferred from slope analysis.
462#[derive(Debug, Clone, Copy, PartialEq, Eq)]
463pub enum TrendDirection {
464    /// Score is growing over time.
465    Growing,
466    /// Score is declining over time.
467    Declining,
468    /// No discernible trend.
469    Flat,
470}
471
472impl TrendDirection {
473    /// Classify a slope value.
474    pub fn from_slope(slope: f64, epsilon: f64) -> Self {
475        if slope > epsilon {
476            Self::Growing
477        } else if slope < -epsilon {
478            Self::Declining
479        } else {
480            Self::Flat
481        }
482    }
483}
484
485/// Compute the exponential moving average of an engagement score series.
486///
487/// Returns `None` when the series is empty or `alpha ∉ (0.0, 1.0]`.
488pub fn exponential_moving_average(series: &[(i64, f32)], config: &EmaConfig) -> Option<EmaResult> {
489    if series.is_empty() || config.alpha <= 0.0 || config.alpha > 1.0 {
490        return None;
491    }
492
493    let alpha = config.alpha;
494    let one_minus = 1.0 - alpha;
495
496    let mut smoothed = Vec::with_capacity(series.len());
497    let mut prev = f64::from(series[0].1);
498    smoothed.push(prev);
499
500    for &(_, y) in &series[1..] {
501        let ema = alpha * f64::from(y) + one_minus * prev;
502        smoothed.push(ema);
503        prev = ema;
504    }
505
506    let indexed: Vec<(i64, f32)> = smoothed
507        .iter()
508        .enumerate()
509        .map(|(i, &v)| (i as i64, v as f32))
510        .collect();
511    let trend_slope = f64::from(linear_regression_slope(&indexed));
512
513    Some(EmaResult {
514        smoothed,
515        alpha,
516        trend_slope,
517    })
518}
519
520// ─── Ranking ──────────────────────────────────────────────────────────────────
521
522/// Ranks and recommends content items by their engagement score.
523pub struct ContentRanker;
524
525impl ContentRanker {
526    /// Sort `scores` by engagement descending and return `(content_id, score)`
527    /// pairs.
528    pub fn rank_by_engagement<'a>(scores: &'a [ContentEngagementScore]) -> Vec<(&'a str, f32)> {
529        let mut ranked: Vec<_> = scores
530            .iter()
531            .map(|s| (s.content_id.as_str(), s.score))
532            .collect();
533        ranked.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
534        ranked
535    }
536}
537
538// ─── Tests ────────────────────────────────────────────────────────────────────
539
540#[cfg(test)]
541mod tests {
542    use super::*;
543    use crate::session::{PlaybackEvent, ViewerSession};
544
545    fn full_watch_session(id: &str, content_ms: u64) -> ViewerSession {
546        ViewerSession {
547            session_id: id.to_string(),
548            user_id: None,
549            content_id: "content_a".to_string(),
550            started_at_ms: 0,
551            events: vec![
552                PlaybackEvent::Play { timestamp_ms: 0 },
553                PlaybackEvent::End {
554                    position_ms: content_ms,
555                    watch_duration_ms: content_ms,
556                },
557            ],
558        }
559    }
560
561    fn partial_watch_session(id: &str, watch_ms: u64, _content_ms: u64) -> ViewerSession {
562        ViewerSession {
563            session_id: id.to_string(),
564            user_id: None,
565            content_id: "content_a".to_string(),
566            started_at_ms: 0,
567            events: vec![
568                PlaybackEvent::Play { timestamp_ms: 0 },
569                PlaybackEvent::End {
570                    position_ms: watch_ms,
571                    watch_duration_ms: watch_ms,
572                },
573            ],
574        }
575    }
576
577    fn session_with_forward_seek(id: &str, content_ms: u64) -> ViewerSession {
578        ViewerSession {
579            session_id: id.to_string(),
580            user_id: None,
581            content_id: "content_a".to_string(),
582            started_at_ms: 0,
583            events: vec![
584                PlaybackEvent::Play { timestamp_ms: 0 },
585                PlaybackEvent::Seek {
586                    from_ms: 3000,
587                    to_ms: 7000,
588                },
589                PlaybackEvent::End {
590                    position_ms: content_ms,
591                    watch_duration_ms: content_ms / 2,
592                },
593            ],
594        }
595    }
596
597    fn session_with_backward_seek(id: &str, content_ms: u64) -> ViewerSession {
598        ViewerSession {
599            session_id: id.to_string(),
600            user_id: None,
601            content_id: "content_a".to_string(),
602            started_at_ms: 0,
603            events: vec![
604                PlaybackEvent::Play { timestamp_ms: 0 },
605                PlaybackEvent::Seek {
606                    from_ms: 7000,
607                    to_ms: 3000,
608                },
609                PlaybackEvent::End {
610                    position_ms: content_ms,
611                    watch_duration_ms: content_ms,
612                },
613            ],
614        }
615    }
616
617    // ── compute_engagement ───────────────────────────────────────────────────
618
619    #[test]
620    fn engagement_empty_sessions() {
621        let weights = EngagementWeights::default();
622        let score = compute_engagement(&[], 10_000, &weights);
623        assert_eq!(score.score, 0.0);
624    }
625
626    #[test]
627    fn engagement_zero_duration() {
628        let sessions = vec![full_watch_session("s1", 10_000)];
629        let weights = EngagementWeights::default();
630        let score = compute_engagement(&sessions, 0, &weights);
631        assert_eq!(score.score, 0.0);
632    }
633
634    #[test]
635    fn engagement_full_watch_high_score() {
636        let sessions: Vec<_> = (0..10)
637            .map(|i| full_watch_session(&format!("s{i}"), 10_000))
638            .collect();
639        let weights = EngagementWeights::default();
640        let score = compute_engagement(&sessions, 10_000, &weights);
641        // watch_time=1.0, completion=1.0, rewatch=0.0, social=0.5, penalty=0.0
642        // = 0.2*1 + 0.2*1 + 0.2*0 + 0.2*0.5 - 0.2*0 = 0.5
643        assert!((score.score - 0.5).abs() < 0.05, "score={}", score.score);
644    }
645
646    #[test]
647    fn engagement_partial_watch_lower_score() {
648        let sessions: Vec<_> = (0..10)
649            .map(|i| partial_watch_session(&format!("s{i}"), 3_000, 10_000))
650            .collect();
651        let weights = EngagementWeights::default();
652        let full = compute_engagement(
653            &(0..10)
654                .map(|i| full_watch_session(&format!("s{i}"), 10_000))
655                .collect::<Vec<_>>(),
656            10_000,
657            &weights,
658        );
659        let partial = compute_engagement(&sessions, 10_000, &weights);
660        assert!(
661            partial.score < full.score,
662            "partial={} full={}",
663            partial.score,
664            full.score
665        );
666    }
667
668    #[test]
669    fn engagement_components_watch_time_capped() {
670        // Watch time = 2x content duration → capped at 1.0.
671        let sessions = vec![partial_watch_session("s1", 20_000, 10_000)];
672        let weights = EngagementWeights::default();
673        let score = compute_engagement(&sessions, 10_000, &weights);
674        assert!(score.components.watch_time_score <= 1.0);
675    }
676
677    #[test]
678    fn engagement_rewatch_detected() {
679        let sessions = vec![session_with_backward_seek("s1", 10_000)];
680        let weights = EngagementWeights::default();
681        let score = compute_engagement(&sessions, 10_000, &weights);
682        assert!((score.components.rewatch_score - 1.0).abs() < 1e-6);
683    }
684
685    #[test]
686    fn engagement_forward_seek_penalty() {
687        let no_seek: Vec<_> = (0..5)
688            .map(|i| full_watch_session(&format!("s{i}"), 10_000))
689            .collect();
690        let with_seek: Vec<_> = (0..5)
691            .map(|i| session_with_forward_seek(&format!("s{i}"), 10_000))
692            .collect();
693        let weights = EngagementWeights::default();
694        let score_clean = compute_engagement(&no_seek, 10_000, &weights);
695        let score_seeky = compute_engagement(&with_seek, 10_000, &weights);
696        assert!(
697            score_seeky.score <= score_clean.score,
698            "seeky={} clean={}",
699            score_seeky.score,
700            score_clean.score
701        );
702    }
703
704    #[test]
705    fn engagement_social_score_placeholder() {
706        let sessions = vec![full_watch_session("s1", 5_000)];
707        let weights = EngagementWeights::default();
708        let score = compute_engagement(&sessions, 5_000, &weights);
709        assert!((score.components.social_score - 0.5).abs() < 1e-6);
710    }
711
712    #[test]
713    fn engagement_content_id_from_first_session() {
714        let sessions = vec![full_watch_session("s1", 10_000)];
715        let weights = EngagementWeights::default();
716        let score = compute_engagement(&sessions, 10_000, &weights);
717        assert_eq!(score.content_id, "content_a");
718    }
719
720    #[test]
721    fn engagement_weights_default_sum_to_one() {
722        let w = EngagementWeights::default();
723        let sum = w.watch_time + w.completion + w.rewatch + w.social + w.forward_seek_penalty;
724        assert!((sum - 1.0).abs() < 1e-6);
725    }
726
727    // ── linear_regression_slope ──────────────────────────────────────────────
728
729    #[test]
730    fn slope_perfectly_increasing() {
731        // y = x (in tiny units): (0,0.0),(1,1.0),(2,2.0),(3,3.0)
732        let points = vec![(0i64, 0.0f32), (1, 1.0), (2, 2.0), (3, 3.0)];
733        let slope = linear_regression_slope(&points);
734        assert!((slope - 1.0).abs() < 1e-4, "slope={slope}");
735    }
736
737    #[test]
738    fn slope_perfectly_decreasing() {
739        let points = vec![(0i64, 3.0f32), (1, 2.0), (2, 1.0), (3, 0.0)];
740        let slope = linear_regression_slope(&points);
741        assert!((slope + 1.0).abs() < 1e-4, "slope={slope}");
742    }
743
744    #[test]
745    fn slope_flat() {
746        let points = vec![(0i64, 0.5f32), (1, 0.5), (2, 0.5), (3, 0.5)];
747        let slope = linear_regression_slope(&points);
748        assert!(slope.abs() < 1e-6, "slope={slope}");
749    }
750
751    #[test]
752    fn slope_single_point_returns_zero() {
753        let points = vec![(100i64, 0.8f32)];
754        assert_eq!(linear_regression_slope(&points), 0.0);
755    }
756
757    #[test]
758    fn slope_two_points() {
759        let points = vec![(0i64, 0.0f32), (10, 1.0)];
760        let slope = linear_regression_slope(&points);
761        assert!((slope - 0.1).abs() < 1e-5, "slope={slope}");
762    }
763
764    #[test]
765    fn engagement_trend_slope_method() {
766        let trend = EngagementTrend {
767            scores_over_time: vec![(0, 0.3), (1_000, 0.6), (2_000, 0.9)],
768        };
769        let slope = trend.slope();
770        assert!(slope > 0.0, "expected positive slope, got {slope}");
771    }
772
773    // ── ContentRanker ────────────────────────────────────────────────────────
774
775    #[test]
776    fn ranker_sorted_descending() {
777        let scores = vec![
778            ContentEngagementScore {
779                content_id: "a".to_string(),
780                score: 0.4,
781                components: EngagementComponents {
782                    watch_time_score: 0.4,
783                    completion_score: 0.4,
784                    rewatch_score: 0.0,
785                    social_score: 0.5,
786                    seek_forward_penalty: 0.0,
787                },
788            },
789            ContentEngagementScore {
790                content_id: "b".to_string(),
791                score: 0.9,
792                components: EngagementComponents {
793                    watch_time_score: 0.9,
794                    completion_score: 0.9,
795                    rewatch_score: 0.1,
796                    social_score: 0.5,
797                    seek_forward_penalty: 0.0,
798                },
799            },
800            ContentEngagementScore {
801                content_id: "c".to_string(),
802                score: 0.6,
803                components: EngagementComponents {
804                    watch_time_score: 0.6,
805                    completion_score: 0.6,
806                    rewatch_score: 0.0,
807                    social_score: 0.5,
808                    seek_forward_penalty: 0.0,
809                },
810            },
811        ];
812        let ranked = ContentRanker::rank_by_engagement(&scores);
813        assert_eq!(ranked[0].0, "b");
814        assert_eq!(ranked[1].0, "c");
815        assert_eq!(ranked[2].0, "a");
816    }
817
818    #[test]
819    fn ranker_empty_input() {
820        let ranked = ContentRanker::rank_by_engagement(&[]);
821        assert!(ranked.is_empty());
822    }
823
824    #[test]
825    fn ranker_single_item() {
826        let scores = vec![ContentEngagementScore {
827            content_id: "only".to_string(),
828            score: 0.7,
829            components: EngagementComponents {
830                watch_time_score: 0.7,
831                completion_score: 0.7,
832                rewatch_score: 0.0,
833                social_score: 0.5,
834                seek_forward_penalty: 0.0,
835            },
836        }];
837        let ranked = ContentRanker::rank_by_engagement(&scores);
838        assert_eq!(ranked.len(), 1);
839        assert_eq!(ranked[0].0, "only");
840    }
841
842    // ── EMA tests ────────────────────────────────────────────────────────────
843
844    #[test]
845    fn ema_empty_series_returns_none() {
846        assert!(exponential_moving_average(&[], &EmaConfig::default()).is_none());
847    }
848
849    #[test]
850    fn ema_alpha_one_equals_original_series() {
851        // alpha=1.0 → EMA(i) = y(i).
852        let config = EmaConfig::with_alpha(1.0).expect("valid");
853        let series = vec![(0i64, 0.1f32), (1, 0.5), (2, 0.9), (3, 0.3)];
854        let result = exponential_moving_average(&series, &config).expect("result");
855        assert_eq!(result.smoothed.len(), series.len());
856        for (i, &(_, y)) in series.iter().enumerate() {
857            // f64::from(f32) then back: use 1e-6 tolerance for f32 → f64 conversion.
858            assert!(
859                (result.smoothed[i] - f64::from(y)).abs() < 1e-6,
860                "index {i}: ema={} y={}",
861                result.smoothed[i],
862                y
863            );
864        }
865    }
866
867    #[test]
868    fn ema_smooths_noisy_signal() {
869        let series: Vec<(i64, f32)> = (0i64..20)
870            .map(|i| (i, if i % 2 == 0 { 0.9 } else { 0.1 }))
871            .collect();
872        let config = EmaConfig::from_span(5).expect("valid span");
873        let result = exponential_moving_average(&series, &config).expect("result");
874        let last = result.last_smoothed();
875        assert!(
876            last > 0.2 && last < 0.8,
877            "smoothed last={last} should be near 0.5"
878        );
879    }
880
881    #[test]
882    fn ema_seeded_with_first_observation() {
883        // seed = y(0) = 0.7; second EMA = 0.5 * 0.1 + 0.5 * 0.7 = 0.4
884        let series = vec![(0i64, 0.7f32), (1, 0.1)];
885        let config = EmaConfig::with_alpha(0.5).expect("valid");
886        let result = exponential_moving_average(&series, &config).expect("result");
887        // f64::from(0.7f32) is ~0.699999988; use 1e-6 tolerance.
888        assert!(
889            (result.first_smoothed() - f64::from(0.7f32)).abs() < 1e-9,
890            "first_smoothed={} expected {}",
891            result.first_smoothed(),
892            f64::from(0.7f32)
893        );
894        // EMA[1] = 0.5 * f64::from(0.1f32) + 0.5 * f64::from(0.7f32)
895        let expected = 0.5 * f64::from(0.1f32) + 0.5 * f64::from(0.7f32);
896        assert!(
897            (result.smoothed[1] - expected).abs() < 1e-9,
898            "smoothed[1]={} expected {expected}",
899            result.smoothed[1]
900        );
901    }
902
903    #[test]
904    fn ema_from_span_produces_valid_alpha() {
905        let config = EmaConfig::from_span(9).expect("valid");
906        assert!((config.alpha - 0.2).abs() < 1e-12);
907    }
908
909    #[test]
910    fn ema_from_span_zero_returns_none() {
911        assert!(EmaConfig::from_span(0).is_none());
912    }
913
914    #[test]
915    fn ema_with_invalid_alpha_returns_none() {
916        assert!(EmaConfig::with_alpha(0.0).is_none());
917        assert!(EmaConfig::with_alpha(-0.1).is_none());
918        assert!(EmaConfig::with_alpha(1.1).is_none());
919    }
920
921    #[test]
922    fn ema_trend_slope_positive_for_growing_series() {
923        let series: Vec<(i64, f32)> = (0i64..10).map(|i| (i, i as f32 * 0.1)).collect();
924        let config = EmaConfig::with_alpha(0.3).expect("valid");
925        let result = exponential_moving_average(&series, &config).expect("result");
926        assert!(result.trend_slope > 0.0, "slope={}", result.trend_slope);
927        assert_eq!(result.trend_direction(1e-6), TrendDirection::Growing);
928    }
929
930    #[test]
931    fn ema_trend_direction_declining() {
932        let series: Vec<(i64, f32)> = (0i64..10).map(|i| (i, 1.0f32 - i as f32 * 0.1)).collect();
933        let config = EmaConfig::with_alpha(0.3).expect("valid");
934        let result = exponential_moving_average(&series, &config).expect("result");
935        assert_eq!(result.trend_direction(1e-6), TrendDirection::Declining);
936    }
937
938    #[test]
939    fn ema_trend_direction_flat_for_constant_series() {
940        let series: Vec<(i64, f32)> = (0i64..10).map(|i| (i, 0.5f32)).collect();
941        let config = EmaConfig::with_alpha(0.3).expect("valid");
942        let result = exponential_moving_average(&series, &config).expect("result");
943        assert_eq!(result.trend_direction(1e-6), TrendDirection::Flat);
944    }
945
946    #[test]
947    fn ema_result_alpha_stored_correctly() {
948        let series = vec![(0i64, 0.5f32), (1, 0.6)];
949        let config = EmaConfig::with_alpha(0.4).expect("valid");
950        let result = exponential_moving_average(&series, &config).expect("result");
951        assert!((result.alpha - 0.4).abs() < 1e-12);
952    }
953}