use crate::types::*;
pub struct EpisodeCatalog<const N: usize> {
entries: [Option<DebugEpisode>; N],
write_head: usize,
total_recorded: u64,
}
#[derive(Copy, Clone, Debug, PartialEq)]
pub struct SimilarEpisode {
pub catalog_index: usize,
pub past_episode: DebugEpisode,
pub similarity: f64,
}
impl<const N: usize> EpisodeCatalog<N> {
pub fn new() -> Self {
Self {
entries: [None; N],
write_head: 0,
total_recorded: 0,
}
}
pub fn record(&mut self, ep: DebugEpisode) {
if N == 0 {
return;
}
self.entries[self.write_head] = Some(ep);
self.write_head = (self.write_head + 1) % N;
self.total_recorded += 1;
}
pub fn total_recorded(&self) -> u64 {
self.total_recorded
}
pub fn len(&self) -> usize {
if self.total_recorded as usize > N {
N
} else {
self.total_recorded as usize
}
}
pub fn is_empty(&self) -> bool {
self.total_recorded == 0
}
pub fn find_similar(&self, query: &DebugEpisode) -> Option<SimilarEpisode> {
let mut best: Option<SimilarEpisode> = None;
let mut i = 0;
while i < N {
if let Some(past) = self.entries[i] {
let s = signature_similarity(query, &past);
let take = match best {
None => s > 0.0,
Some(b) => s > b.similarity,
};
if take {
best = Some(SimilarEpisode {
catalog_index: i,
past_episode: past,
similarity: s,
});
}
}
i += 1;
}
best
}
}
impl<const N: usize> Default for EpisodeCatalog<N> {
fn default() -> Self {
Self::new()
}
}
#[inline]
fn signature_similarity(a: &DebugEpisode, b: &DebugEpisode) -> f64 {
let motif_match = match (a.matched_motif, b.matched_motif) {
(SemanticDisposition::Named(m1), SemanticDisposition::Named(m2)) => {
if m1 == m2 { 1.0 } else { 0.0 }
}
(SemanticDisposition::Unknown, SemanticDisposition::Unknown) => 0.5,
_ => 0.0,
};
let reason_match = if a.primary_reason_code == b.primary_reason_code { 1.0 } else { 0.0 };
let drift_match = if a.structural_signature.dominant_drift_direction
== b.structural_signature.dominant_drift_direction { 1.0 } else { 0.0 };
let slew_a = abs_f64(a.structural_signature.peak_slew_magnitude);
let slew_b = abs_f64(b.structural_signature.peak_slew_magnitude);
let slew_max = if slew_a > slew_b { slew_a } else { slew_b };
let slew_proximity = if slew_max > 0.0 {
let denom = if slew_max > 1e-9 { slew_max } else { 1e-9 };
1.0 - abs_f64(slew_a - slew_b) / denom
} else {
1.0
};
let dur_a_raw = a.structural_signature.duration_windows;
let dur_b_raw = b.structural_signature.duration_windows;
let dur_a = if dur_a_raw > 1 { dur_a_raw } else { 1 } as f64;
let dur_b = if dur_b_raw > 1 { dur_b_raw } else { 1 } as f64;
let dur_proximity = 1.0 - abs_f64(log2_approx(dur_a) - log2_approx(dur_b)) / 8.0;
let dur_proximity = clamp01_f64(dur_proximity);
let raw: f64 = 0.40 * motif_match
+ 0.20 * reason_match
+ 0.10 * drift_match
+ 0.15 * slew_proximity
+ 0.15 * dur_proximity;
clamp01_f64(raw)
}
#[inline]
fn abs_f64(x: f64) -> f64 {
if x >= 0.0 { x } else { -x }
}
#[inline]
fn clamp01_f64(x: f64) -> f64 {
if x < 0.0 { 0.0 } else if x > 1.0 { 1.0 } else { x }
}
#[inline]
fn log2_approx(x: f64) -> f64 {
if x <= 0.0 {
return 0.0;
}
let bits = x.to_bits();
let exp = ((bits >> 52) & 0x7FF) as i32 - 1023;
let mantissa = (bits & ((1u64 << 52) - 1)) as f64 / (1u64 << 52) as f64;
exp as f64 + mantissa
}
#[cfg(test)]
mod tests {
use super::*;
fn ep(motif: MotifClass, slew: f64, duration: u64, contrib: u16) -> DebugEpisode {
DebugEpisode {
episode_id: 0,
start_window: 0,
end_window: duration - 1,
peak_grammar_state: GrammarState::Boundary,
primary_reason_code: ReasonCode::AbruptSlewViolation,
matched_motif: SemanticDisposition::Named(motif),
policy_state: PolicyState::Review,
contributing_signal_count: contrib,
structural_signature: StructuralSignature {
dominant_drift_direction: DriftDirection::Positive,
peak_slew_magnitude: slew,
duration_windows: duration,
signal_correlation: contrib as f64 / 8.0,
},
root_cause_signal_index: None,
}
}
#[test]
fn empty_catalog_returns_none() {
let cat = EpisodeCatalog::<8>::new();
let q = ep(MotifClass::CascadingTimeoutSlew, 0.5, 10, 3);
assert!(cat.find_similar(&q).is_none());
assert!(cat.is_empty());
}
#[test]
fn identical_episode_yields_high_similarity() {
let mut cat = EpisodeCatalog::<8>::new();
let past = ep(MotifClass::CascadingTimeoutSlew, 0.85, 12, 4);
cat.record(past);
let query = ep(MotifClass::CascadingTimeoutSlew, 0.85, 12, 4);
let sim = cat.find_similar(&query).expect("should find a match");
assert!(sim.similarity > 0.9,
"identical signature should yield similarity > 0.9, got {}",
sim.similarity);
}
#[test]
fn different_motif_yields_low_similarity() {
let mut cat = EpisodeCatalog::<8>::new();
cat.record(ep(MotifClass::CascadingTimeoutSlew, 0.5, 10, 3));
let query = ep(MotifClass::DeploymentRegressionSlew, 0.5, 10, 3);
let sim = cat.find_similar(&query).expect("should find a match");
assert!(sim.similarity < 0.7,
"different motif should yield similarity < 0.7, got {}",
sim.similarity);
}
#[test]
fn deterministic_lookup_across_calls() {
let mut cat = EpisodeCatalog::<8>::new();
cat.record(ep(MotifClass::MemoryLeakDrift, 0.3, 50, 2));
cat.record(ep(MotifClass::CascadingTimeoutSlew, 0.8, 8, 4));
cat.record(ep(MotifClass::DeploymentRegressionSlew, 0.9, 5, 1));
let q = ep(MotifClass::CascadingTimeoutSlew, 0.7, 10, 3);
let s1 = cat.find_similar(&q);
let s2 = cat.find_similar(&q);
assert_eq!(s1, s2, "find_similar must be deterministic");
}
#[test]
fn circular_buffer_wraps() {
let mut cat = EpisodeCatalog::<3>::new();
for i in 0..5 {
let mut e = ep(MotifClass::MemoryLeakDrift, 0.5, 10, 2);
e.episode_id = i as u32;
cat.record(e);
}
assert_eq!(cat.total_recorded(), 5);
assert_eq!(cat.len(), 3, "buffer should be full at capacity");
}
#[test]
fn unknown_motif_pair_partial_similarity() {
let mut cat = EpisodeCatalog::<8>::new();
let mut past = ep(MotifClass::CascadingTimeoutSlew, 0.5, 10, 3);
past.matched_motif = SemanticDisposition::Unknown;
cat.record(past);
let mut query = ep(MotifClass::CascadingTimeoutSlew, 0.5, 10, 3);
query.matched_motif = SemanticDisposition::Unknown;
let sim = cat.find_similar(&query).expect("should find a match");
assert!(sim.similarity > 0.7,
"unknown-unknown structural twins should still match well");
}
}