Skip to main content

ipfrs_tensorlogic/
session_replay.rs

1//! Session Replay Engine — records and replays inference sessions for debugging,
2//! regression testing, and audit purposes.
3
4/// A single recorded event within an inference session.
5#[derive(Clone, Debug, PartialEq)]
6pub enum ReplayEvent {
7    /// A term was asserted into the knowledge base.
8    Assert { term: String, rule_set: String },
9    /// A term was retracted from the knowledge base.
10    Retract { term: String },
11    /// A query was submitted.
12    Query { goal: String, max_depth: usize },
13    /// The result of a query.
14    QueryResult {
15        goal: String,
16        success: bool,
17        bindings_count: usize,
18    },
19    /// A rule was loaded into the engine.
20    RuleLoaded { rule_id: String, head: String },
21    /// The session was started.
22    SessionStart {
23        session_id: String,
24        timestamp_secs: u64,
25    },
26    /// The session ended.
27    SessionEnd {
28        session_id: String,
29        timestamp_secs: u64,
30    },
31}
32
33/// A recorded inference session consisting of an ordered list of events.
34#[derive(Clone, Debug)]
35pub struct ReplaySession {
36    /// Unique identifier for this session.
37    pub session_id: String,
38    /// Ordered list of events recorded during the session.
39    pub events: Vec<ReplayEvent>,
40    /// Unix timestamp (seconds) when the session started.
41    pub started_at_secs: u64,
42}
43
44impl ReplaySession {
45    /// Creates a new empty session.
46    fn new(session_id: String) -> Self {
47        Self {
48            session_id,
49            events: Vec::new(),
50            started_at_secs: 0,
51        }
52    }
53
54    /// Returns the duration in seconds between `SessionStart` and `SessionEnd` events.
55    /// Returns 0 if no `SessionEnd` event is present.
56    pub fn duration_secs(&self) -> u64 {
57        let start = self.events.iter().find_map(|e| {
58            if let ReplayEvent::SessionStart { timestamp_secs, .. } = e {
59                Some(*timestamp_secs)
60            } else {
61                None
62            }
63        });
64
65        let end = self.events.iter().find_map(|e| {
66            if let ReplayEvent::SessionEnd { timestamp_secs, .. } = e {
67                Some(*timestamp_secs)
68            } else {
69                None
70            }
71        });
72
73        match (start, end) {
74            (Some(s), Some(e)) => e.saturating_sub(s),
75            _ => 0,
76        }
77    }
78
79    /// Returns the total number of events in this session.
80    pub fn event_count(&self) -> usize {
81        self.events.len()
82    }
83
84    /// Returns the number of `Query` events in this session.
85    pub fn query_count(&self) -> usize {
86        self.events
87            .iter()
88            .filter(|e| matches!(e, ReplayEvent::Query { .. }))
89            .count()
90    }
91
92    /// Returns the fraction of `QueryResult` events that were successful.
93    /// Returns `0.0` if there are no `QueryResult` events.
94    pub fn success_rate(&self) -> f64 {
95        let results: Vec<bool> = self
96            .events
97            .iter()
98            .filter_map(|e| {
99                if let ReplayEvent::QueryResult { success, .. } = e {
100                    Some(*success)
101                } else {
102                    None
103                }
104            })
105            .collect();
106
107        if results.is_empty() {
108            return 0.0;
109        }
110
111        let successes = results.iter().filter(|&&s| s).count();
112        successes as f64 / results.len() as f64
113    }
114}
115
116/// Filter criteria for selecting sessions from the engine.
117#[derive(Clone, Debug, PartialEq)]
118pub enum ReplayFilter {
119    /// Return all sessions.
120    All,
121    /// Return sessions that contain at least one `Query` or `QueryResult` event.
122    QueriesOnly,
123    /// Return sessions that contain at least one `Assert` event.
124    AssertionsOnly,
125    /// Return the single session matching the given session ID.
126    SessionId(String),
127    /// Return sessions whose `started_at_secs` is within `[from_secs, to_secs]`.
128    TimeRange { from_secs: u64, to_secs: u64 },
129}
130
131/// Aggregate statistics across all recorded sessions.
132#[derive(Clone, Debug, PartialEq)]
133pub struct ReplayStats {
134    /// Total number of recorded sessions.
135    pub total_sessions: usize,
136    /// Total events across all sessions.
137    pub total_events: usize,
138    /// Total `Query` events across all sessions.
139    pub total_queries: usize,
140    /// Total `Assert` events across all sessions.
141    pub total_assertions: usize,
142    /// Average session duration in seconds.
143    pub avg_session_duration_secs: f64,
144}
145
146/// Engine that records inference session events and allows structured replay and analysis.
147pub struct SessionReplayEngine {
148    /// All recorded sessions, oldest-first.
149    pub sessions: Vec<ReplaySession>,
150    /// Maximum number of sessions to retain; oldest session is dropped when exceeded.
151    pub max_sessions: usize,
152}
153
154impl SessionReplayEngine {
155    /// Creates a new engine with the specified maximum session capacity.
156    pub fn new(max_sessions: usize) -> Self {
157        Self {
158            sessions: Vec::new(),
159            max_sessions,
160        }
161    }
162
163    /// Records `event` for the session identified by `session_id`.
164    ///
165    /// If no session with that ID exists yet, a new one is created.  When the
166    /// engine is already at capacity (`max_sessions`) and a new session must be
167    /// created, the oldest session is dropped first.
168    ///
169    /// If the event is a `SessionStart`, the session's `started_at_secs` is
170    /// initialised from the event's `timestamp_secs`.
171    pub fn record_event(&mut self, session_id: &str, event: ReplayEvent) {
172        // Check if the session already exists.
173        let pos = self
174            .sessions
175            .iter()
176            .position(|s| s.session_id == session_id);
177
178        if let Some(idx) = pos {
179            // Existing session — just append.
180            if let ReplayEvent::SessionStart { timestamp_secs, .. } = &event {
181                if self.sessions[idx].started_at_secs == 0 {
182                    self.sessions[idx].started_at_secs = *timestamp_secs;
183                }
184            }
185            self.sessions[idx].events.push(event);
186        } else {
187            // New session — enforce capacity first.
188            if self.sessions.len() >= self.max_sessions {
189                self.sessions.remove(0);
190            }
191
192            let mut session = ReplaySession::new(session_id.to_string());
193
194            // Pre-populate started_at_secs if the first event is a SessionStart.
195            if let ReplayEvent::SessionStart { timestamp_secs, .. } = &event {
196                session.started_at_secs = *timestamp_secs;
197            }
198
199            session.events.push(event);
200            self.sessions.push(session);
201        }
202    }
203
204    /// Returns a reference to the session with the given ID, if it exists.
205    pub fn get_session(&self, session_id: &str) -> Option<&ReplaySession> {
206        self.sessions.iter().find(|s| s.session_id == session_id)
207    }
208
209    /// Returns all sessions that match the given filter.
210    pub fn filter_sessions(&self, filter: &ReplayFilter) -> Vec<&ReplaySession> {
211        self.sessions
212            .iter()
213            .filter(|s| match filter {
214                ReplayFilter::All => true,
215                ReplayFilter::QueriesOnly => s.events.iter().any(|e| {
216                    matches!(
217                        e,
218                        ReplayEvent::Query { .. } | ReplayEvent::QueryResult { .. }
219                    )
220                }),
221                ReplayFilter::AssertionsOnly => s
222                    .events
223                    .iter()
224                    .any(|e| matches!(e, ReplayEvent::Assert { .. })),
225                ReplayFilter::SessionId(id) => &s.session_id == id,
226                ReplayFilter::TimeRange { from_secs, to_secs } => {
227                    s.started_at_secs >= *from_secs && s.started_at_secs <= *to_secs
228                }
229            })
230            .collect()
231    }
232
233    /// Returns only the `Query` and `QueryResult` events for the given session.
234    pub fn replay_queries(&self, session_id: &str) -> Vec<&ReplayEvent> {
235        match self.get_session(session_id) {
236            None => Vec::new(),
237            Some(session) => session
238                .events
239                .iter()
240                .filter(|e| {
241                    matches!(
242                        e,
243                        ReplayEvent::Query { .. } | ReplayEvent::QueryResult { .. }
244                    )
245                })
246                .collect(),
247        }
248    }
249
250    /// Computes aggregate statistics across all recorded sessions.
251    pub fn stats(&self) -> ReplayStats {
252        let total_sessions = self.sessions.len();
253        let total_events: usize = self.sessions.iter().map(|s| s.events.len()).sum();
254
255        let total_queries: usize = self.sessions.iter().map(|s| s.query_count()).sum();
256
257        let total_assertions: usize = self
258            .sessions
259            .iter()
260            .map(|s| {
261                s.events
262                    .iter()
263                    .filter(|e| matches!(e, ReplayEvent::Assert { .. }))
264                    .count()
265            })
266            .sum();
267
268        let avg_session_duration_secs = if total_sessions == 0 {
269            0.0
270        } else {
271            let total_dur: u64 = self.sessions.iter().map(|s| s.duration_secs()).sum();
272            total_dur as f64 / total_sessions as f64
273        };
274
275        ReplayStats {
276            total_sessions,
277            total_events,
278            total_queries,
279            total_assertions,
280            avg_session_duration_secs,
281        }
282    }
283
284    /// Exports a summary of the session as a JSON-like string.
285    ///
286    /// Returns `None` if the session is not found.
287    pub fn export_session(&self, session_id: &str) -> Option<String> {
288        let session = self.get_session(session_id)?;
289        let events = session.event_count();
290        let queries = session.query_count();
291        let success_rate = session.success_rate();
292        Some(format!(
293            r#"{{"session_id":"{id}","events":{events},"queries":{queries},"success_rate":{success_rate}}}"#,
294            id = session.session_id,
295            events = events,
296            queries = queries,
297            success_rate = success_rate,
298        ))
299    }
300
301    /// Removes all recorded sessions from the engine.
302    pub fn clear(&mut self) {
303        self.sessions.clear();
304    }
305}
306
307// ---------------------------------------------------------------------------
308// Tests
309// ---------------------------------------------------------------------------
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314
315    fn make_engine() -> SessionReplayEngine {
316        SessionReplayEngine::new(1000)
317    }
318
319    fn session_start(session_id: &str, ts: u64) -> ReplayEvent {
320        ReplayEvent::SessionStart {
321            session_id: session_id.to_string(),
322            timestamp_secs: ts,
323        }
324    }
325
326    fn session_end(session_id: &str, ts: u64) -> ReplayEvent {
327        ReplayEvent::SessionEnd {
328            session_id: session_id.to_string(),
329            timestamp_secs: ts,
330        }
331    }
332
333    fn query(goal: &str) -> ReplayEvent {
334        ReplayEvent::Query {
335            goal: goal.to_string(),
336            max_depth: 10,
337        }
338    }
339
340    fn query_result(goal: &str, success: bool, bindings_count: usize) -> ReplayEvent {
341        ReplayEvent::QueryResult {
342            goal: goal.to_string(),
343            success,
344            bindings_count,
345        }
346    }
347
348    fn assert_event(term: &str) -> ReplayEvent {
349        ReplayEvent::Assert {
350            term: term.to_string(),
351            rule_set: "default".to_string(),
352        }
353    }
354
355    // 1. new() produces an empty engine
356    #[test]
357    fn test_new_empty() {
358        let engine = make_engine();
359        assert_eq!(engine.sessions.len(), 0);
360        assert_eq!(engine.max_sessions, 1000);
361    }
362
363    // 2. record_event creates a session on first event
364    #[test]
365    fn test_record_event_creates_session() {
366        let mut engine = make_engine();
367        engine.record_event("s1", session_start("s1", 100));
368        assert_eq!(engine.sessions.len(), 1);
369        assert_eq!(engine.sessions[0].session_id, "s1");
370    }
371
372    // 3. record_event appends to an existing session
373    #[test]
374    fn test_record_event_appends() {
375        let mut engine = make_engine();
376        engine.record_event("s1", session_start("s1", 100));
377        engine.record_event("s1", query("parent(X, Y)"));
378        engine.record_event("s1", query_result("parent(X, Y)", true, 2));
379        assert_eq!(engine.sessions.len(), 1);
380        assert_eq!(engine.sessions[0].events.len(), 3);
381    }
382
383    // 4. record_event enforces max_sessions by dropping the oldest
384    #[test]
385    fn test_record_event_max_sessions_drops_oldest() {
386        let mut engine = SessionReplayEngine::new(2);
387        engine.record_event("s1", session_start("s1", 10));
388        engine.record_event("s2", session_start("s2", 20));
389        // Adding a third session should evict s1
390        engine.record_event("s3", session_start("s3", 30));
391        assert_eq!(engine.sessions.len(), 2);
392        assert!(engine.get_session("s1").is_none());
393        assert!(engine.get_session("s2").is_some());
394        assert!(engine.get_session("s3").is_some());
395    }
396
397    // 5. get_session: found case
398    #[test]
399    fn test_get_session_found() {
400        let mut engine = make_engine();
401        engine.record_event("s1", session_start("s1", 100));
402        let s = engine.get_session("s1");
403        assert!(s.is_some());
404        assert_eq!(s.expect("test: should succeed").session_id, "s1");
405    }
406
407    // 6. get_session: not found case
408    #[test]
409    fn test_get_session_not_found() {
410        let engine = make_engine();
411        assert!(engine.get_session("missing").is_none());
412    }
413
414    // 7. filter_sessions All returns all
415    #[test]
416    fn test_filter_all() {
417        let mut engine = make_engine();
418        engine.record_event("s1", session_start("s1", 1));
419        engine.record_event("s2", session_start("s2", 2));
420        let results = engine.filter_sessions(&ReplayFilter::All);
421        assert_eq!(results.len(), 2);
422    }
423
424    // 8. filter_sessions QueriesOnly
425    #[test]
426    fn test_filter_queries_only() {
427        let mut engine = make_engine();
428        engine.record_event("s1", session_start("s1", 1));
429        engine.record_event("s1", query("foo(X)"));
430        engine.record_event("s2", session_start("s2", 2));
431        engine.record_event("s2", assert_event("bar(a)"));
432        let results = engine.filter_sessions(&ReplayFilter::QueriesOnly);
433        assert_eq!(results.len(), 1);
434        assert_eq!(results[0].session_id, "s1");
435    }
436
437    // 9. filter_sessions AssertionsOnly
438    #[test]
439    fn test_filter_assertions_only() {
440        let mut engine = make_engine();
441        engine.record_event("s1", session_start("s1", 1));
442        engine.record_event("s1", assert_event("parent(a, b)"));
443        engine.record_event("s2", session_start("s2", 2));
444        engine.record_event("s2", query("foo(X)"));
445        let results = engine.filter_sessions(&ReplayFilter::AssertionsOnly);
446        assert_eq!(results.len(), 1);
447        assert_eq!(results[0].session_id, "s1");
448    }
449
450    // 10. filter_sessions SessionId
451    #[test]
452    fn test_filter_session_id() {
453        let mut engine = make_engine();
454        engine.record_event("s1", session_start("s1", 1));
455        engine.record_event("s2", session_start("s2", 2));
456        let results = engine.filter_sessions(&ReplayFilter::SessionId("s2".to_string()));
457        assert_eq!(results.len(), 1);
458        assert_eq!(results[0].session_id, "s2");
459    }
460
461    // 11. filter_sessions TimeRange
462    #[test]
463    fn test_filter_time_range() {
464        let mut engine = make_engine();
465        engine.record_event("s1", session_start("s1", 100));
466        engine.record_event("s2", session_start("s2", 200));
467        engine.record_event("s3", session_start("s3", 300));
468        let results = engine.filter_sessions(&ReplayFilter::TimeRange {
469            from_secs: 150,
470            to_secs: 250,
471        });
472        assert_eq!(results.len(), 1);
473        assert_eq!(results[0].session_id, "s2");
474    }
475
476    // 12. replay_queries returns only Query + QueryResult events
477    #[test]
478    fn test_replay_queries_filters_correctly() {
479        let mut engine = make_engine();
480        engine.record_event("s1", session_start("s1", 1));
481        engine.record_event("s1", assert_event("foo(a)"));
482        engine.record_event("s1", query("foo(X)"));
483        engine.record_event("s1", query_result("foo(X)", true, 1));
484        engine.record_event("s1", session_end("s1", 100));
485
486        let events = engine.replay_queries("s1");
487        assert_eq!(events.len(), 2);
488        assert!(matches!(events[0], ReplayEvent::Query { .. }));
489        assert!(matches!(events[1], ReplayEvent::QueryResult { .. }));
490    }
491
492    // 13. duration_secs: correct difference between SessionStart and SessionEnd
493    #[test]
494    fn test_duration_secs() {
495        let mut engine = make_engine();
496        engine.record_event("s1", session_start("s1", 1000));
497        engine.record_event("s1", session_end("s1", 1045));
498        let session = engine.get_session("s1").expect("test: should succeed");
499        assert_eq!(session.duration_secs(), 45);
500    }
501
502    // 14. success_rate: 1 success + 1 failure => 0.5
503    #[test]
504    fn test_success_rate_half() {
505        let mut engine = make_engine();
506        engine.record_event("s1", session_start("s1", 1));
507        engine.record_event("s1", query_result("a(X)", true, 1));
508        engine.record_event("s1", query_result("b(X)", false, 0));
509        let session = engine.get_session("s1").expect("test: should succeed");
510        let rate = session.success_rate();
511        assert!((rate - 0.5).abs() < f64::EPSILON);
512    }
513
514    // 15. query_count is correct
515    #[test]
516    fn test_query_count() {
517        let mut engine = make_engine();
518        engine.record_event("s1", session_start("s1", 1));
519        engine.record_event("s1", query("a(X)"));
520        engine.record_event("s1", query("b(X)"));
521        engine.record_event("s1", query_result("a(X)", true, 1));
522        let session = engine.get_session("s1").expect("test: should succeed");
523        assert_eq!(session.query_count(), 2);
524    }
525
526    // 16. stats() returns correct totals
527    #[test]
528    fn test_stats_totals() {
529        let mut engine = make_engine();
530        // session s1: 1 assert, 1 query, duration 50s
531        engine.record_event("s1", session_start("s1", 0));
532        engine.record_event("s1", assert_event("foo(a)"));
533        engine.record_event("s1", query("foo(X)"));
534        engine.record_event("s1", session_end("s1", 50));
535
536        // session s2: 2 asserts, 2 queries, duration 100s
537        engine.record_event("s2", session_start("s2", 200));
538        engine.record_event("s2", assert_event("bar(b)"));
539        engine.record_event("s2", assert_event("baz(c)"));
540        engine.record_event("s2", query("bar(X)"));
541        engine.record_event("s2", query("baz(X)"));
542        engine.record_event("s2", session_end("s2", 300));
543
544        let stats = engine.stats();
545        assert_eq!(stats.total_sessions, 2);
546        assert_eq!(stats.total_events, 4 + 6); // 10 total
547        assert_eq!(stats.total_queries, 3);
548        assert_eq!(stats.total_assertions, 3);
549        // avg duration = (50 + 100) / 2 = 75.0
550        assert!((stats.avg_session_duration_secs - 75.0).abs() < f64::EPSILON);
551    }
552
553    // 17. export_session: Some for known session, None for unknown
554    #[test]
555    fn test_export_session() {
556        let mut engine = make_engine();
557        engine.record_event("s1", session_start("s1", 1));
558        engine.record_event("s1", query("foo(X)"));
559        engine.record_event("s1", query_result("foo(X)", true, 2));
560
561        let exported = engine.export_session("s1");
562        assert!(exported.is_some());
563        let s = exported.expect("test: should succeed");
564        assert!(s.contains("\"session_id\":\"s1\""));
565        assert!(s.contains("\"events\":3"));
566        assert!(s.contains("\"queries\":1"));
567
568        assert!(engine.export_session("nonexistent").is_none());
569    }
570
571    // 18. clear() resets everything
572    #[test]
573    fn test_clear() {
574        let mut engine = make_engine();
575        engine.record_event("s1", session_start("s1", 1));
576        engine.record_event("s2", session_start("s2", 2));
577        assert_eq!(engine.sessions.len(), 2);
578        engine.clear();
579        assert_eq!(engine.sessions.len(), 0);
580        assert!(engine.get_session("s1").is_none());
581    }
582}