1use crate::session::{build_playback_map, PlaybackEvent, ViewerSession};
7
8#[derive(Debug, Clone, PartialEq)]
12pub struct EngagementComponents {
13 pub watch_time_score: f32,
15 pub completion_score: f32,
17 pub rewatch_score: f32,
19 pub social_score: f32,
22 pub seek_forward_penalty: f32,
24}
25
26#[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 pub forward_seek_penalty: f32,
37}
38
39impl EngagementWeights {
40 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#[derive(Debug, Clone, PartialEq)]
54pub struct ContentEngagementScore {
55 pub content_id: String,
56 pub score: f32,
58 pub components: EngagementComponents,
59}
60
61pub 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 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 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 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 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; 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 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#[derive(Debug, Clone)]
187pub struct EngagementTrend {
188 pub scores_over_time: Vec<(i64, f32)>,
190}
191
192impl EngagementTrend {
193 pub fn slope(&self) -> f32 {
198 linear_regression_slope(&self.scores_over_time)
199 }
200}
201
202pub fn linear_regression_slope(points: &[(i64, f32)]) -> f32 {
209 let n = points.len();
210 if n < 2 {
211 return 0.0;
212 }
213
214 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
242pub enum SeasonalPeriod {
243 Weekly,
245 Monthly,
247 Custom(usize),
249}
250
251impl SeasonalPeriod {
252 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#[derive(Debug, Clone)]
266pub struct DecomposedSeries {
267 pub trend: Vec<f64>,
269 pub seasonal: Vec<f64>,
271 pub residual: Vec<f64>,
273 pub observed: Vec<f64>,
275 pub period: usize,
277}
278
279pub 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 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 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 let mut start = None;
331 for i in 0..n {
332 if !trend[i].is_nan() {
333 if let Some(s) = start {
334 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 let detrended: Vec<f64> = y
351 .iter()
352 .zip(trend.iter())
353 .map(|(&yi, &ti)| yi - ti)
354 .collect();
355
356 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 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 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#[derive(Debug, Clone, PartialEq)]
401pub struct EmaConfig {
402 pub alpha: f64,
404}
405
406impl EmaConfig {
407 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 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#[derive(Debug, Clone)]
435pub struct EmaResult {
436 pub smoothed: Vec<f64>,
438 pub alpha: f64,
440 pub trend_slope: f64,
442}
443
444impl EmaResult {
445 pub fn last_smoothed(&self) -> f64 {
447 self.smoothed.last().copied().unwrap_or(0.0)
448 }
449
450 pub fn first_smoothed(&self) -> f64 {
452 self.smoothed.first().copied().unwrap_or(0.0)
453 }
454
455 pub fn trend_direction(&self, epsilon: f64) -> TrendDirection {
457 TrendDirection::from_slope(self.trend_slope, epsilon)
458 }
459}
460
461#[derive(Debug, Clone, Copy, PartialEq, Eq)]
463pub enum TrendDirection {
464 Growing,
466 Declining,
468 Flat,
470}
471
472impl TrendDirection {
473 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
485pub 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
520pub struct ContentRanker;
524
525impl ContentRanker {
526 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#[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 #[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 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 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 #[test]
730 fn slope_perfectly_increasing() {
731 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 #[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 #[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 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 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 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 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 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}