Skip to main content

dsfb_debug/
episode_catalog.rs

1//! DSFB-Debug: episode catalog — institutional memory for debugging
2//! episodes.
3//!
4//! Each closed `DebugEpisode` is recorded into a fixed-size circular
5//! buffer. New episodes can be matched against history via
6//! signature-vector cosine similarity, surfacing past episodes whose
7//! structural shape matches the current one.
8//!
9//! # Operator use case
10//!
11//! At 3am, a `MotifClass::CascadingTimeoutSlew` fires. The catalog
12//! lookup says: "similar to episode #4173 from 23 days ago,
13//! similarity 0.91, peak_slew 0.83 vs current 0.85, contributing
14//! services were the same set". The operator immediately reaches
15//! for the past resolution (e.g. "increased connection pool to 200,
16//! see runbook PR #2418") rather than re-discovering the diagnosis.
17//!
18//! Per panellist P12 (Distributed-Systems Architect, Session 6):
19//! "questions 5 and 7 of the on-call questions are 'have we seen
20//! this before?' and 'what should I investigate next?'. The catalog
21//! is the answer to both." This module is the answer.
22//!
23//! # no_std + no_alloc — Copy slots
24//!
25//! The catalog is a fixed-size `[Option<DebugEpisode>; N]` array.
26//! Older episodes are overwritten when the buffer wraps. Lookup is
27//! O(N) linear scan. Suitable for embedded deployments (no heap)
28//! and audit-grade determinism (no time-dependent state — the same
29//! `record` history produces the same `find_similar` output).
30//!
31//! For audit-grade durability across process restarts, the operator
32//! should periodically serialise the catalog to a write-ahead log
33//! (see `docs/operator_handbook.md` §Episode Catalog Persistence).
34
35use crate::types::*;
36
37/// Fixed-size circular buffer of past episodes.
38///
39/// `N` is the catalog capacity. Older episodes are overwritten when
40/// the buffer wraps. For audit-grade durability, persist the catalog
41/// to disk between runs (see `docs/operator_handbook.md`).
42pub struct EpisodeCatalog<const N: usize> {
43    entries: [Option<DebugEpisode>; N],
44    write_head: usize,
45    total_recorded: u64,
46}
47
48/// Result of a catalog similarity lookup.
49#[derive(Copy, Clone, Debug, PartialEq)]
50pub struct SimilarEpisode {
51    /// Index into the catalog (0..N). Stable for as long as the
52    /// catalog has not wrapped past this entry.
53    pub catalog_index: usize,
54    /// The matched past episode.
55    pub past_episode: DebugEpisode,
56    /// Similarity score in `[0.0, 1.0]`. 1.0 = identical signature
57    /// vector (motif, drift direction, peak slew, duration,
58    /// contributing-signal-count rounded to nearest); 0.0 = nothing
59    /// in common. Cosine-style metric over a 5-element feature vector.
60    pub similarity: f64,
61}
62
63impl<const N: usize> EpisodeCatalog<N> {
64    /// Construct an empty catalog.
65    pub fn new() -> Self {
66        Self {
67            entries: [None; N],
68            write_head: 0,
69            total_recorded: 0,
70        }
71    }
72
73    /// Append an episode to the catalog. Wraps when full.
74    pub fn record(&mut self, ep: DebugEpisode) {
75        if N == 0 {
76            return;
77        }
78        self.entries[self.write_head] = Some(ep);
79        self.write_head = (self.write_head + 1) % N;
80        self.total_recorded += 1;
81    }
82
83    /// Total episodes recorded since construction (including any
84    /// overwritten by buffer wrap). Useful for audit logs.
85    pub fn total_recorded(&self) -> u64 {
86        self.total_recorded
87    }
88
89    /// Number of entries currently stored (capped at N).
90    pub fn len(&self) -> usize {
91        if self.total_recorded as usize > N {
92            N
93        } else {
94            self.total_recorded as usize
95        }
96    }
97
98    /// True iff no episodes have been recorded.
99    pub fn is_empty(&self) -> bool {
100        self.total_recorded == 0
101    }
102
103    /// Find the most-similar past episode to `query`.
104    ///
105    /// Returns `None` if the catalog is empty or no past entry has
106    /// non-zero similarity. Tie-broken by lower catalog index for
107    /// determinism.
108    pub fn find_similar(&self, query: &DebugEpisode) -> Option<SimilarEpisode> {
109        let mut best: Option<SimilarEpisode> = None;
110        let mut i = 0;
111        while i < N {
112            if let Some(past) = self.entries[i] {
113                let s = signature_similarity(query, &past);
114                let take = match best {
115                    None => s > 0.0,
116                    Some(b) => s > b.similarity,
117                };
118                if take {
119                    best = Some(SimilarEpisode {
120                        catalog_index: i,
121                        past_episode: past,
122                        similarity: s,
123                    });
124                }
125            }
126            i += 1;
127        }
128        best
129    }
130}
131
132impl<const N: usize> Default for EpisodeCatalog<N> {
133    fn default() -> Self {
134        Self::new()
135    }
136}
137
138/// Signature similarity in `[0.0, 1.0]`. Five-feature comparison:
139///   1. Motif equality (binary 1.0 / 0.0 — strongest signal)
140///   2. Reason code equality (binary)
141///   3. Drift-direction equality (binary)
142///   4. Peak slew magnitude proximity (1 - |Δ| / max)
143///   5. Duration proximity (1 - |Δlog| / max)
144/// Combined as weighted sum normalised to [0, 1]. Weights: motif 0.40,
145/// reason 0.20, drift_dir 0.10, slew 0.15, duration 0.15.
146#[inline]
147fn signature_similarity(a: &DebugEpisode, b: &DebugEpisode) -> f64 {
148    let motif_match = match (a.matched_motif, b.matched_motif) {
149        (SemanticDisposition::Named(m1), SemanticDisposition::Named(m2)) => {
150            if m1 == m2 { 1.0 } else { 0.0 }
151        }
152        (SemanticDisposition::Unknown, SemanticDisposition::Unknown) => 0.5,
153        _ => 0.0,
154    };
155    let reason_match = if a.primary_reason_code == b.primary_reason_code { 1.0 } else { 0.0 };
156    let drift_match = if a.structural_signature.dominant_drift_direction
157        == b.structural_signature.dominant_drift_direction { 1.0 } else { 0.0 };
158
159    let slew_a = abs_f64(a.structural_signature.peak_slew_magnitude);
160    let slew_b = abs_f64(b.structural_signature.peak_slew_magnitude);
161    let slew_max = if slew_a > slew_b { slew_a } else { slew_b };
162    let slew_proximity = if slew_max > 0.0 {
163        let denom = if slew_max > 1e-9 { slew_max } else { 1e-9 };
164        1.0 - abs_f64(slew_a - slew_b) / denom
165    } else {
166        1.0
167    };
168
169    let dur_a_raw = a.structural_signature.duration_windows;
170    let dur_b_raw = b.structural_signature.duration_windows;
171    let dur_a = if dur_a_raw > 1 { dur_a_raw } else { 1 } as f64;
172    let dur_b = if dur_b_raw > 1 { dur_b_raw } else { 1 } as f64;
173    let dur_proximity = 1.0 - abs_f64(log2_approx(dur_a) - log2_approx(dur_b)) / 8.0;
174    let dur_proximity = clamp01_f64(dur_proximity);
175
176    let raw: f64 = 0.40 * motif_match
177        + 0.20 * reason_match
178        + 0.10 * drift_match
179        + 0.15 * slew_proximity
180        + 0.15 * dur_proximity;
181    clamp01_f64(raw)
182}
183
184#[inline]
185fn abs_f64(x: f64) -> f64 {
186    if x >= 0.0 { x } else { -x }
187}
188
189#[inline]
190fn clamp01_f64(x: f64) -> f64 {
191    if x < 0.0 { 0.0 } else if x > 1.0 { 1.0 } else { x }
192}
193
194/// `log2(x)` approximation for x > 0 — Newton-Raphson-free bit-trick.
195/// We don't have access to f64::log2 in no_std; use a quick coarse
196/// approximation good enough for the duration-similarity feature
197/// (which only needs ~8-bin granularity).
198#[inline]
199fn log2_approx(x: f64) -> f64 {
200    if x <= 0.0 {
201        return 0.0;
202    }
203    // For x in [1, 1<<63], the integer floor(log2(x)) equals
204    // 63 - leading_zeros(x). For fractional refinement, linearly
205    // interpolate within the bin.
206    let bits = x.to_bits();
207    let exp = ((bits >> 52) & 0x7FF) as i32 - 1023;
208    let mantissa = (bits & ((1u64 << 52) - 1)) as f64 / (1u64 << 52) as f64;
209    exp as f64 + mantissa
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215
216    fn ep(motif: MotifClass, slew: f64, duration: u64, contrib: u16) -> DebugEpisode {
217        DebugEpisode {
218            episode_id: 0,
219            start_window: 0,
220            end_window: duration - 1,
221            peak_grammar_state: GrammarState::Boundary,
222            primary_reason_code: ReasonCode::AbruptSlewViolation,
223            matched_motif: SemanticDisposition::Named(motif),
224            policy_state: PolicyState::Review,
225            contributing_signal_count: contrib,
226            structural_signature: StructuralSignature {
227                dominant_drift_direction: DriftDirection::Positive,
228                peak_slew_magnitude: slew,
229                duration_windows: duration,
230                signal_correlation: contrib as f64 / 8.0,
231            },
232            root_cause_signal_index: None,
233        }
234    }
235
236    #[test]
237    fn empty_catalog_returns_none() {
238        let cat = EpisodeCatalog::<8>::new();
239        let q = ep(MotifClass::CascadingTimeoutSlew, 0.5, 10, 3);
240        assert!(cat.find_similar(&q).is_none());
241        assert!(cat.is_empty());
242    }
243
244    #[test]
245    fn identical_episode_yields_high_similarity() {
246        let mut cat = EpisodeCatalog::<8>::new();
247        let past = ep(MotifClass::CascadingTimeoutSlew, 0.85, 12, 4);
248        cat.record(past);
249        let query = ep(MotifClass::CascadingTimeoutSlew, 0.85, 12, 4);
250        let sim = cat.find_similar(&query).expect("should find a match");
251        assert!(sim.similarity > 0.9,
252                "identical signature should yield similarity > 0.9, got {}",
253                sim.similarity);
254    }
255
256    #[test]
257    fn different_motif_yields_low_similarity() {
258        let mut cat = EpisodeCatalog::<8>::new();
259        cat.record(ep(MotifClass::CascadingTimeoutSlew, 0.5, 10, 3));
260        let query = ep(MotifClass::DeploymentRegressionSlew, 0.5, 10, 3);
261        // Reason matches (both AbruptSlewViolation) but motif differs.
262        let sim = cat.find_similar(&query).expect("should find a match");
263        // motif=0 (0.40) + reason=1 (0.20) + drift=1 (0.10) + slew=1 (0.15) + dur=1 (0.15) = 0.60
264        assert!(sim.similarity < 0.7,
265                "different motif should yield similarity < 0.7, got {}",
266                sim.similarity);
267    }
268
269    #[test]
270    fn deterministic_lookup_across_calls() {
271        let mut cat = EpisodeCatalog::<8>::new();
272        cat.record(ep(MotifClass::MemoryLeakDrift, 0.3, 50, 2));
273        cat.record(ep(MotifClass::CascadingTimeoutSlew, 0.8, 8, 4));
274        cat.record(ep(MotifClass::DeploymentRegressionSlew, 0.9, 5, 1));
275        let q = ep(MotifClass::CascadingTimeoutSlew, 0.7, 10, 3);
276        let s1 = cat.find_similar(&q);
277        let s2 = cat.find_similar(&q);
278        assert_eq!(s1, s2, "find_similar must be deterministic");
279    }
280
281    #[test]
282    fn circular_buffer_wraps() {
283        let mut cat = EpisodeCatalog::<3>::new();
284        for i in 0..5 {
285            let mut e = ep(MotifClass::MemoryLeakDrift, 0.5, 10, 2);
286            e.episode_id = i as u32;
287            cat.record(e);
288        }
289        assert_eq!(cat.total_recorded(), 5);
290        assert_eq!(cat.len(), 3, "buffer should be full at capacity");
291    }
292
293    #[test]
294    fn unknown_motif_pair_partial_similarity() {
295        let mut cat = EpisodeCatalog::<8>::new();
296        let mut past = ep(MotifClass::CascadingTimeoutSlew, 0.5, 10, 3);
297        past.matched_motif = SemanticDisposition::Unknown;
298        cat.record(past);
299        let mut query = ep(MotifClass::CascadingTimeoutSlew, 0.5, 10, 3);
300        query.matched_motif = SemanticDisposition::Unknown;
301        let sim = cat.find_similar(&query).expect("should find a match");
302        // Unknown-Unknown = 0.5 motif weight; reason+drift+slew+dur = 0.6
303        assert!(sim.similarity > 0.7,
304                "unknown-unknown structural twins should still match well");
305    }
306}