Skip to main content

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}