1use crate::grammar::GrammarState;
8use crate::residual::ResidualSource;
9use crate::ReasonCode;
10
11#[derive(Debug, Clone)]
17pub struct Episode {
18 pub start_ts: u64,
20 pub end_ts: Option<u64>,
23 pub grammar_state: GrammarState,
25 pub reason_code: ReasonCode,
27 pub primary_source: ResidualSource,
29 pub max_drift: f64,
31 pub max_slew: f64,
33 pub max_residual: f64,
35 pub sample_count: u32,
37}
38
39impl Episode {
40 pub fn duration_ns(&self) -> Option<u64> {
42 self.end_ts.map(|end| end.saturating_sub(self.start_ts))
43 }
44
45 pub fn is_open(&self) -> bool {
47 self.end_ts.is_none()
48 }
49
50 pub fn is_anomalous(&self) -> bool {
53 matches!(
54 self.grammar_state,
55 GrammarState::Boundary | GrammarState::Violation
56 )
57 }
58}
59
60pub struct EpisodeBuilder {
62 current: Option<Episode>,
63}
64
65impl EpisodeBuilder {
66 pub fn new() -> Self {
68 Self { current: None }
69 }
70
71 pub fn open(
73 &mut self,
74 timestamp_ns: u64,
75 grammar_state: GrammarState,
76 reason_code: ReasonCode,
77 source: ResidualSource,
78 ) {
79 self.current = Some(Episode {
80 start_ts: timestamp_ns,
81 end_ts: None,
82 grammar_state,
83 reason_code,
84 primary_source: source,
85 max_drift: 0.0,
86 max_slew: 0.0,
87 max_residual: 0.0,
88 sample_count: 0,
89 });
90 }
91
92 pub fn update(&mut self, residual: f64, drift: f64, slew: f64) {
94 if let Some(ref mut ep) = self.current {
95 ep.max_drift = ep.max_drift.max(drift.abs());
96 ep.max_slew = ep.max_slew.max(slew.abs());
97 ep.max_residual = ep.max_residual.max(residual.abs());
98 ep.sample_count += 1;
99 }
100 }
101
102 pub fn close(&mut self, timestamp_ns: u64) -> Option<Episode> {
104 if let Some(mut ep) = self.current.take() {
105 ep.end_ts = Some(timestamp_ns);
106 Some(ep)
107 } else {
108 None
109 }
110 }
111
112 pub fn is_open(&self) -> bool {
114 self.current.is_some()
115 }
116
117 pub fn current(&self) -> Option<&Episode> {
119 self.current.as_ref()
120 }
121}
122
123impl Default for EpisodeBuilder {
124 fn default() -> Self {
125 Self::new()
126 }
127}
128
129#[cfg(test)]
130mod tests {
131 use super::*;
132
133 #[test]
134 fn test_episode_lifecycle() {
135 let mut builder = EpisodeBuilder::new();
136 assert!(!builder.is_open());
137
138 builder.open(
139 1000,
140 GrammarState::Boundary,
141 ReasonCode::SustainedLatencyDrift,
142 ResidualSource::Latency,
143 );
144 assert!(builder.is_open());
145
146 builder.update(5.0, 0.3, 0.01);
147 builder.update(6.0, 0.4, 0.02);
148
149 let ep = builder.close(3000).unwrap();
150 assert_eq!(ep.start_ts, 1000);
151 assert_eq!(ep.end_ts, Some(3000));
152 assert_eq!(ep.grammar_state, GrammarState::Boundary);
153 assert_eq!(ep.sample_count, 2);
154 assert!((ep.max_drift - 0.4).abs() < 1e-10);
155 assert!(ep.is_anomalous());
156 }
157}