kimetsu_brain/drift.rs
1//! v2.6: has this session wandered off the thing it set out to do?
2//!
3//! Nautilus Compass reaches ROC AUC 0.83 detecting behavioural drift on real
4//! Claude Code traces using nothing but cosine similarity against a behavioural
5//! anchor — no model in the loop, no labels, no training. That is a Free-tier
6//! signal by construction, and Kimetsu already keeps a warm embedder for
7//! retrieval, so the marginal cost of computing it is one embedding per turn.
8//!
9//! ## What Kimetsu can and cannot see
10//!
11//! Being precise about this matters, because the paper's setting is not
12//! Kimetsu's. Compass reads agent *traces* — the actions the agent took. A
13//! memory sidecar sees no such thing. What Kimetsu has is the sequence of user
14//! prompts, recorded per session in `context.served`.
15//!
16//! So this measures how far a session has moved from the question it opened
17//! with, not whether an agent has drifted from its instructions. That is a
18//! weaker claim than the paper's, and it is the honest one: a session that
19//! opened on "fix the failing migration test" and is now three turns into
20//! Kubernetes networking has drifted, whatever the agent did in between.
21//!
22//! ## Why a memory system cares
23//!
24//! Because retrieval anchors on the session. When Kimetsu augments a query with
25//! ambient session context, a drifted session's opening turns stop being
26//! context and start being noise — the retrieval is being steered by a task
27//! nobody is working on any more. Knowing *where* the session turned is what
28//! makes it possible to stop doing that.
29//!
30//! Report-only for now, like `brain prune` and `brain audit` before it. A
31//! signal that silently re-anchors retrieval is a signal whose false positives
32//! are invisible, and this one has never been measured on a Kimetsu corpus.
33//!
34//! ## The shape of the signal
35//!
36//! Sustained, never instantaneous. A single tangential question is not drift —
37//! it is a question. [`detect`] requires the similarity to stay below the
38//! threshold for [`SUSTAINED_TURNS`] consecutive turns before it will say the
39//! session turned, which is the difference between an aside and a new topic.
40
41use rusqlite::Connection;
42
43use kimetsu_core::KimetsuResult;
44
45/// Cosine below which a turn is considered off-anchor.
46///
47/// Cosine between short unrelated English texts under the default embedder
48/// sits well under this; two phrasings of the same task sit well above it. It
49/// is a wide gap, chosen deliberately: the cost of a false positive here is
50/// telling a user their session drifted when it did not, and the signal has no
51/// measured operating point on a Kimetsu corpus to tune against.
52pub const OFF_ANCHOR_COSINE: f32 = 0.35;
53
54/// Consecutive off-anchor turns required before the session is called drifted.
55///
56/// One tangential question is a question. Three in a row is a different task.
57pub const SUSTAINED_TURNS: usize = 3;
58
59/// A session's prompts, oldest first.
60#[derive(Debug, Clone, PartialEq)]
61pub struct SessionQueries {
62 pub session_id: String,
63 pub queries: Vec<String>,
64}
65
66/// What [`analyze`] found in one session.
67#[derive(Debug, Clone, PartialEq)]
68pub struct DriftReport {
69 pub session_id: String,
70 /// Cosine of each turn against the anchor, in turn order. The anchor's own
71 /// entry is 1.0 and is included so indices line up with the prompts.
72 pub similarity: Vec<f32>,
73 /// Index of the first turn of the sustained run that turned the session,
74 /// or `None` when the session held its topic.
75 pub drifted_at: Option<usize>,
76}
77
78impl DriftReport {
79 pub fn drifted(&self) -> bool {
80 self.drifted_at.is_some()
81 }
82
83 /// How far the session got from its anchor at worst, in `[0, 1]` where 1.0
84 /// means it never left.
85 pub fn min_similarity(&self) -> f32 {
86 self.similarity
87 .iter()
88 .copied()
89 .fold(1.0f32, |acc, s| acc.min(s))
90 }
91}
92
93/// The first turn of the first sustained off-anchor run, if any.
94///
95/// `similarity[0]` is the anchor against itself and is skipped: a session
96/// cannot drift at the moment it starts.
97pub fn detect(similarity: &[f32], threshold: f32, sustained: usize) -> Option<usize> {
98 if sustained == 0 {
99 return None;
100 }
101 let mut run_start: Option<usize> = None;
102 for (idx, &sim) in similarity.iter().enumerate().skip(1) {
103 if sim < threshold {
104 let start = *run_start.get_or_insert(idx);
105 if idx + 1 - start >= sustained {
106 return Some(start);
107 }
108 } else {
109 run_start = None;
110 }
111 }
112 None
113}
114
115/// Score one session's turns against its opening turn.
116///
117/// The anchor is the session's first prompt — what it set out to do. Anchoring
118/// on a rolling window instead would let the session walk anywhere one step at
119/// a time without ever registering, which is exactly the failure being looked
120/// for.
121pub fn analyze(session_id: &str, embeddings: &[Vec<f32>]) -> DriftReport {
122 let Some(anchor) = embeddings.first() else {
123 return DriftReport {
124 session_id: session_id.to_string(),
125 similarity: Vec::new(),
126 drifted_at: None,
127 };
128 };
129 let similarity: Vec<f32> = embeddings
130 .iter()
131 .map(|e| crate::embeddings::cosine_similarity(anchor, e))
132 .collect();
133 let drifted_at = detect(&similarity, OFF_ANCHOR_COSINE, SUSTAINED_TURNS);
134 DriftReport {
135 session_id: session_id.to_string(),
136 similarity,
137 drifted_at,
138 }
139}
140
141/// Reconstruct recent sessions' prompts from the `context.served` log.
142///
143/// Sessions are returned newest-first by their last turn; the prompts within
144/// each are oldest-first, which is the order drift is measured in.
145///
146/// Only sessions whose queries were actually stored are returned. `[learning]
147/// store_queries = false` keeps a `query_hash` and drops the text, which is a
148/// deliberate privacy choice and leaves nothing to embed — such sessions are
149/// absent rather than reported as un-drifted.
150pub fn recent_sessions(conn: &Connection, limit: usize) -> KimetsuResult<Vec<SessionQueries>> {
151 let mut stmt = conn.prepare(
152 "SELECT payload_json, ts
153 FROM events
154 WHERE kind = 'context.served'
155 ORDER BY ts",
156 )?;
157 let rows = stmt
158 .query_map([], |row| {
159 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
160 })?
161 .collect::<Result<Vec<_>, _>>()?;
162
163 // Insertion order is turn order because the query is sorted by ts; the map
164 // preserves which session was seen last so the newest can be returned
165 // first without a second sort key.
166 let mut order: Vec<String> = Vec::new();
167 let mut by_session: std::collections::HashMap<String, Vec<String>> =
168 std::collections::HashMap::new();
169 for (payload_json, _ts) in rows {
170 let Ok(payload) = serde_json::from_str::<serde_json::Value>(&payload_json) else {
171 continue;
172 };
173 let Some(session_id) = payload
174 .get("session_id")
175 .and_then(serde_json::Value::as_str)
176 else {
177 continue; // a host with no session id has no session to measure
178 };
179 let Some(query) = payload
180 .get("query")
181 .and_then(serde_json::Value::as_str)
182 .filter(|q| !q.trim().is_empty())
183 else {
184 continue; // store_queries = false: nothing to embed
185 };
186 let entry = by_session.entry(session_id.to_string()).or_insert_with(|| {
187 order.push(session_id.to_string());
188 Vec::new()
189 });
190 entry.push(query.to_string());
191 }
192
193 let mut sessions: Vec<SessionQueries> = order
194 .into_iter()
195 .rev()
196 .filter_map(|session_id| {
197 by_session
198 .remove(&session_id)
199 .map(|queries| SessionQueries {
200 session_id,
201 queries,
202 })
203 })
204 .take(limit)
205 .collect();
206 // A one-turn session has an anchor and nothing to compare it against.
207 sessions.retain(|s| s.queries.len() > 1);
208 Ok(sessions)
209}
210
211#[cfg(test)]
212mod tests {
213 use super::*;
214
215 /// A session that stays on topic must not be flagged. False positives here
216 /// cost more than misses: the signal has no measured operating point yet.
217 #[test]
218 fn a_session_that_holds_its_topic_does_not_drift() {
219 let report = analyze("s", &[vec![1.0, 0.0], vec![0.95, 0.05], vec![0.9, 0.1]]);
220 assert!(!report.drifted(), "got: {report:?}");
221 assert!(report.min_similarity() > OFF_ANCHOR_COSINE);
222 }
223
224 /// A single tangential question is a question, not a new task.
225 #[test]
226 fn one_off_anchor_turn_is_not_drift() {
227 let similarity = [1.0, 0.9, 0.05, 0.9, 0.95];
228 assert_eq!(
229 detect(&similarity, OFF_ANCHOR_COSINE, SUSTAINED_TURNS),
230 None
231 );
232 }
233
234 /// Sustained is the whole definition: three in a row is a different task.
235 #[test]
236 fn a_sustained_run_marks_where_the_session_turned() {
237 let similarity = [1.0, 0.9, 0.05, 0.02, 0.01, 0.03];
238 assert_eq!(
239 detect(&similarity, OFF_ANCHOR_COSINE, SUSTAINED_TURNS),
240 Some(2),
241 "the run starts where it started, not where it was confirmed"
242 );
243 }
244
245 /// A run interrupted by a return to topic starts over, or a session that
246 /// dips in and out would eventually accumulate into a false positive.
247 #[test]
248 fn a_return_to_topic_resets_the_run() {
249 let similarity = [1.0, 0.05, 0.02, 0.9, 0.05, 0.02];
250 assert_eq!(
251 detect(&similarity, OFF_ANCHOR_COSINE, SUSTAINED_TURNS),
252 None
253 );
254 }
255
256 /// The anchor cannot drift from itself, and a session cannot drift at the
257 /// moment it opens.
258 #[test]
259 fn the_anchor_turn_is_never_the_drift_point() {
260 let similarity = [0.0, 0.0, 0.0, 0.0];
261 assert_eq!(
262 detect(&similarity, OFF_ANCHOR_COSINE, SUSTAINED_TURNS),
263 Some(1)
264 );
265 }
266
267 #[test]
268 fn an_empty_session_reports_nothing() {
269 let report = analyze("s", &[]);
270 assert!(!report.drifted());
271 assert!(report.similarity.is_empty());
272 assert_eq!(report.min_similarity(), 1.0);
273 }
274
275 /// Anchoring on the opening turn, not on a rolling window: a session that
276 /// walks away one small step at a time is exactly the case a rolling anchor
277 /// would never catch.
278 #[test]
279 fn a_slow_walk_away_from_the_opening_turn_is_still_drift() {
280 let steps = [
281 vec![1.0f32, 0.0],
282 vec![0.8, 0.6],
283 vec![0.3, 0.95],
284 vec![0.1, 0.99],
285 vec![0.0, 1.0],
286 ];
287 let report = analyze("s", &steps);
288 assert!(report.drifted(), "got: {report:?}");
289 }
290
291 // ── Reconstructing sessions from the log ─────────────────────────────
292
293 fn served(conn: &Connection, session_id: Option<&str>, query: Option<&str>, ts: &str) {
294 let mut payload = serde_json::Map::new();
295 if let Some(sid) = session_id {
296 payload.insert("session_id".into(), serde_json::json!(sid));
297 }
298 if let Some(q) = query {
299 payload.insert("query".into(), serde_json::json!(q));
300 }
301 conn.execute(
302 "INSERT INTO events (event_id, run_id, ts, kind, schema_version, payload_json)
303 VALUES (?1, 'r', ?2, 'context.served', 1, ?3)",
304 rusqlite::params![
305 kimetsu_core::ids::new_id().to_string(),
306 ts,
307 serde_json::Value::Object(payload).to_string()
308 ],
309 )
310 .expect("insert event");
311 }
312
313 fn conn() -> Connection {
314 let conn = Connection::open_in_memory().expect("open");
315 crate::schema::initialize(&conn).expect("schema");
316 conn
317 }
318
319 #[test]
320 fn turns_come_back_in_order_grouped_by_session() {
321 let c = conn();
322 served(&c, Some("a"), Some("first"), "2026-01-01T00:00:00Z");
323 served(&c, Some("b"), Some("other"), "2026-01-01T00:00:01Z");
324 served(&c, Some("a"), Some("second"), "2026-01-01T00:00:02Z");
325 served(&c, Some("b"), Some("other two"), "2026-01-01T00:00:03Z");
326
327 let sessions = recent_sessions(&c, 10).expect("sessions");
328 assert_eq!(sessions.len(), 2);
329 assert_eq!(sessions[0].session_id, "b", "newest first: {sessions:?}");
330 let a = sessions.iter().find(|s| s.session_id == "a").expect("a");
331 assert_eq!(a.queries, vec!["first", "second"], "oldest turn first");
332 }
333
334 /// `store_queries = false` is a deliberate privacy choice that leaves
335 /// nothing to embed. Such a session is absent, not reported as un-drifted.
336 #[test]
337 fn sessions_without_stored_queries_are_absent_not_clean() {
338 let c = conn();
339 served(&c, Some("a"), None, "2026-01-01T00:00:00Z");
340 served(&c, Some("a"), None, "2026-01-01T00:00:01Z");
341 assert!(recent_sessions(&c, 10).expect("sessions").is_empty());
342 }
343
344 /// A one-turn session has an anchor and nothing to compare it against.
345 #[test]
346 fn a_single_turn_session_is_not_reported() {
347 let c = conn();
348 served(&c, Some("a"), Some("only turn"), "2026-01-01T00:00:00Z");
349 assert!(recent_sessions(&c, 10).expect("sessions").is_empty());
350 }
351
352 /// A host that reports no session id has no session to measure.
353 #[test]
354 fn turns_without_a_session_id_are_skipped() {
355 let c = conn();
356 served(&c, None, Some("a query"), "2026-01-01T00:00:00Z");
357 served(&c, None, Some("another"), "2026-01-01T00:00:01Z");
358 assert!(recent_sessions(&c, 10).expect("sessions").is_empty());
359 }
360}