Skip to main content

dsfb_gray/
episode.rs

1//! Episode formation: the operator-facing output object.
2//!
3//! An [`Episode`] represents a contiguous period during which the grammar
4//! state held a specific classification. Episodes are the primary output
5//! consumed by operators, dashboards, and automated response systems.
6
7use crate::grammar::GrammarState;
8use crate::residual::ResidualSource;
9use crate::ReasonCode;
10
11/// A structural episode: a contiguous period of a specific grammar state.
12///
13/// This is the operator-facing output of the DSFB engine. Each episode
14/// carries the full audit context needed to understand why the classification
15/// was made and what structural pattern was detected.
16#[derive(Debug, Clone)]
17pub struct Episode {
18    /// Monotonic timestamp when this episode began (nanoseconds).
19    pub start_ts: u64,
20    /// Monotonic timestamp when this episode ended (nanoseconds).
21    /// `None` if the episode is still open.
22    pub end_ts: Option<u64>,
23    /// Grammar state during this episode.
24    pub grammar_state: GrammarState,
25    /// Reason code from the heuristics bank match.
26    pub reason_code: ReasonCode,
27    /// Which residual source(s) contributed to this classification.
28    pub primary_source: ResidualSource,
29    /// Maximum absolute drift observed during this episode.
30    pub max_drift: f64,
31    /// Maximum absolute slew observed during this episode.
32    pub max_slew: f64,
33    /// Maximum absolute residual observed during this episode.
34    pub max_residual: f64,
35    /// Number of observation samples in this episode.
36    pub sample_count: u32,
37}
38
39impl Episode {
40    /// Duration of this episode in nanoseconds, or `None` if still open.
41    pub fn duration_ns(&self) -> Option<u64> {
42        self.end_ts.map(|end| end.saturating_sub(self.start_ts))
43    }
44
45    /// Whether this episode is still open (no end timestamp).
46    pub fn is_open(&self) -> bool {
47        self.end_ts.is_none()
48    }
49
50    /// Whether this episode represents a structural anomaly
51    /// (Boundary or Violation).
52    pub fn is_anomalous(&self) -> bool {
53        matches!(
54            self.grammar_state,
55            GrammarState::Boundary | GrammarState::Violation
56        )
57    }
58}
59
60/// Builder for constructing episodes incrementally as observations arrive.
61pub struct EpisodeBuilder {
62    current: Option<Episode>,
63}
64
65impl EpisodeBuilder {
66    /// Create a new episode builder.
67    pub fn new() -> Self {
68        Self { current: None }
69    }
70
71    /// Open a new episode at the given timestamp with the given state.
72    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    /// Update the current open episode with a new observation.
93    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    /// Close the current episode and return it.
103    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    /// Whether an episode is currently open.
113    pub fn is_open(&self) -> bool {
114        self.current.is_some()
115    }
116
117    /// Reference to the current open episode, if any.
118    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}