#[derive(Clone, Debug, PartialEq)]
pub enum ReplayEvent {
Assert { term: String, rule_set: String },
Retract { term: String },
Query { goal: String, max_depth: usize },
QueryResult {
goal: String,
success: bool,
bindings_count: usize,
},
RuleLoaded { rule_id: String, head: String },
SessionStart {
session_id: String,
timestamp_secs: u64,
},
SessionEnd {
session_id: String,
timestamp_secs: u64,
},
}
#[derive(Clone, Debug)]
pub struct ReplaySession {
pub session_id: String,
pub events: Vec<ReplayEvent>,
pub started_at_secs: u64,
}
impl ReplaySession {
fn new(session_id: String) -> Self {
Self {
session_id,
events: Vec::new(),
started_at_secs: 0,
}
}
pub fn duration_secs(&self) -> u64 {
let start = self.events.iter().find_map(|e| {
if let ReplayEvent::SessionStart { timestamp_secs, .. } = e {
Some(*timestamp_secs)
} else {
None
}
});
let end = self.events.iter().find_map(|e| {
if let ReplayEvent::SessionEnd { timestamp_secs, .. } = e {
Some(*timestamp_secs)
} else {
None
}
});
match (start, end) {
(Some(s), Some(e)) => e.saturating_sub(s),
_ => 0,
}
}
pub fn event_count(&self) -> usize {
self.events.len()
}
pub fn query_count(&self) -> usize {
self.events
.iter()
.filter(|e| matches!(e, ReplayEvent::Query { .. }))
.count()
}
pub fn success_rate(&self) -> f64 {
let results: Vec<bool> = self
.events
.iter()
.filter_map(|e| {
if let ReplayEvent::QueryResult { success, .. } = e {
Some(*success)
} else {
None
}
})
.collect();
if results.is_empty() {
return 0.0;
}
let successes = results.iter().filter(|&&s| s).count();
successes as f64 / results.len() as f64
}
}
#[derive(Clone, Debug, PartialEq)]
pub enum ReplayFilter {
All,
QueriesOnly,
AssertionsOnly,
SessionId(String),
TimeRange { from_secs: u64, to_secs: u64 },
}
#[derive(Clone, Debug, PartialEq)]
pub struct ReplayStats {
pub total_sessions: usize,
pub total_events: usize,
pub total_queries: usize,
pub total_assertions: usize,
pub avg_session_duration_secs: f64,
}
pub struct SessionReplayEngine {
pub sessions: Vec<ReplaySession>,
pub max_sessions: usize,
}
impl SessionReplayEngine {
pub fn new(max_sessions: usize) -> Self {
Self {
sessions: Vec::new(),
max_sessions,
}
}
pub fn record_event(&mut self, session_id: &str, event: ReplayEvent) {
let pos = self
.sessions
.iter()
.position(|s| s.session_id == session_id);
if let Some(idx) = pos {
if let ReplayEvent::SessionStart { timestamp_secs, .. } = &event {
if self.sessions[idx].started_at_secs == 0 {
self.sessions[idx].started_at_secs = *timestamp_secs;
}
}
self.sessions[idx].events.push(event);
} else {
if self.sessions.len() >= self.max_sessions {
self.sessions.remove(0);
}
let mut session = ReplaySession::new(session_id.to_string());
if let ReplayEvent::SessionStart { timestamp_secs, .. } = &event {
session.started_at_secs = *timestamp_secs;
}
session.events.push(event);
self.sessions.push(session);
}
}
pub fn get_session(&self, session_id: &str) -> Option<&ReplaySession> {
self.sessions.iter().find(|s| s.session_id == session_id)
}
pub fn filter_sessions(&self, filter: &ReplayFilter) -> Vec<&ReplaySession> {
self.sessions
.iter()
.filter(|s| match filter {
ReplayFilter::All => true,
ReplayFilter::QueriesOnly => s.events.iter().any(|e| {
matches!(
e,
ReplayEvent::Query { .. } | ReplayEvent::QueryResult { .. }
)
}),
ReplayFilter::AssertionsOnly => s
.events
.iter()
.any(|e| matches!(e, ReplayEvent::Assert { .. })),
ReplayFilter::SessionId(id) => &s.session_id == id,
ReplayFilter::TimeRange { from_secs, to_secs } => {
s.started_at_secs >= *from_secs && s.started_at_secs <= *to_secs
}
})
.collect()
}
pub fn replay_queries(&self, session_id: &str) -> Vec<&ReplayEvent> {
match self.get_session(session_id) {
None => Vec::new(),
Some(session) => session
.events
.iter()
.filter(|e| {
matches!(
e,
ReplayEvent::Query { .. } | ReplayEvent::QueryResult { .. }
)
})
.collect(),
}
}
pub fn stats(&self) -> ReplayStats {
let total_sessions = self.sessions.len();
let total_events: usize = self.sessions.iter().map(|s| s.events.len()).sum();
let total_queries: usize = self.sessions.iter().map(|s| s.query_count()).sum();
let total_assertions: usize = self
.sessions
.iter()
.map(|s| {
s.events
.iter()
.filter(|e| matches!(e, ReplayEvent::Assert { .. }))
.count()
})
.sum();
let avg_session_duration_secs = if total_sessions == 0 {
0.0
} else {
let total_dur: u64 = self.sessions.iter().map(|s| s.duration_secs()).sum();
total_dur as f64 / total_sessions as f64
};
ReplayStats {
total_sessions,
total_events,
total_queries,
total_assertions,
avg_session_duration_secs,
}
}
pub fn export_session(&self, session_id: &str) -> Option<String> {
let session = self.get_session(session_id)?;
let events = session.event_count();
let queries = session.query_count();
let success_rate = session.success_rate();
Some(format!(
r#"{{"session_id":"{id}","events":{events},"queries":{queries},"success_rate":{success_rate}}}"#,
id = session.session_id,
events = events,
queries = queries,
success_rate = success_rate,
))
}
pub fn clear(&mut self) {
self.sessions.clear();
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_engine() -> SessionReplayEngine {
SessionReplayEngine::new(1000)
}
fn session_start(session_id: &str, ts: u64) -> ReplayEvent {
ReplayEvent::SessionStart {
session_id: session_id.to_string(),
timestamp_secs: ts,
}
}
fn session_end(session_id: &str, ts: u64) -> ReplayEvent {
ReplayEvent::SessionEnd {
session_id: session_id.to_string(),
timestamp_secs: ts,
}
}
fn query(goal: &str) -> ReplayEvent {
ReplayEvent::Query {
goal: goal.to_string(),
max_depth: 10,
}
}
fn query_result(goal: &str, success: bool, bindings_count: usize) -> ReplayEvent {
ReplayEvent::QueryResult {
goal: goal.to_string(),
success,
bindings_count,
}
}
fn assert_event(term: &str) -> ReplayEvent {
ReplayEvent::Assert {
term: term.to_string(),
rule_set: "default".to_string(),
}
}
#[test]
fn test_new_empty() {
let engine = make_engine();
assert_eq!(engine.sessions.len(), 0);
assert_eq!(engine.max_sessions, 1000);
}
#[test]
fn test_record_event_creates_session() {
let mut engine = make_engine();
engine.record_event("s1", session_start("s1", 100));
assert_eq!(engine.sessions.len(), 1);
assert_eq!(engine.sessions[0].session_id, "s1");
}
#[test]
fn test_record_event_appends() {
let mut engine = make_engine();
engine.record_event("s1", session_start("s1", 100));
engine.record_event("s1", query("parent(X, Y)"));
engine.record_event("s1", query_result("parent(X, Y)", true, 2));
assert_eq!(engine.sessions.len(), 1);
assert_eq!(engine.sessions[0].events.len(), 3);
}
#[test]
fn test_record_event_max_sessions_drops_oldest() {
let mut engine = SessionReplayEngine::new(2);
engine.record_event("s1", session_start("s1", 10));
engine.record_event("s2", session_start("s2", 20));
engine.record_event("s3", session_start("s3", 30));
assert_eq!(engine.sessions.len(), 2);
assert!(engine.get_session("s1").is_none());
assert!(engine.get_session("s2").is_some());
assert!(engine.get_session("s3").is_some());
}
#[test]
fn test_get_session_found() {
let mut engine = make_engine();
engine.record_event("s1", session_start("s1", 100));
let s = engine.get_session("s1");
assert!(s.is_some());
assert_eq!(s.expect("test: should succeed").session_id, "s1");
}
#[test]
fn test_get_session_not_found() {
let engine = make_engine();
assert!(engine.get_session("missing").is_none());
}
#[test]
fn test_filter_all() {
let mut engine = make_engine();
engine.record_event("s1", session_start("s1", 1));
engine.record_event("s2", session_start("s2", 2));
let results = engine.filter_sessions(&ReplayFilter::All);
assert_eq!(results.len(), 2);
}
#[test]
fn test_filter_queries_only() {
let mut engine = make_engine();
engine.record_event("s1", session_start("s1", 1));
engine.record_event("s1", query("foo(X)"));
engine.record_event("s2", session_start("s2", 2));
engine.record_event("s2", assert_event("bar(a)"));
let results = engine.filter_sessions(&ReplayFilter::QueriesOnly);
assert_eq!(results.len(), 1);
assert_eq!(results[0].session_id, "s1");
}
#[test]
fn test_filter_assertions_only() {
let mut engine = make_engine();
engine.record_event("s1", session_start("s1", 1));
engine.record_event("s1", assert_event("parent(a, b)"));
engine.record_event("s2", session_start("s2", 2));
engine.record_event("s2", query("foo(X)"));
let results = engine.filter_sessions(&ReplayFilter::AssertionsOnly);
assert_eq!(results.len(), 1);
assert_eq!(results[0].session_id, "s1");
}
#[test]
fn test_filter_session_id() {
let mut engine = make_engine();
engine.record_event("s1", session_start("s1", 1));
engine.record_event("s2", session_start("s2", 2));
let results = engine.filter_sessions(&ReplayFilter::SessionId("s2".to_string()));
assert_eq!(results.len(), 1);
assert_eq!(results[0].session_id, "s2");
}
#[test]
fn test_filter_time_range() {
let mut engine = make_engine();
engine.record_event("s1", session_start("s1", 100));
engine.record_event("s2", session_start("s2", 200));
engine.record_event("s3", session_start("s3", 300));
let results = engine.filter_sessions(&ReplayFilter::TimeRange {
from_secs: 150,
to_secs: 250,
});
assert_eq!(results.len(), 1);
assert_eq!(results[0].session_id, "s2");
}
#[test]
fn test_replay_queries_filters_correctly() {
let mut engine = make_engine();
engine.record_event("s1", session_start("s1", 1));
engine.record_event("s1", assert_event("foo(a)"));
engine.record_event("s1", query("foo(X)"));
engine.record_event("s1", query_result("foo(X)", true, 1));
engine.record_event("s1", session_end("s1", 100));
let events = engine.replay_queries("s1");
assert_eq!(events.len(), 2);
assert!(matches!(events[0], ReplayEvent::Query { .. }));
assert!(matches!(events[1], ReplayEvent::QueryResult { .. }));
}
#[test]
fn test_duration_secs() {
let mut engine = make_engine();
engine.record_event("s1", session_start("s1", 1000));
engine.record_event("s1", session_end("s1", 1045));
let session = engine.get_session("s1").expect("test: should succeed");
assert_eq!(session.duration_secs(), 45);
}
#[test]
fn test_success_rate_half() {
let mut engine = make_engine();
engine.record_event("s1", session_start("s1", 1));
engine.record_event("s1", query_result("a(X)", true, 1));
engine.record_event("s1", query_result("b(X)", false, 0));
let session = engine.get_session("s1").expect("test: should succeed");
let rate = session.success_rate();
assert!((rate - 0.5).abs() < f64::EPSILON);
}
#[test]
fn test_query_count() {
let mut engine = make_engine();
engine.record_event("s1", session_start("s1", 1));
engine.record_event("s1", query("a(X)"));
engine.record_event("s1", query("b(X)"));
engine.record_event("s1", query_result("a(X)", true, 1));
let session = engine.get_session("s1").expect("test: should succeed");
assert_eq!(session.query_count(), 2);
}
#[test]
fn test_stats_totals() {
let mut engine = make_engine();
engine.record_event("s1", session_start("s1", 0));
engine.record_event("s1", assert_event("foo(a)"));
engine.record_event("s1", query("foo(X)"));
engine.record_event("s1", session_end("s1", 50));
engine.record_event("s2", session_start("s2", 200));
engine.record_event("s2", assert_event("bar(b)"));
engine.record_event("s2", assert_event("baz(c)"));
engine.record_event("s2", query("bar(X)"));
engine.record_event("s2", query("baz(X)"));
engine.record_event("s2", session_end("s2", 300));
let stats = engine.stats();
assert_eq!(stats.total_sessions, 2);
assert_eq!(stats.total_events, 4 + 6); assert_eq!(stats.total_queries, 3);
assert_eq!(stats.total_assertions, 3);
assert!((stats.avg_session_duration_secs - 75.0).abs() < f64::EPSILON);
}
#[test]
fn test_export_session() {
let mut engine = make_engine();
engine.record_event("s1", session_start("s1", 1));
engine.record_event("s1", query("foo(X)"));
engine.record_event("s1", query_result("foo(X)", true, 2));
let exported = engine.export_session("s1");
assert!(exported.is_some());
let s = exported.expect("test: should succeed");
assert!(s.contains("\"session_id\":\"s1\""));
assert!(s.contains("\"events\":3"));
assert!(s.contains("\"queries\":1"));
assert!(engine.export_session("nonexistent").is_none());
}
#[test]
fn test_clear() {
let mut engine = make_engine();
engine.record_event("s1", session_start("s1", 1));
engine.record_event("s2", session_start("s2", 2));
assert_eq!(engine.sessions.len(), 2);
engine.clear();
assert_eq!(engine.sessions.len(), 0);
assert!(engine.get_session("s1").is_none());
}
}