1#[derive(Clone, Debug)]
8pub enum TraceEvent {
9 RuleFired {
11 rule_id: String,
12 goal: String,
13 depth: usize,
14 bindings: Vec<(String, String)>,
15 },
16 GoalResolved {
18 goal: String,
19 depth: usize,
20 success: bool,
21 },
22 BacktrackPoint { goal: String, alternatives: usize },
24 CacheHit { goal: String, depth: usize },
26 MaxDepthReached { goal: String, depth: usize },
28}
29
30#[derive(Clone, Debug)]
32pub struct TraceSpan {
33 pub span_id: u64,
35 pub label: String,
37 pub start_event_idx: usize,
39 pub end_event_idx: Option<usize>,
42}
43
44impl TraceSpan {
45 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#[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 pub fn cache_hit_rate(&self) -> f64 {
72 self.cache_hits as f64 / self.goals_resolved.max(1) as f64
73 }
74}
75
76pub struct InferenceTraceRecorder {
78 pub events: Vec<TraceEvent>,
80 pub spans: Vec<TraceSpan>,
82 pub next_span_id: u64,
84 pub stats: TraceStats,
86 pub max_events: usize,
88}
89
90impl InferenceTraceRecorder {
91 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 pub fn record(&mut self, event: TraceEvent) {
108 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 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 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 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 pub fn filter_events(&self, pred: impl Fn(&TraceEvent) -> bool) -> Vec<&TraceEvent> {
168 self.events.iter().filter(|e| pred(e)).collect()
169 }
170
171 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 pub fn stats(&self) -> &TraceStats {
181 &self.stats
182 }
183
184 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 assert_eq!(r.events.len(), 3);
311
312 r.record(rule_fired("r4", "goal4"));
314 assert_eq!(r.events.len(), 3);
315 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 assert_eq!(r.stats.total_events, 4);
327 }
328
329 #[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 #[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 #[test]
357 fn test_events_in_span_correct_slice() {
358 let mut r = InferenceTraceRecorder::new(100);
359 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 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 #[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 #[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 #[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 #[test]
420 fn test_cache_hit_rate() {
421 let mut r = InferenceTraceRecorder::new(100);
422 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 #[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 #[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 #[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 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 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 #[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}