use rusqlite::Connection;
use kimetsu_core::KimetsuResult;
pub const OFF_ANCHOR_COSINE: f32 = 0.35;
pub const SUSTAINED_TURNS: usize = 3;
#[derive(Debug, Clone, PartialEq)]
pub struct SessionQueries {
pub session_id: String,
pub queries: Vec<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct DriftReport {
pub session_id: String,
pub similarity: Vec<f32>,
pub drifted_at: Option<usize>,
}
impl DriftReport {
pub fn drifted(&self) -> bool {
self.drifted_at.is_some()
}
pub fn min_similarity(&self) -> f32 {
self.similarity
.iter()
.copied()
.fold(1.0f32, |acc, s| acc.min(s))
}
}
pub fn detect(similarity: &[f32], threshold: f32, sustained: usize) -> Option<usize> {
if sustained == 0 {
return None;
}
let mut run_start: Option<usize> = None;
for (idx, &sim) in similarity.iter().enumerate().skip(1) {
if sim < threshold {
let start = *run_start.get_or_insert(idx);
if idx + 1 - start >= sustained {
return Some(start);
}
} else {
run_start = None;
}
}
None
}
pub fn analyze(session_id: &str, embeddings: &[Vec<f32>]) -> DriftReport {
let Some(anchor) = embeddings.first() else {
return DriftReport {
session_id: session_id.to_string(),
similarity: Vec::new(),
drifted_at: None,
};
};
let similarity: Vec<f32> = embeddings
.iter()
.map(|e| crate::embeddings::cosine_similarity(anchor, e))
.collect();
let drifted_at = detect(&similarity, OFF_ANCHOR_COSINE, SUSTAINED_TURNS);
DriftReport {
session_id: session_id.to_string(),
similarity,
drifted_at,
}
}
pub fn recent_sessions(conn: &Connection, limit: usize) -> KimetsuResult<Vec<SessionQueries>> {
let mut stmt = conn.prepare(
"SELECT payload_json, ts
FROM events
WHERE kind = 'context.served'
ORDER BY ts",
)?;
let rows = stmt
.query_map([], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})?
.collect::<Result<Vec<_>, _>>()?;
let mut order: Vec<String> = Vec::new();
let mut by_session: std::collections::HashMap<String, Vec<String>> =
std::collections::HashMap::new();
for (payload_json, _ts) in rows {
let Ok(payload) = serde_json::from_str::<serde_json::Value>(&payload_json) else {
continue;
};
let Some(session_id) = payload
.get("session_id")
.and_then(serde_json::Value::as_str)
else {
continue; };
let Some(query) = payload
.get("query")
.and_then(serde_json::Value::as_str)
.filter(|q| !q.trim().is_empty())
else {
continue; };
let entry = by_session.entry(session_id.to_string()).or_insert_with(|| {
order.push(session_id.to_string());
Vec::new()
});
entry.push(query.to_string());
}
let mut sessions: Vec<SessionQueries> = order
.into_iter()
.rev()
.filter_map(|session_id| {
by_session
.remove(&session_id)
.map(|queries| SessionQueries {
session_id,
queries,
})
})
.take(limit)
.collect();
sessions.retain(|s| s.queries.len() > 1);
Ok(sessions)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_session_that_holds_its_topic_does_not_drift() {
let report = analyze("s", &[vec![1.0, 0.0], vec![0.95, 0.05], vec![0.9, 0.1]]);
assert!(!report.drifted(), "got: {report:?}");
assert!(report.min_similarity() > OFF_ANCHOR_COSINE);
}
#[test]
fn one_off_anchor_turn_is_not_drift() {
let similarity = [1.0, 0.9, 0.05, 0.9, 0.95];
assert_eq!(
detect(&similarity, OFF_ANCHOR_COSINE, SUSTAINED_TURNS),
None
);
}
#[test]
fn a_sustained_run_marks_where_the_session_turned() {
let similarity = [1.0, 0.9, 0.05, 0.02, 0.01, 0.03];
assert_eq!(
detect(&similarity, OFF_ANCHOR_COSINE, SUSTAINED_TURNS),
Some(2),
"the run starts where it started, not where it was confirmed"
);
}
#[test]
fn a_return_to_topic_resets_the_run() {
let similarity = [1.0, 0.05, 0.02, 0.9, 0.05, 0.02];
assert_eq!(
detect(&similarity, OFF_ANCHOR_COSINE, SUSTAINED_TURNS),
None
);
}
#[test]
fn the_anchor_turn_is_never_the_drift_point() {
let similarity = [0.0, 0.0, 0.0, 0.0];
assert_eq!(
detect(&similarity, OFF_ANCHOR_COSINE, SUSTAINED_TURNS),
Some(1)
);
}
#[test]
fn an_empty_session_reports_nothing() {
let report = analyze("s", &[]);
assert!(!report.drifted());
assert!(report.similarity.is_empty());
assert_eq!(report.min_similarity(), 1.0);
}
#[test]
fn a_slow_walk_away_from_the_opening_turn_is_still_drift() {
let steps = [
vec![1.0f32, 0.0],
vec![0.8, 0.6],
vec![0.3, 0.95],
vec![0.1, 0.99],
vec![0.0, 1.0],
];
let report = analyze("s", &steps);
assert!(report.drifted(), "got: {report:?}");
}
fn served(conn: &Connection, session_id: Option<&str>, query: Option<&str>, ts: &str) {
let mut payload = serde_json::Map::new();
if let Some(sid) = session_id {
payload.insert("session_id".into(), serde_json::json!(sid));
}
if let Some(q) = query {
payload.insert("query".into(), serde_json::json!(q));
}
conn.execute(
"INSERT INTO events (event_id, run_id, ts, kind, schema_version, payload_json)
VALUES (?1, 'r', ?2, 'context.served', 1, ?3)",
rusqlite::params![
kimetsu_core::ids::new_id().to_string(),
ts,
serde_json::Value::Object(payload).to_string()
],
)
.expect("insert event");
}
fn conn() -> Connection {
let conn = Connection::open_in_memory().expect("open");
crate::schema::initialize(&conn).expect("schema");
conn
}
#[test]
fn turns_come_back_in_order_grouped_by_session() {
let c = conn();
served(&c, Some("a"), Some("first"), "2026-01-01T00:00:00Z");
served(&c, Some("b"), Some("other"), "2026-01-01T00:00:01Z");
served(&c, Some("a"), Some("second"), "2026-01-01T00:00:02Z");
served(&c, Some("b"), Some("other two"), "2026-01-01T00:00:03Z");
let sessions = recent_sessions(&c, 10).expect("sessions");
assert_eq!(sessions.len(), 2);
assert_eq!(sessions[0].session_id, "b", "newest first: {sessions:?}");
let a = sessions.iter().find(|s| s.session_id == "a").expect("a");
assert_eq!(a.queries, vec!["first", "second"], "oldest turn first");
}
#[test]
fn sessions_without_stored_queries_are_absent_not_clean() {
let c = conn();
served(&c, Some("a"), None, "2026-01-01T00:00:00Z");
served(&c, Some("a"), None, "2026-01-01T00:00:01Z");
assert!(recent_sessions(&c, 10).expect("sessions").is_empty());
}
#[test]
fn a_single_turn_session_is_not_reported() {
let c = conn();
served(&c, Some("a"), Some("only turn"), "2026-01-01T00:00:00Z");
assert!(recent_sessions(&c, 10).expect("sessions").is_empty());
}
#[test]
fn turns_without_a_session_id_are_skipped() {
let c = conn();
served(&c, None, Some("a query"), "2026-01-01T00:00:00Z");
served(&c, None, Some("another"), "2026-01-01T00:00:01Z");
assert!(recent_sessions(&c, 10).expect("sessions").is_empty());
}
}