1use crate::types::*;
36
37pub struct EpisodeCatalog<const N: usize> {
43 entries: [Option<DebugEpisode>; N],
44 write_head: usize,
45 total_recorded: u64,
46}
47
48#[derive(Copy, Clone, Debug, PartialEq)]
50pub struct SimilarEpisode {
51 pub catalog_index: usize,
54 pub past_episode: DebugEpisode,
56 pub similarity: f64,
61}
62
63impl<const N: usize> EpisodeCatalog<N> {
64 pub fn new() -> Self {
66 Self {
67 entries: [None; N],
68 write_head: 0,
69 total_recorded: 0,
70 }
71 }
72
73 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 pub fn total_recorded(&self) -> u64 {
86 self.total_recorded
87 }
88
89 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 pub fn is_empty(&self) -> bool {
100 self.total_recorded == 0
101 }
102
103 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#[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#[inline]
199fn log2_approx(x: f64) -> f64 {
200 if x <= 0.0 {
201 return 0.0;
202 }
203 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 let sim = cat.find_similar(&query).expect("should find a match");
263 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 assert!(sim.similarity > 0.7,
304 "unknown-unknown structural twins should still match well");
305 }
306}