#[derive(Clone, Debug)]
pub enum TraceEvent {
RuleFired {
rule_id: String,
goal: String,
depth: usize,
bindings: Vec<(String, String)>,
},
GoalResolved {
goal: String,
depth: usize,
success: bool,
},
BacktrackPoint { goal: String, alternatives: usize },
CacheHit { goal: String, depth: usize },
MaxDepthReached { goal: String, depth: usize },
}
#[derive(Clone, Debug)]
pub struct TraceSpan {
pub span_id: u64,
pub label: String,
pub start_event_idx: usize,
pub end_event_idx: Option<usize>,
}
impl TraceSpan {
pub fn duration_events(&self) -> usize {
match self.end_event_idx {
Some(end) => end.saturating_sub(self.start_event_idx),
None => 0,
}
}
}
#[derive(Clone, Debug, Default)]
pub struct TraceStats {
pub total_events: u64,
pub rules_fired: u64,
pub goals_resolved: u64,
pub cache_hits: u64,
pub backtracks: u64,
pub max_depth_reached: u64,
}
impl TraceStats {
pub fn cache_hit_rate(&self) -> f64 {
self.cache_hits as f64 / self.goals_resolved.max(1) as f64
}
}
pub struct InferenceTraceRecorder {
pub events: Vec<TraceEvent>,
pub spans: Vec<TraceSpan>,
pub next_span_id: u64,
pub stats: TraceStats,
pub max_events: usize,
}
impl InferenceTraceRecorder {
pub fn new(max_events: usize) -> Self {
Self {
events: Vec::new(),
spans: Vec::new(),
next_span_id: 0,
stats: TraceStats::default(),
max_events,
}
}
pub fn record(&mut self, event: TraceEvent) {
self.stats.total_events += 1;
match &event {
TraceEvent::RuleFired { .. } => self.stats.rules_fired += 1,
TraceEvent::GoalResolved { .. } => self.stats.goals_resolved += 1,
TraceEvent::CacheHit { .. } => self.stats.cache_hits += 1,
TraceEvent::BacktrackPoint { .. } => self.stats.backtracks += 1,
TraceEvent::MaxDepthReached { .. } => self.stats.max_depth_reached += 1,
}
if self.events.len() >= self.max_events {
self.events.remove(0);
}
self.events.push(event);
}
pub fn begin_span(&mut self, label: String) -> u64 {
let id = self.next_span_id;
self.next_span_id += 1;
self.spans.push(TraceSpan {
span_id: id,
label,
start_event_idx: self.events.len(),
end_event_idx: None,
});
id
}
pub fn end_span(&mut self, span_id: u64) {
let current_len = self.events.len();
if let Some(span) = self.spans.iter_mut().find(|s| s.span_id == span_id) {
span.end_event_idx = Some(current_len);
}
}
pub fn events_in_span(&self, span_id: u64) -> &[TraceEvent] {
let span = match self.spans.iter().find(|s| s.span_id == span_id) {
Some(s) => s,
None => return &[],
};
let end = match span.end_event_idx {
Some(e) => e,
None => return &[],
};
let start = span.start_event_idx.min(self.events.len());
let end = end.min(self.events.len());
if start >= end {
return &[];
}
&self.events[start..end]
}
pub fn filter_events(&self, pred: impl Fn(&TraceEvent) -> bool) -> Vec<&TraceEvent> {
self.events.iter().filter(|e| pred(e)).collect()
}
pub fn rules_fired_in_span(&self, span_id: u64) -> Vec<&TraceEvent> {
self.events_in_span(span_id)
.iter()
.filter(|e| matches!(e, TraceEvent::RuleFired { .. }))
.collect()
}
pub fn stats(&self) -> &TraceStats {
&self.stats
}
pub fn clear(&mut self) {
self.events.clear();
self.spans.clear();
self.next_span_id = 0;
self.stats = TraceStats::default();
}
pub fn export_summary(&self) -> String {
format!(
"events={} rules={} cache_hits={} backtracks={}",
self.stats.total_events,
self.stats.rules_fired,
self.stats.cache_hits,
self.stats.backtracks,
)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn rule_fired(rule_id: &str, goal: &str) -> TraceEvent {
TraceEvent::RuleFired {
rule_id: rule_id.to_string(),
goal: goal.to_string(),
depth: 1,
bindings: vec![("X".to_string(), "alice".to_string())],
}
}
fn goal_resolved(goal: &str, success: bool) -> TraceEvent {
TraceEvent::GoalResolved {
goal: goal.to_string(),
depth: 1,
success,
}
}
fn cache_hit(goal: &str) -> TraceEvent {
TraceEvent::CacheHit {
goal: goal.to_string(),
depth: 1,
}
}
fn backtrack(goal: &str) -> TraceEvent {
TraceEvent::BacktrackPoint {
goal: goal.to_string(),
alternatives: 3,
}
}
fn max_depth(goal: &str) -> TraceEvent {
TraceEvent::MaxDepthReached {
goal: goal.to_string(),
depth: 10,
}
}
#[test]
fn test_new_empty_state() {
let recorder = InferenceTraceRecorder::new(100);
assert!(recorder.events.is_empty());
assert!(recorder.spans.is_empty());
assert_eq!(recorder.next_span_id, 0);
assert_eq!(recorder.stats.total_events, 0);
assert_eq!(recorder.max_events, 100);
}
#[test]
fn test_record_rule_fired_stats() {
let mut r = InferenceTraceRecorder::new(100);
r.record(rule_fired("r1", "parent(X,Y)"));
assert_eq!(r.stats.rules_fired, 1);
assert_eq!(r.stats.total_events, 1);
}
#[test]
fn test_record_goal_resolved_stats() {
let mut r = InferenceTraceRecorder::new(100);
r.record(goal_resolved("parent(alice,bob)", true));
assert_eq!(r.stats.goals_resolved, 1);
assert_eq!(r.stats.total_events, 1);
}
#[test]
fn test_record_cache_hit_stats() {
let mut r = InferenceTraceRecorder::new(100);
r.record(cache_hit("ancestor(X,Y)"));
assert_eq!(r.stats.cache_hits, 1);
assert_eq!(r.stats.total_events, 1);
}
#[test]
fn test_record_backtrack_stats() {
let mut r = InferenceTraceRecorder::new(100);
r.record(backtrack("foo(X)"));
assert_eq!(r.stats.backtracks, 1);
assert_eq!(r.stats.total_events, 1);
}
#[test]
fn test_record_max_depth_stats() {
let mut r = InferenceTraceRecorder::new(100);
r.record(max_depth("loop(X)"));
assert_eq!(r.stats.max_depth_reached, 1);
assert_eq!(r.stats.total_events, 1);
}
#[test]
fn test_max_events_cap_drops_oldest() {
let mut r = InferenceTraceRecorder::new(3);
r.record(rule_fired("r1", "goal1"));
r.record(rule_fired("r2", "goal2"));
r.record(rule_fired("r3", "goal3"));
assert_eq!(r.events.len(), 3);
r.record(rule_fired("r4", "goal4"));
assert_eq!(r.events.len(), 3);
let goals: Vec<String> = r
.events
.iter()
.map(|e| match e {
TraceEvent::RuleFired { goal, .. } => goal.clone(),
_ => String::new(),
})
.collect();
assert_eq!(goals, vec!["goal2", "goal3", "goal4"]);
assert_eq!(r.stats.total_events, 4);
}
#[test]
fn test_begin_span_monotonic_ids() {
let mut r = InferenceTraceRecorder::new(100);
let id0 = r.begin_span("span-a".to_string());
let id1 = r.begin_span("span-b".to_string());
let id2 = r.begin_span("span-c".to_string());
assert!(id0 < id1);
assert!(id1 < id2);
}
#[test]
fn test_end_span_sets_end_event_idx() {
let mut r = InferenceTraceRecorder::new(100);
let id = r.begin_span("my-span".to_string());
r.record(rule_fired("r1", "g1"));
r.end_span(id);
let span = r
.spans
.iter()
.find(|s| s.span_id == id)
.expect("test: should succeed");
assert_eq!(span.end_event_idx, Some(1));
}
#[test]
fn test_events_in_span_correct_slice() {
let mut r = InferenceTraceRecorder::new(100);
r.record(rule_fired("r0", "before"));
let id = r.begin_span("my-span".to_string());
r.record(rule_fired("r1", "inside1"));
r.record(goal_resolved("inside2", true));
r.end_span(id);
r.record(rule_fired("r2", "after"));
let slice = r.events_in_span(id);
assert_eq!(slice.len(), 2);
match &slice[0] {
TraceEvent::RuleFired { goal, .. } => assert_eq!(goal, "inside1"),
_ => panic!("unexpected event"),
}
match &slice[1] {
TraceEvent::GoalResolved { goal, .. } => assert_eq!(goal, "inside2"),
_ => panic!("unexpected event"),
}
}
#[test]
fn test_events_in_span_unknown_returns_empty() {
let r = InferenceTraceRecorder::new(100);
assert!(r.events_in_span(9999).is_empty());
}
#[test]
fn test_filter_events_by_closure() {
let mut r = InferenceTraceRecorder::new(100);
r.record(rule_fired("r1", "g1"));
r.record(goal_resolved("g2", true));
r.record(cache_hit("g3"));
r.record(rule_fired("r2", "g4"));
let fired = r.filter_events(|e| matches!(e, TraceEvent::RuleFired { .. }));
assert_eq!(fired.len(), 2);
}
#[test]
fn test_rules_fired_in_span() {
let mut r = InferenceTraceRecorder::new(100);
let id = r.begin_span("test".to_string());
r.record(rule_fired("r1", "g1"));
r.record(goal_resolved("g2", true));
r.record(cache_hit("g3"));
r.record(rule_fired("r2", "g4"));
r.end_span(id);
let fired = r.rules_fired_in_span(id);
assert_eq!(fired.len(), 2);
for e in fired {
assert!(matches!(e, TraceEvent::RuleFired { .. }));
}
}
#[test]
fn test_cache_hit_rate() {
let mut r = InferenceTraceRecorder::new(100);
r.record(goal_resolved("g1", true));
r.record(goal_resolved("g2", false));
r.record(cache_hit("g3"));
let rate = r.stats().cache_hit_rate();
assert!((rate - 0.5).abs() < f64::EPSILON);
}
#[test]
fn test_clear_resets_everything() {
let mut r = InferenceTraceRecorder::new(100);
r.record(rule_fired("r1", "g1"));
let _ = r.begin_span("s1".to_string());
r.clear();
assert!(r.events.is_empty());
assert!(r.spans.is_empty());
assert_eq!(r.next_span_id, 0);
assert_eq!(r.stats.total_events, 0);
assert_eq!(r.stats.rules_fired, 0);
assert_eq!(r.stats.goals_resolved, 0);
assert_eq!(r.stats.cache_hits, 0);
assert_eq!(r.stats.backtracks, 0);
assert_eq!(r.stats.max_depth_reached, 0);
}
#[test]
fn test_export_summary_format() {
let mut r = InferenceTraceRecorder::new(100);
r.record(rule_fired("r1", "g1"));
r.record(cache_hit("g2"));
r.record(backtrack("g3"));
let summary = r.export_summary();
assert_eq!(summary, "events=3 rules=1 cache_hits=1 backtracks=1");
}
#[test]
fn test_duration_events_closed_vs_open() {
let mut r = InferenceTraceRecorder::new(100);
let open_id = r.begin_span("open".to_string());
r.record(rule_fired("r1", "g1"));
r.record(rule_fired("r2", "g2"));
let closed_id = r.begin_span("closed".to_string());
r.record(rule_fired("r3", "g3"));
r.end_span(closed_id);
let open_span = r
.spans
.iter()
.find(|s| s.span_id == open_id)
.expect("test: should succeed");
assert_eq!(open_span.duration_events(), 0);
let closed_span = r
.spans
.iter()
.find(|s| s.span_id == closed_id)
.expect("test: should succeed");
assert_eq!(closed_span.duration_events(), 1);
}
#[test]
fn test_cache_hit_rate_no_goals() {
let r = InferenceTraceRecorder::new(100);
assert_eq!(r.stats().cache_hit_rate(), 0.0);
}
}