Skip to main content

ipfrs_tensorlogic/
inference_trace.rs

1//! Inference trace recorder for debugging and performance analysis.
2//!
3//! Records detailed traces of inference execution: which rules fired, in what
4//! order, with what bindings, and summary statistics.
5
6/// A single event recorded during inference execution.
7#[derive(Clone, Debug)]
8pub enum TraceEvent {
9    /// A rule was fired during backward/forward chaining.
10    RuleFired {
11        rule_id: String,
12        goal: String,
13        depth: usize,
14        bindings: Vec<(String, String)>,
15    },
16    /// A goal was resolved (successfully or not).
17    GoalResolved {
18        goal: String,
19        depth: usize,
20        success: bool,
21    },
22    /// A backtrack point was recorded with the number of alternatives.
23    BacktrackPoint { goal: String, alternatives: usize },
24    /// The inference result was served from cache.
25    CacheHit { goal: String, depth: usize },
26    /// The maximum recursion depth was reached for a goal.
27    MaxDepthReached { goal: String, depth: usize },
28}
29
30/// A named span grouping a contiguous window of trace events.
31#[derive(Clone, Debug)]
32pub struct TraceSpan {
33    /// Monotonically increasing identifier assigned at span creation.
34    pub span_id: u64,
35    /// Human-readable label for this span.
36    pub label: String,
37    /// Index into the events vec at the time the span was opened.
38    pub start_event_idx: usize,
39    /// Index into the events vec at the time the span was closed.
40    /// `None` while the span is still open.
41    pub end_event_idx: Option<usize>,
42}
43
44impl TraceSpan {
45    /// Number of events captured inside this span.
46    ///
47    /// Returns `0` for an unclosed span.
48    pub fn duration_events(&self) -> usize {
49        match self.end_event_idx {
50            Some(end) => end.saturating_sub(self.start_event_idx),
51            None => 0,
52        }
53    }
54}
55
56/// Aggregated counters derived from recorded events.
57#[derive(Clone, Debug, Default)]
58pub struct TraceStats {
59    pub total_events: u64,
60    pub rules_fired: u64,
61    pub goals_resolved: u64,
62    pub cache_hits: u64,
63    pub backtracks: u64,
64    pub max_depth_reached: u64,
65}
66
67impl TraceStats {
68    /// Fraction of resolved goals that were served from cache.
69    ///
70    /// Returns `0.0` when no goals have been resolved yet.
71    pub fn cache_hit_rate(&self) -> f64 {
72        self.cache_hits as f64 / self.goals_resolved.max(1) as f64
73    }
74}
75
76/// Records and queries a bounded trace of inference execution events.
77pub struct InferenceTraceRecorder {
78    /// Recorded events in order of occurrence.
79    pub events: Vec<TraceEvent>,
80    /// Named spans.
81    pub spans: Vec<TraceSpan>,
82    /// Counter used to hand out monotonically increasing span IDs.
83    pub next_span_id: u64,
84    /// Aggregated statistics.
85    pub stats: TraceStats,
86    /// Maximum number of events to retain (oldest are dropped when exceeded).
87    pub max_events: usize,
88}
89
90impl InferenceTraceRecorder {
91    /// Create a new recorder with the given event capacity cap.
92    pub fn new(max_events: usize) -> Self {
93        Self {
94            events: Vec::new(),
95            spans: Vec::new(),
96            next_span_id: 0,
97            stats: TraceStats::default(),
98            max_events,
99        }
100    }
101
102    /// Append an event, updating statistics.
103    ///
104    /// If the number of events would exceed `max_events`, the oldest event is
105    /// removed first (and span indices that referenced it are **not** adjusted
106    /// — callers should treat indices as approximate after overflow).
107    pub fn record(&mut self, event: TraceEvent) {
108        // Update counters before the potential removal so totals stay accurate.
109        self.stats.total_events += 1;
110        match &event {
111            TraceEvent::RuleFired { .. } => self.stats.rules_fired += 1,
112            TraceEvent::GoalResolved { .. } => self.stats.goals_resolved += 1,
113            TraceEvent::CacheHit { .. } => self.stats.cache_hits += 1,
114            TraceEvent::BacktrackPoint { .. } => self.stats.backtracks += 1,
115            TraceEvent::MaxDepthReached { .. } => self.stats.max_depth_reached += 1,
116        }
117
118        if self.events.len() >= self.max_events {
119            self.events.remove(0);
120        }
121        self.events.push(event);
122    }
123
124    /// Open a new named span and return its ID.
125    pub fn begin_span(&mut self, label: String) -> u64 {
126        let id = self.next_span_id;
127        self.next_span_id += 1;
128        self.spans.push(TraceSpan {
129            span_id: id,
130            label,
131            start_event_idx: self.events.len(),
132            end_event_idx: None,
133        });
134        id
135    }
136
137    /// Close the span identified by `span_id`, recording the current event
138    /// count as the end boundary.
139    pub fn end_span(&mut self, span_id: u64) {
140        let current_len = self.events.len();
141        if let Some(span) = self.spans.iter_mut().find(|s| s.span_id == span_id) {
142            span.end_event_idx = Some(current_len);
143        }
144    }
145
146    /// Return the slice of events that fall within the given span.
147    ///
148    /// Returns an empty slice when the span is unknown or not yet closed.
149    pub fn events_in_span(&self, span_id: u64) -> &[TraceEvent] {
150        let span = match self.spans.iter().find(|s| s.span_id == span_id) {
151            Some(s) => s,
152            None => return &[],
153        };
154        let end = match span.end_event_idx {
155            Some(e) => e,
156            None => return &[],
157        };
158        let start = span.start_event_idx.min(self.events.len());
159        let end = end.min(self.events.len());
160        if start >= end {
161            return &[];
162        }
163        &self.events[start..end]
164    }
165
166    /// Return all events for which `pred` returns `true`.
167    pub fn filter_events(&self, pred: impl Fn(&TraceEvent) -> bool) -> Vec<&TraceEvent> {
168        self.events.iter().filter(|e| pred(e)).collect()
169    }
170
171    /// Return only the `RuleFired` events that fall inside the given span.
172    pub fn rules_fired_in_span(&self, span_id: u64) -> Vec<&TraceEvent> {
173        self.events_in_span(span_id)
174            .iter()
175            .filter(|e| matches!(e, TraceEvent::RuleFired { .. }))
176            .collect()
177    }
178
179    /// Borrow the accumulated statistics.
180    pub fn stats(&self) -> &TraceStats {
181        &self.stats
182    }
183
184    /// Reset all recorded data and statistics.
185    pub fn clear(&mut self) {
186        self.events.clear();
187        self.spans.clear();
188        self.next_span_id = 0;
189        self.stats = TraceStats::default();
190    }
191
192    /// Produce a one-line human-readable summary of the trace statistics.
193    pub fn export_summary(&self) -> String {
194        format!(
195            "events={} rules={} cache_hits={} backtracks={}",
196            self.stats.total_events,
197            self.stats.rules_fired,
198            self.stats.cache_hits,
199            self.stats.backtracks,
200        )
201    }
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207
208    fn rule_fired(rule_id: &str, goal: &str) -> TraceEvent {
209        TraceEvent::RuleFired {
210            rule_id: rule_id.to_string(),
211            goal: goal.to_string(),
212            depth: 1,
213            bindings: vec![("X".to_string(), "alice".to_string())],
214        }
215    }
216
217    fn goal_resolved(goal: &str, success: bool) -> TraceEvent {
218        TraceEvent::GoalResolved {
219            goal: goal.to_string(),
220            depth: 1,
221            success,
222        }
223    }
224
225    fn cache_hit(goal: &str) -> TraceEvent {
226        TraceEvent::CacheHit {
227            goal: goal.to_string(),
228            depth: 1,
229        }
230    }
231
232    fn backtrack(goal: &str) -> TraceEvent {
233        TraceEvent::BacktrackPoint {
234            goal: goal.to_string(),
235            alternatives: 3,
236        }
237    }
238
239    fn max_depth(goal: &str) -> TraceEvent {
240        TraceEvent::MaxDepthReached {
241            goal: goal.to_string(),
242            depth: 10,
243        }
244    }
245
246    // 1. new() produces empty state
247    #[test]
248    fn test_new_empty_state() {
249        let recorder = InferenceTraceRecorder::new(100);
250        assert!(recorder.events.is_empty());
251        assert!(recorder.spans.is_empty());
252        assert_eq!(recorder.next_span_id, 0);
253        assert_eq!(recorder.stats.total_events, 0);
254        assert_eq!(recorder.max_events, 100);
255    }
256
257    // 2. record RuleFired updates stats.rules_fired
258    #[test]
259    fn test_record_rule_fired_stats() {
260        let mut r = InferenceTraceRecorder::new(100);
261        r.record(rule_fired("r1", "parent(X,Y)"));
262        assert_eq!(r.stats.rules_fired, 1);
263        assert_eq!(r.stats.total_events, 1);
264    }
265
266    // 3. record GoalResolved updates stats.goals_resolved
267    #[test]
268    fn test_record_goal_resolved_stats() {
269        let mut r = InferenceTraceRecorder::new(100);
270        r.record(goal_resolved("parent(alice,bob)", true));
271        assert_eq!(r.stats.goals_resolved, 1);
272        assert_eq!(r.stats.total_events, 1);
273    }
274
275    // 4. record CacheHit updates stats.cache_hits
276    #[test]
277    fn test_record_cache_hit_stats() {
278        let mut r = InferenceTraceRecorder::new(100);
279        r.record(cache_hit("ancestor(X,Y)"));
280        assert_eq!(r.stats.cache_hits, 1);
281        assert_eq!(r.stats.total_events, 1);
282    }
283
284    // 5. record BacktrackPoint updates stats.backtracks
285    #[test]
286    fn test_record_backtrack_stats() {
287        let mut r = InferenceTraceRecorder::new(100);
288        r.record(backtrack("foo(X)"));
289        assert_eq!(r.stats.backtracks, 1);
290        assert_eq!(r.stats.total_events, 1);
291    }
292
293    // 6. record MaxDepthReached updates stats.max_depth_reached
294    #[test]
295    fn test_record_max_depth_stats() {
296        let mut r = InferenceTraceRecorder::new(100);
297        r.record(max_depth("loop(X)"));
298        assert_eq!(r.stats.max_depth_reached, 1);
299        assert_eq!(r.stats.total_events, 1);
300    }
301
302    // 7. max_events cap drops oldest event
303    #[test]
304    fn test_max_events_cap_drops_oldest() {
305        let mut r = InferenceTraceRecorder::new(3);
306        r.record(rule_fired("r1", "goal1"));
307        r.record(rule_fired("r2", "goal2"));
308        r.record(rule_fired("r3", "goal3"));
309        // All three should be present
310        assert_eq!(r.events.len(), 3);
311
312        // Adding a fourth should drop the first
313        r.record(rule_fired("r4", "goal4"));
314        assert_eq!(r.events.len(), 3);
315        // The surviving events should be r2, r3, r4
316        let goals: Vec<String> = r
317            .events
318            .iter()
319            .map(|e| match e {
320                TraceEvent::RuleFired { goal, .. } => goal.clone(),
321                _ => String::new(),
322            })
323            .collect();
324        assert_eq!(goals, vec!["goal2", "goal3", "goal4"]);
325        // Total events counter is still 4
326        assert_eq!(r.stats.total_events, 4);
327    }
328
329    // 8. begin_span returns monotonic ids
330    #[test]
331    fn test_begin_span_monotonic_ids() {
332        let mut r = InferenceTraceRecorder::new(100);
333        let id0 = r.begin_span("span-a".to_string());
334        let id1 = r.begin_span("span-b".to_string());
335        let id2 = r.begin_span("span-c".to_string());
336        assert!(id0 < id1);
337        assert!(id1 < id2);
338    }
339
340    // 9. end_span sets end_event_idx
341    #[test]
342    fn test_end_span_sets_end_event_idx() {
343        let mut r = InferenceTraceRecorder::new(100);
344        let id = r.begin_span("my-span".to_string());
345        r.record(rule_fired("r1", "g1"));
346        r.end_span(id);
347        let span = r
348            .spans
349            .iter()
350            .find(|s| s.span_id == id)
351            .expect("test: should succeed");
352        assert_eq!(span.end_event_idx, Some(1));
353    }
354
355    // 10. events_in_span returns correct slice
356    #[test]
357    fn test_events_in_span_correct_slice() {
358        let mut r = InferenceTraceRecorder::new(100);
359        // event before span
360        r.record(rule_fired("r0", "before"));
361        let id = r.begin_span("my-span".to_string());
362        r.record(rule_fired("r1", "inside1"));
363        r.record(goal_resolved("inside2", true));
364        r.end_span(id);
365        // event after span
366        r.record(rule_fired("r2", "after"));
367
368        let slice = r.events_in_span(id);
369        assert_eq!(slice.len(), 2);
370        match &slice[0] {
371            TraceEvent::RuleFired { goal, .. } => assert_eq!(goal, "inside1"),
372            _ => panic!("unexpected event"),
373        }
374        match &slice[1] {
375            TraceEvent::GoalResolved { goal, .. } => assert_eq!(goal, "inside2"),
376            _ => panic!("unexpected event"),
377        }
378    }
379
380    // 11. events_in_span unknown span_id returns empty
381    #[test]
382    fn test_events_in_span_unknown_returns_empty() {
383        let r = InferenceTraceRecorder::new(100);
384        assert!(r.events_in_span(9999).is_empty());
385    }
386
387    // 12. filter_events by closure
388    #[test]
389    fn test_filter_events_by_closure() {
390        let mut r = InferenceTraceRecorder::new(100);
391        r.record(rule_fired("r1", "g1"));
392        r.record(goal_resolved("g2", true));
393        r.record(cache_hit("g3"));
394        r.record(rule_fired("r2", "g4"));
395
396        let fired = r.filter_events(|e| matches!(e, TraceEvent::RuleFired { .. }));
397        assert_eq!(fired.len(), 2);
398    }
399
400    // 13. rules_fired_in_span filters correctly
401    #[test]
402    fn test_rules_fired_in_span() {
403        let mut r = InferenceTraceRecorder::new(100);
404        let id = r.begin_span("test".to_string());
405        r.record(rule_fired("r1", "g1"));
406        r.record(goal_resolved("g2", true));
407        r.record(cache_hit("g3"));
408        r.record(rule_fired("r2", "g4"));
409        r.end_span(id);
410
411        let fired = r.rules_fired_in_span(id);
412        assert_eq!(fired.len(), 2);
413        for e in fired {
414            assert!(matches!(e, TraceEvent::RuleFired { .. }));
415        }
416    }
417
418    // 14. cache_hit_rate calculation
419    #[test]
420    fn test_cache_hit_rate() {
421        let mut r = InferenceTraceRecorder::new(100);
422        // 2 goals resolved, 1 cache hit → 0.5
423        r.record(goal_resolved("g1", true));
424        r.record(goal_resolved("g2", false));
425        r.record(cache_hit("g3"));
426        let rate = r.stats().cache_hit_rate();
427        assert!((rate - 0.5).abs() < f64::EPSILON);
428    }
429
430    // 15. clear() resets everything
431    #[test]
432    fn test_clear_resets_everything() {
433        let mut r = InferenceTraceRecorder::new(100);
434        r.record(rule_fired("r1", "g1"));
435        let _ = r.begin_span("s1".to_string());
436        r.clear();
437        assert!(r.events.is_empty());
438        assert!(r.spans.is_empty());
439        assert_eq!(r.next_span_id, 0);
440        assert_eq!(r.stats.total_events, 0);
441        assert_eq!(r.stats.rules_fired, 0);
442        assert_eq!(r.stats.goals_resolved, 0);
443        assert_eq!(r.stats.cache_hits, 0);
444        assert_eq!(r.stats.backtracks, 0);
445        assert_eq!(r.stats.max_depth_reached, 0);
446    }
447
448    // 16. export_summary format correct
449    #[test]
450    fn test_export_summary_format() {
451        let mut r = InferenceTraceRecorder::new(100);
452        r.record(rule_fired("r1", "g1"));
453        r.record(cache_hit("g2"));
454        r.record(backtrack("g3"));
455        let summary = r.export_summary();
456        assert_eq!(summary, "events=3 rules=1 cache_hits=1 backtracks=1");
457    }
458
459    // 17. duration_events: closed vs open span
460    #[test]
461    fn test_duration_events_closed_vs_open() {
462        let mut r = InferenceTraceRecorder::new(100);
463        let open_id = r.begin_span("open".to_string());
464        r.record(rule_fired("r1", "g1"));
465        r.record(rule_fired("r2", "g2"));
466
467        let closed_id = r.begin_span("closed".to_string());
468        r.record(rule_fired("r3", "g3"));
469        r.end_span(closed_id);
470
471        // open span: end_event_idx is None → duration_events() == 0
472        let open_span = r
473            .spans
474            .iter()
475            .find(|s| s.span_id == open_id)
476            .expect("test: should succeed");
477        assert_eq!(open_span.duration_events(), 0);
478
479        // closed span: captured exactly 1 event
480        let closed_span = r
481            .spans
482            .iter()
483            .find(|s| s.span_id == closed_id)
484            .expect("test: should succeed");
485        assert_eq!(closed_span.duration_events(), 1);
486    }
487
488    // Bonus: cache_hit_rate when no goals resolved (avoid division by zero)
489    #[test]
490    fn test_cache_hit_rate_no_goals() {
491        let r = InferenceTraceRecorder::new(100);
492        assert_eq!(r.stats().cache_hit_rate(), 0.0);
493    }
494}