Skip to main content

ipfrs_tensorlogic/
execution_tracer.rs

1//! TensorExecutionTracer — records detailed execution traces of TensorLogic
2//! inference operations for debugging, profiling, and replay purposes.
3
4/// Kinds of events that can be recorded in an execution trace.
5#[derive(Clone, Debug, PartialEq)]
6pub enum TraceEventKind {
7    /// A rule evaluation has started.
8    RuleEvalStart { rule_id: u64 },
9    /// A rule evaluation has finished.
10    RuleEvalEnd { rule_id: u64, matched: bool },
11    /// A fact lookup was performed.
12    FactLookup { predicate: String, found: bool },
13    /// A tensor was read from storage.
14    TensorRead { tensor_id: u64, size_bytes: u64 },
15    /// A tensor was written to storage.
16    TensorWrite { tensor_id: u64, size_bytes: u64 },
17    /// An inference goal evaluation has started.
18    InferenceStart { goal: String },
19    /// An inference goal evaluation has finished.
20    InferenceEnd { goal: String, success: bool },
21}
22
23/// A single recorded event in the execution trace.
24#[derive(Clone, Debug, PartialEq)]
25pub struct TraceEvent {
26    /// Globally unique identifier for this event (monotonically increasing).
27    pub event_id: u64,
28    /// Logical clock tick at which the event was recorded.
29    pub tick: u64,
30    /// The kind (payload) of the event.
31    pub kind: TraceEventKind,
32    /// The session that produced this event.
33    pub session_id: u64,
34}
35
36/// Aggregated summary statistics for a single session.
37#[derive(Clone, Debug, PartialEq)]
38pub struct TraceSummary {
39    /// The session being summarised.
40    pub session_id: u64,
41    /// Total number of events in this session.
42    pub event_count: usize,
43    /// Number of `RuleEvalStart` events.
44    pub rule_evaluations: usize,
45    /// Number of `RuleEvalEnd` events where `matched == true`.
46    pub successful_rules: usize,
47    /// Total bytes read across all `TensorRead` events.
48    pub tensor_reads: u64,
49    /// Total bytes written across all `TensorWrite` events.
50    pub tensor_writes: u64,
51}
52
53impl TraceSummary {
54    /// Returns the fraction of rule evaluations that matched.
55    ///
56    /// Returns `0.0` when no rule evaluations have been recorded.
57    pub fn rule_match_rate(&self) -> f64 {
58        if self.rule_evaluations == 0 {
59            0.0
60        } else {
61            self.successful_rules as f64 / self.rule_evaluations as f64
62        }
63    }
64}
65
66/// Configuration for [`TensorExecutionTracer`].
67#[derive(Clone, Debug, PartialEq)]
68pub struct TracerConfig {
69    /// Maximum number of events retained in the ring buffer.  Oldest events
70    /// are evicted when this limit is reached.
71    pub max_events: usize,
72    /// When `false` all `record` calls are no-ops.
73    pub enabled: bool,
74}
75
76impl Default for TracerConfig {
77    fn default() -> Self {
78        Self {
79            max_events: 10_000,
80            enabled: true,
81        }
82    }
83}
84
85/// Whole-tracer statistics (across all sessions).
86#[derive(Clone, Debug, PartialEq)]
87pub struct TracerStats {
88    /// Total number of events currently held.
89    pub total_events: usize,
90    /// Number of distinct session identifiers present.
91    pub total_sessions: usize,
92    /// Number of events that were evicted due to the `max_events` cap.
93    pub dropped_events: u64,
94}
95
96/// Records detailed execution traces of TensorLogic inference operations.
97///
98/// The tracer maintains a capped ring buffer of [`TraceEvent`]s.  When the
99/// buffer reaches `config.max_events`, the oldest event is evicted and
100/// `dropped_events` is incremented.
101pub struct TensorExecutionTracer {
102    /// Retained events (oldest-first).
103    pub events: Vec<TraceEvent>,
104    /// The `event_id` that will be assigned to the next recorded event.
105    pub next_event_id: u64,
106    /// Tracer configuration.
107    pub config: TracerConfig,
108    /// Count of events dropped due to the capacity cap.
109    dropped_events: u64,
110}
111
112impl TensorExecutionTracer {
113    /// Creates a new tracer with the supplied configuration.
114    pub fn new(config: TracerConfig) -> Self {
115        Self {
116            events: Vec::new(),
117            next_event_id: 0,
118            config,
119            dropped_events: 0,
120        }
121    }
122
123    /// Records an event.
124    ///
125    /// * If `config.enabled` is `false` the call is a no-op.
126    /// * If the buffer is already at capacity the oldest event is removed and
127    ///   `dropped_events` is incremented before the new event is appended.
128    pub fn record(&mut self, session_id: u64, tick: u64, kind: TraceEventKind) {
129        if !self.config.enabled {
130            return;
131        }
132
133        if self.events.len() >= self.config.max_events {
134            self.events.remove(0);
135            self.dropped_events += 1;
136        }
137
138        self.events.push(TraceEvent {
139            event_id: self.next_event_id,
140            tick,
141            kind,
142            session_id,
143        });
144        self.next_event_id += 1;
145    }
146
147    /// Returns all events belonging to `session_id`, in chronological order.
148    pub fn events_for_session(&self, session_id: u64) -> Vec<&TraceEvent> {
149        self.events
150            .iter()
151            .filter(|e| e.session_id == session_id)
152            .collect()
153    }
154
155    /// Computes aggregated statistics for `session_id`.
156    pub fn summarize_session(&self, session_id: u64) -> TraceSummary {
157        let mut summary = TraceSummary {
158            session_id,
159            event_count: 0,
160            rule_evaluations: 0,
161            successful_rules: 0,
162            tensor_reads: 0,
163            tensor_writes: 0,
164        };
165
166        for event in self.events.iter().filter(|e| e.session_id == session_id) {
167            summary.event_count += 1;
168            match &event.kind {
169                TraceEventKind::RuleEvalStart { .. } => {
170                    summary.rule_evaluations += 1;
171                }
172                TraceEventKind::RuleEvalEnd { matched, .. } if *matched => {
173                    summary.successful_rules += 1;
174                }
175                TraceEventKind::RuleEvalEnd { .. } => {}
176                TraceEventKind::TensorRead { size_bytes, .. } => {
177                    summary.tensor_reads += size_bytes;
178                }
179                TraceEventKind::TensorWrite { size_bytes, .. } => {
180                    summary.tensor_writes += size_bytes;
181                }
182                _ => {}
183            }
184        }
185
186        summary
187    }
188
189    /// Removes all events that belong to `session_id`.
190    pub fn clear_session(&mut self, session_id: u64) {
191        self.events.retain(|e| e.session_id != session_id);
192    }
193
194    /// Returns a sorted, deduplicated list of all session identifiers present
195    /// in the current event buffer.
196    pub fn all_sessions(&self) -> Vec<u64> {
197        let mut ids: Vec<u64> = self.events.iter().map(|e| e.session_id).collect();
198        ids.sort_unstable();
199        ids.dedup();
200        ids
201    }
202
203    /// Returns aggregate statistics across all sessions.
204    pub fn stats(&self) -> TracerStats {
205        let total_sessions = {
206            let mut ids: Vec<u64> = self.events.iter().map(|e| e.session_id).collect();
207            ids.sort_unstable();
208            ids.dedup();
209            ids.len()
210        };
211
212        TracerStats {
213            total_events: self.events.len(),
214            total_sessions,
215            dropped_events: self.dropped_events,
216        }
217    }
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223
224    // ── helpers ──────────────────────────────────────────────────────────────
225
226    fn default_tracer() -> TensorExecutionTracer {
227        TensorExecutionTracer::new(TracerConfig::default())
228    }
229
230    fn rule_start(rule_id: u64) -> TraceEventKind {
231        TraceEventKind::RuleEvalStart { rule_id }
232    }
233
234    fn rule_end(rule_id: u64, matched: bool) -> TraceEventKind {
235        TraceEventKind::RuleEvalEnd { rule_id, matched }
236    }
237
238    fn fact_lookup(predicate: &str, found: bool) -> TraceEventKind {
239        TraceEventKind::FactLookup {
240            predicate: predicate.to_string(),
241            found,
242        }
243    }
244
245    fn tensor_read(tensor_id: u64, size_bytes: u64) -> TraceEventKind {
246        TraceEventKind::TensorRead {
247            tensor_id,
248            size_bytes,
249        }
250    }
251
252    fn tensor_write(tensor_id: u64, size_bytes: u64) -> TraceEventKind {
253        TraceEventKind::TensorWrite {
254            tensor_id,
255            size_bytes,
256        }
257    }
258
259    fn inf_start(goal: &str) -> TraceEventKind {
260        TraceEventKind::InferenceStart {
261            goal: goal.to_string(),
262        }
263    }
264
265    fn inf_end(goal: &str, success: bool) -> TraceEventKind {
266        TraceEventKind::InferenceEnd {
267            goal: goal.to_string(),
268            success,
269        }
270    }
271
272    // ── 1. new() starts empty ────────────────────────────────────────────────
273
274    #[test]
275    fn test_new_starts_empty() {
276        let tracer = default_tracer();
277        assert!(tracer.events.is_empty());
278        assert_eq!(tracer.next_event_id, 0);
279    }
280
281    // ── 2. record adds event ─────────────────────────────────────────────────
282
283    #[test]
284    fn test_record_adds_event() {
285        let mut tracer = default_tracer();
286        tracer.record(1, 0, rule_start(42));
287        assert_eq!(tracer.events.len(), 1);
288        assert_eq!(tracer.events[0].session_id, 1);
289        assert_eq!(tracer.events[0].tick, 0);
290        assert_eq!(tracer.events[0].kind, rule_start(42));
291    }
292
293    // ── 3. record is no-op when disabled ────────────────────────────────────
294
295    #[test]
296    fn test_record_noop_when_disabled() {
297        let mut tracer = TensorExecutionTracer::new(TracerConfig {
298            max_events: 100,
299            enabled: false,
300        });
301        tracer.record(1, 0, rule_start(1));
302        tracer.record(1, 1, rule_end(1, true));
303        assert!(tracer.events.is_empty());
304        assert_eq!(tracer.next_event_id, 0);
305    }
306
307    // ── 4. record evicts oldest at max_events ────────────────────────────────
308
309    #[test]
310    fn test_record_evicts_oldest_at_max_events() {
311        let mut tracer = TensorExecutionTracer::new(TracerConfig {
312            max_events: 3,
313            enabled: true,
314        });
315        tracer.record(1, 0, rule_start(1));
316        tracer.record(1, 1, rule_start(2));
317        tracer.record(1, 2, rule_start(3));
318        // Buffer is full; next record evicts event_id=0
319        tracer.record(1, 3, rule_start(4));
320        assert_eq!(tracer.events.len(), 3);
321        assert_eq!(tracer.events[0].event_id, 1); // oldest now
322        assert_eq!(tracer.events[2].event_id, 3); // newest
323    }
324
325    // ── 5. record increments next_event_id ──────────────────────────────────
326
327    #[test]
328    fn test_record_increments_event_id() {
329        let mut tracer = default_tracer();
330        tracer.record(1, 0, rule_start(1));
331        tracer.record(1, 1, rule_start(2));
332        assert_eq!(tracer.next_event_id, 2);
333        assert_eq!(tracer.events[0].event_id, 0);
334        assert_eq!(tracer.events[1].event_id, 1);
335    }
336
337    // ── 6. events_for_session returns correct events in order ────────────────
338
339    #[test]
340    fn test_events_for_session_order() {
341        let mut tracer = default_tracer();
342        tracer.record(1, 0, rule_start(1));
343        tracer.record(2, 1, rule_start(99));
344        tracer.record(1, 2, rule_end(1, true));
345        let events = tracer.events_for_session(1);
346        assert_eq!(events.len(), 2);
347        assert_eq!(events[0].tick, 0);
348        assert_eq!(events[1].tick, 2);
349    }
350
351    // ── 7. events_for_session empty for unknown session ──────────────────────
352
353    #[test]
354    fn test_events_for_session_unknown() {
355        let mut tracer = default_tracer();
356        tracer.record(1, 0, rule_start(1));
357        assert!(tracer.events_for_session(999).is_empty());
358    }
359
360    // ── 8. summarize_session event_count ────────────────────────────────────
361
362    #[test]
363    fn test_summarize_event_count() {
364        let mut tracer = default_tracer();
365        tracer.record(1, 0, rule_start(1));
366        tracer.record(1, 1, rule_end(1, false));
367        tracer.record(1, 2, fact_lookup("parent", true));
368        tracer.record(2, 3, rule_start(2)); // different session
369        let summary = tracer.summarize_session(1);
370        assert_eq!(summary.event_count, 3);
371    }
372
373    // ── 9. summarize_session rule_evaluations count ──────────────────────────
374
375    #[test]
376    fn test_summarize_rule_evaluations() {
377        let mut tracer = default_tracer();
378        tracer.record(1, 0, rule_start(1));
379        tracer.record(1, 1, rule_start(2));
380        tracer.record(1, 2, rule_end(1, true));
381        let summary = tracer.summarize_session(1);
382        assert_eq!(summary.rule_evaluations, 2);
383    }
384
385    // ── 10. summarize_session successful_rules count ─────────────────────────
386
387    #[test]
388    fn test_summarize_successful_rules() {
389        let mut tracer = default_tracer();
390        tracer.record(1, 0, rule_end(1, true));
391        tracer.record(1, 1, rule_end(2, false));
392        tracer.record(1, 2, rule_end(3, true));
393        let summary = tracer.summarize_session(1);
394        assert_eq!(summary.successful_rules, 2);
395    }
396
397    // ── 11. summarize_session tensor_reads bytes ─────────────────────────────
398
399    #[test]
400    fn test_summarize_tensor_reads() {
401        let mut tracer = default_tracer();
402        tracer.record(1, 0, tensor_read(10, 1024));
403        tracer.record(1, 1, tensor_read(11, 2048));
404        let summary = tracer.summarize_session(1);
405        assert_eq!(summary.tensor_reads, 3072);
406    }
407
408    // ── 12. summarize_session tensor_writes bytes ────────────────────────────
409
410    #[test]
411    fn test_summarize_tensor_writes() {
412        let mut tracer = default_tracer();
413        tracer.record(1, 0, tensor_write(10, 512));
414        tracer.record(1, 1, tensor_write(11, 256));
415        let summary = tracer.summarize_session(1);
416        assert_eq!(summary.tensor_writes, 768);
417    }
418
419    // ── 13. rule_match_rate computed correctly ───────────────────────────────
420
421    #[test]
422    fn test_rule_match_rate() {
423        let mut tracer = default_tracer();
424        tracer.record(1, 0, rule_start(1));
425        tracer.record(1, 1, rule_start(2));
426        tracer.record(1, 2, rule_end(1, true));
427        tracer.record(1, 3, rule_end(2, false));
428        let summary = tracer.summarize_session(1);
429        // 2 evals, 1 match → 0.5
430        assert!((summary.rule_match_rate() - 0.5).abs() < f64::EPSILON);
431    }
432
433    // ── 14. rule_match_rate 0.0 when no evals ───────────────────────────────
434
435    #[test]
436    fn test_rule_match_rate_zero_when_no_evals() {
437        let tracer = default_tracer();
438        let summary = tracer.summarize_session(99);
439        assert_eq!(summary.rule_match_rate(), 0.0);
440    }
441
442    // ── 15. clear_session removes events ────────────────────────────────────
443
444    #[test]
445    fn test_clear_session() {
446        let mut tracer = default_tracer();
447        tracer.record(1, 0, rule_start(1));
448        tracer.record(2, 1, rule_start(2));
449        tracer.record(1, 2, rule_end(1, true));
450        tracer.clear_session(1);
451        assert_eq!(tracer.events.len(), 1);
452        assert_eq!(tracer.events[0].session_id, 2);
453    }
454
455    // ── 16. all_sessions unique sorted ids ──────────────────────────────────
456
457    #[test]
458    fn test_all_sessions_unique_sorted() {
459        let mut tracer = default_tracer();
460        tracer.record(3, 0, rule_start(1));
461        tracer.record(1, 1, rule_start(2));
462        tracer.record(3, 2, rule_end(1, false));
463        tracer.record(2, 3, fact_lookup("foo", true));
464        let sessions = tracer.all_sessions();
465        assert_eq!(sessions, vec![1, 2, 3]);
466    }
467
468    // ── 17. stats total_events correct ──────────────────────────────────────
469
470    #[test]
471    fn test_stats_total_events() {
472        let mut tracer = default_tracer();
473        tracer.record(1, 0, rule_start(1));
474        tracer.record(2, 1, rule_start(2));
475        assert_eq!(tracer.stats().total_events, 2);
476    }
477
478    // ── 18. stats total_sessions correct ────────────────────────────────────
479
480    #[test]
481    fn test_stats_total_sessions() {
482        let mut tracer = default_tracer();
483        tracer.record(1, 0, rule_start(1));
484        tracer.record(2, 1, rule_start(2));
485        tracer.record(1, 2, rule_end(1, true));
486        assert_eq!(tracer.stats().total_sessions, 2);
487    }
488
489    // ── 19. stats dropped_events increments on eviction ─────────────────────
490
491    #[test]
492    fn test_stats_dropped_events() {
493        let mut tracer = TensorExecutionTracer::new(TracerConfig {
494            max_events: 2,
495            enabled: true,
496        });
497        tracer.record(1, 0, rule_start(1));
498        tracer.record(1, 1, rule_start(2));
499        assert_eq!(tracer.stats().dropped_events, 0);
500        tracer.record(1, 2, rule_start(3)); // evicts first
501        assert_eq!(tracer.stats().dropped_events, 1);
502        tracer.record(1, 3, rule_start(4)); // evicts second
503        assert_eq!(tracer.stats().dropped_events, 2);
504    }
505
506    // ── 20. multiple sessions interleaved ────────────────────────────────────
507
508    #[test]
509    fn test_multiple_sessions_interleaved() {
510        let mut tracer = default_tracer();
511        tracer.record(1, 0, inf_start("goal_a"));
512        tracer.record(2, 1, inf_start("goal_b"));
513        tracer.record(1, 2, tensor_read(5, 256));
514        tracer.record(2, 3, tensor_write(6, 512));
515        tracer.record(1, 4, inf_end("goal_a", true));
516        tracer.record(2, 5, inf_end("goal_b", false));
517
518        let s1 = tracer.summarize_session(1);
519        assert_eq!(s1.event_count, 3);
520        assert_eq!(s1.tensor_reads, 256);
521
522        let s2 = tracer.summarize_session(2);
523        assert_eq!(s2.event_count, 3);
524        assert_eq!(s2.tensor_writes, 512);
525    }
526
527    // ── 21. FactLookup events counted in event_count ─────────────────────────
528
529    #[test]
530    fn test_fact_lookup_in_event_count() {
531        let mut tracer = default_tracer();
532        tracer.record(1, 0, fact_lookup("edge", true));
533        tracer.record(1, 1, fact_lookup("edge", false));
534        tracer.record(1, 2, fact_lookup("node", true));
535        let summary = tracer.summarize_session(1);
536        assert_eq!(summary.event_count, 3);
537        // FactLookup events do not affect rule or tensor counters
538        assert_eq!(summary.rule_evaluations, 0);
539        assert_eq!(summary.tensor_reads, 0);
540        assert_eq!(summary.tensor_writes, 0);
541    }
542
543    // ── 22. TracerConfig enabled=false suppresses all ────────────────────────
544
545    #[test]
546    fn test_config_disabled_suppresses_all_variants() {
547        let mut tracer = TensorExecutionTracer::new(TracerConfig {
548            max_events: 1000,
549            enabled: false,
550        });
551        tracer.record(1, 0, rule_start(1));
552        tracer.record(1, 1, rule_end(1, true));
553        tracer.record(1, 2, fact_lookup("p", false));
554        tracer.record(1, 3, tensor_read(7, 128));
555        tracer.record(1, 4, tensor_write(8, 64));
556        tracer.record(1, 5, inf_start("g"));
557        tracer.record(1, 6, inf_end("g", true));
558        assert!(tracer.events.is_empty());
559        assert_eq!(tracer.next_event_id, 0);
560        let stats = tracer.stats();
561        assert_eq!(stats.total_events, 0);
562        assert_eq!(stats.dropped_events, 0);
563    }
564
565    // ── bonus: summarize returns zero-valued struct for unknown session ───────
566
567    #[test]
568    fn test_summarize_unknown_session_zeros() {
569        let tracer = default_tracer();
570        let summary = tracer.summarize_session(42);
571        assert_eq!(summary.event_count, 0);
572        assert_eq!(summary.rule_evaluations, 0);
573        assert_eq!(summary.successful_rules, 0);
574        assert_eq!(summary.tensor_reads, 0);
575        assert_eq!(summary.tensor_writes, 0);
576    }
577
578    // ── bonus: all_sessions returns empty when no events ─────────────────────
579
580    #[test]
581    fn test_all_sessions_empty() {
582        let tracer = default_tracer();
583        assert!(tracer.all_sessions().is_empty());
584    }
585
586    // ── bonus: event_id is stable across sessions ─────────────────────────────
587
588    #[test]
589    fn test_event_ids_across_sessions() {
590        let mut tracer = default_tracer();
591        tracer.record(10, 0, rule_start(1));
592        tracer.record(20, 1, rule_start(2));
593        tracer.record(10, 2, rule_end(1, true));
594        assert_eq!(tracer.events[0].event_id, 0);
595        assert_eq!(tracer.events[1].event_id, 1);
596        assert_eq!(tracer.events[2].event_id, 2);
597    }
598
599    // ── bonus: clear_session then re-record works ─────────────────────────────
600
601    #[test]
602    fn test_clear_then_rerecord() {
603        let mut tracer = default_tracer();
604        tracer.record(1, 0, rule_start(1));
605        tracer.clear_session(1);
606        assert!(tracer.events.is_empty());
607        tracer.record(1, 1, rule_start(2));
608        assert_eq!(tracer.events.len(), 1);
609        assert_eq!(tracer.summarize_session(1).event_count, 1);
610    }
611}