Skip to main content

kimetsu_brain/
blame.rs

1//! Per-run memory attribution (blame) + usefulness leaderboard (top).
2//! Split out of `project.rs` (v2.5.1); re-exported by [`crate::project`].
3
4use std::path::Path;
5
6use kimetsu_core::KimetsuResult;
7use rusqlite::{Connection, OptionalExtension, params};
8
9use crate::project::*;
10use crate::user_brain;
11
12// v0.5.1: blame surface — per-run memory attribution. Both the CLI
13// (`kimetsu brain memory blame <run-id>`) and the MCP tool
14// (`kimetsu_brain_memory_blame`) consume `BlameReport`.
15
16#[derive(Debug, Clone, serde::Serialize)]
17pub struct BlameReport {
18    pub run_id: String,
19    /// Terminal outcome of the run: "success" (run.finished),
20    /// "failed" (run.failed), "aborted" (run.aborted), or "unknown"
21    /// (no terminal event found yet).
22    pub outcome: String,
23    /// Failure category when outcome is "failed" (e.g. "Gate",
24    /// "Implementation"). None otherwise.
25    pub failure_category: Option<String>,
26    /// Memories the model explicitly cited via `cite_memory`,
27    /// ordered by turn.
28    pub cited: Vec<CitedMemory>,
29    /// Memories that were retrieved into the run's context but
30    /// never cited. They got the weak ±0.1 signal instead of ±1.0.
31    pub silent_passengers: Vec<SilentMemory>,
32}
33
34#[derive(Debug, Clone, serde::Serialize)]
35pub struct CitedMemory {
36    pub memory_id: String,
37    pub turn: i64,
38    pub rationale: Option<String>,
39    pub cited_at: String,
40    /// Truncated memory text for human-readable output.
41    pub text_preview: String,
42    pub scope: String,
43    pub kind: String,
44}
45
46#[derive(Debug, Clone, serde::Serialize)]
47pub struct SilentMemory {
48    pub memory_id: String,
49    pub text_preview: String,
50    pub scope: String,
51    pub kind: String,
52}
53
54/// `BlameReport` that surfaces which memories the model actually
55/// reasoned with vs which were silent passengers.
56///
57/// Lookups across user + project brains are merged so a cited
58/// user-scope memory shows its text even when the run lived in a
59/// project brain.
60pub fn blame_run(start: &Path, run_id: &str) -> KimetsuResult<BlameReport> {
61    let (_paths, config, conn) = load_project(start)?;
62    // W3.3: honor config.kimetsu.use_user_brain with env override.
63
64    let user_conn = user_brain::open_user_brain_readonly_for_config(config.kimetsu.use_user_brain)?;
65
66    // 1. Terminal outcome.
67    let (outcome, failure_category) = run_outcome(&conn, run_id)?;
68
69    // 2. Cited memories — ordered by turn.
70    let cited_rows: Vec<(String, i64, Option<String>, String)> = {
71        let mut stmt = conn.prepare(
72            "
73            SELECT memory_id, turn, rationale, cited_at
74            FROM memory_citations
75            WHERE run_id = ?1
76            ORDER BY turn ASC, cited_at ASC
77            ",
78        )?;
79        let rows = stmt.query_map(rusqlite::params![run_id], |row| {
80            Ok((
81                row.get::<_, String>(0)?,
82                row.get::<_, i64>(1)?,
83                row.get::<_, Option<String>>(2)?,
84                row.get::<_, String>(3)?,
85            ))
86        })?;
87        let mut out = Vec::new();
88        for row in rows {
89            out.push(row?);
90        }
91        out
92    };
93
94    let mut cited: Vec<CitedMemory> = Vec::with_capacity(cited_rows.len());
95    let mut cited_set: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
96    for (memory_id, turn, rationale, cited_at) in cited_rows {
97        cited_set.insert(memory_id.clone());
98        let (text, scope, kind) = resolve_memory(&conn, user_conn.as_ref(), &memory_id);
99        cited.push(CitedMemory {
100            memory_id,
101            turn,
102            rationale,
103            cited_at,
104            text_preview: text_preview(&text, 120),
105            scope,
106            kind,
107        });
108    }
109
110    // 3. Silent passengers — retrieved but not cited.
111    let retrieved_ids = collect_injected_memory_ids_for_blame(&conn, run_id)?;
112    let mut silent: Vec<SilentMemory> = Vec::new();
113    for memory_id in retrieved_ids {
114        if cited_set.contains(&memory_id) {
115            continue;
116        }
117        let (text, scope, kind) = resolve_memory(&conn, user_conn.as_ref(), &memory_id);
118        silent.push(SilentMemory {
119            memory_id,
120            text_preview: text_preview(&text, 120),
121            scope,
122            kind,
123        });
124    }
125
126    Ok(BlameReport {
127        run_id: run_id.to_string(),
128        outcome,
129        failure_category,
130        cited,
131        silent_passengers: silent,
132    })
133}
134
135fn run_outcome(conn: &Connection, run_id: &str) -> KimetsuResult<(String, Option<String>)> {
136    // Pull the most recent terminal event for the run, if any.
137    let row: Option<(String, String)> = conn
138        .query_row(
139            "
140            SELECT kind, payload_json
141            FROM events
142            WHERE run_id = ?1
143              AND kind IN ('run.finished', 'run.failed', 'run.aborted')
144            ORDER BY ts DESC
145            LIMIT 1
146            ",
147            rusqlite::params![run_id],
148            |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
149        )
150        .optional()?;
151    Ok(match row {
152        Some((kind, payload_json)) => {
153            let outcome = match kind.as_str() {
154                "run.finished" => "success".to_string(),
155                "run.failed" => "failed".to_string(),
156                "run.aborted" => "aborted".to_string(),
157                other => other.to_string(),
158            };
159            let category = if kind == "run.failed" {
160                serde_json::from_str::<serde_json::Value>(&payload_json)
161                    .ok()
162                    .and_then(|v| {
163                        v.get("category")
164                            .and_then(|c| c.as_str())
165                            .map(str::to_string)
166                    })
167            } else {
168                None
169            };
170            (outcome, category)
171        }
172        None => ("unknown".to_string(), None),
173    })
174}
175
176fn collect_injected_memory_ids_for_blame(
177    conn: &Connection,
178    run_id: &str,
179) -> KimetsuResult<Vec<String>> {
180    let mut stmt = conn.prepare(
181        "
182        SELECT payload_json
183        FROM events
184        WHERE run_id = ?1 AND kind = 'context.injected'
185        ",
186    )?;
187    let rows = stmt.query_map(rusqlite::params![run_id], |row| row.get::<_, String>(0))?;
188    let mut seen = std::collections::BTreeSet::new();
189    for row in rows {
190        let payload_json = row?;
191        let payload: serde_json::Value = serde_json::from_str(&payload_json)?;
192        if let Some(ids) = payload.get("memory_ids").and_then(|v| v.as_array()) {
193            for id in ids {
194                if let Some(s) = id.as_str()
195                    && !s.is_empty()
196                {
197                    seen.insert(s.to_string());
198                }
199            }
200        }
201    }
202    Ok(seen.into_iter().collect())
203}
204
205/// Look up a memory's (text, scope, kind) across the project conn
206/// and the optional user-brain conn. Returns
207/// ("<unknown — deleted?>", "", "") when the row isn't found in
208/// either DB (e.g. invalidated + GC'd, or a typo'd memory_id in
209/// the citation).
210fn resolve_memory(
211    project_conn: &Connection,
212    user_conn: Option<&Connection>,
213    memory_id: &str,
214) -> (String, String, String) {
215    let q = "SELECT text, scope, kind FROM memories WHERE memory_id = ?1";
216    let try_conn = |conn: &Connection| -> Option<(String, String, String)> {
217        conn.query_row(q, rusqlite::params![memory_id], |row| {
218            Ok((
219                row.get::<_, String>(0)?,
220                row.get::<_, String>(1)?,
221                row.get::<_, String>(2)?,
222            ))
223        })
224        .optional()
225        .ok()
226        .flatten()
227    };
228    try_conn(project_conn)
229        .or_else(|| user_conn.and_then(try_conn))
230        .unwrap_or_else(|| {
231            (
232                "<unknown — deleted or invalid memory_id>".to_string(),
233                String::new(),
234                String::new(),
235            )
236        })
237}
238
239fn text_preview(text: &str, max_chars: usize) -> String {
240    let trimmed = text.trim();
241    if trimmed.chars().count() <= max_chars {
242        trimmed.to_string()
243    } else {
244        let head: String = trimmed.chars().take(max_chars).collect();
245        format!("{head}…")
246    }
247}
248
249/// MP-6: ranked list of memories sorted by the same usefulness ratio the
250/// broker uses for retrieval scoring (`usefulness_score / use_count`).
251/// Filters out invalidated rows and any memory with `use_count < min_uses`
252/// (the small-sample guard; default 3 matches the broker's
253/// SMALL_SAMPLE_THRESHOLD). Optional scope filter narrows to a single
254/// memory class. Lets the user see which memories are actually doing
255/// work so they can prune the rest with `memory prune`.
256#[derive(Debug, Clone, Default)]
257pub struct TopOptions {
258    pub scope: Option<String>,
259    pub min_uses: u32,
260    pub limit: u32,
261}
262
263pub fn list_memories_top(start: &Path, opts: TopOptions) -> KimetsuResult<Vec<MemoryRow>> {
264    let (_paths, _config, conn) = load_project(start)?;
265    let min_uses = opts.min_uses.max(1) as i64;
266    let limit = if opts.limit == 0 { 20 } else { opts.limit } as i64;
267
268    let (sql, scope_param): (&str, Option<String>) = if let Some(scope) = opts.scope.as_deref() {
269        (
270            "
271            SELECT memory_id, scope, kind, text, confidence, use_count, usefulness_score
272            FROM memories
273            WHERE invalidated_at IS NULL
274              AND superseded_by IS NULL
275              AND use_count >= ?1
276              AND lower(scope) = lower(?2)
277            ORDER BY (usefulness_score / CAST(use_count AS REAL)) DESC, use_count DESC
278            LIMIT ?3
279            ",
280            Some(scope.to_string()),
281        )
282    } else {
283        (
284            "
285            SELECT memory_id, scope, kind, text, confidence, use_count, usefulness_score
286            FROM memories
287            WHERE invalidated_at IS NULL
288              AND superseded_by IS NULL
289              AND use_count >= ?1
290            ORDER BY (usefulness_score / CAST(use_count AS REAL)) DESC, use_count DESC
291            LIMIT ?2
292            ",
293            None,
294        )
295    };
296
297    let mut stmt = conn.prepare(sql)?;
298    let mut rows = if let Some(scope) = scope_param {
299        stmt.query_map(params![min_uses, scope, limit], map_memory_row)?
300            .collect::<Result<Vec<_>, _>>()?
301    } else {
302        stmt.query_map(params![min_uses, limit], map_memory_row)?
303            .collect::<Result<Vec<_>, _>>()?
304    };
305
306    // SQLite's NaN-from-zero protection: a freshly-created memory with
307    // use_count=0 would division-zero, but the WHERE clause guards
308    // min_uses >= 1, so we never see a NaN here. Sort is a defensive
309    // tie-breaker only.
310    rows.sort_by(|a, b| {
311        let ra = a.usefulness_score as f64 / a.use_count.max(1) as f64;
312        let rb = b.usefulness_score as f64 / b.use_count.max(1) as f64;
313        rb.partial_cmp(&ra).unwrap_or(std::cmp::Ordering::Equal)
314    });
315    Ok(rows)
316}
317
318pub(crate) fn map_memory_row(row: &rusqlite::Row) -> rusqlite::Result<MemoryRow> {
319    Ok(MemoryRow {
320        memory_id: row.get(0)?,
321        scope: row.get(1)?,
322        kind: row.get(2)?,
323        text: row.get(3)?,
324        confidence: row.get(4)?,
325        use_count: row.get(5)?,
326        usefulness_score: row.get::<_, f64>(6)? as f32,
327    })
328}