Skip to main content

kimetsu_brain/
user_profile.rs

1//! v2.6: the user's standing preferences, delivered unconditionally.
2//!
3//! Preference following is Kimetsu's second-weakest measured ability
4//! (LongMemEval 66.7%), and the benchmark page already diagnoses why: *"a
5//! preference is a small aside semantically far from the question."*
6//!
7//! That diagnosis rules out the obvious fix. If "I prefer `thiserror` for
8//! library errors" is semantically distant from "add error handling to the
9//! parser", then no amount of re-ranking surfaces it — the candidate never
10//! enters the pool. Boosting preference-kind memories, adding a profile term to
11//! the composite score, tuning the floors: all of them operate on candidates
12//! retrieval already found, and this one it did not.
13//!
14//! So the profile does not go through retrieval. It rides the warm start,
15//! which every host now receives, and is therefore in context before the first
16//! question is asked — which is what a standing preference *is*. PPRO
17//! (arXiv 2607.00017) reaches the same conclusion from the other direction: it
18//! derives a user profile from accumulated memories and uses it as an explicit
19//! prior rather than as one more retrieval signal.
20//!
21//! ## What counts as a preference
22//!
23//! `MemoryKind::Preference` memories, in either the project brain or the
24//! cross-project user brain. Ranked by proven usefulness, then recency, and
25//! hard-capped — a profile that grows without bound stops being a profile and
26//! becomes a second corpus in every prompt.
27//!
28//! Model-free: this is a `SELECT` and a budget.
29
30use kimetsu_core::KimetsuResult;
31use rusqlite::Connection;
32
33/// How many preferences the profile may carry.
34///
35/// Small on purpose. This is injected on every session, so its cost is paid
36/// unconditionally; the top handful of proven preferences is what makes the
37/// difference, and the tail is what makes users turn warm start off.
38pub const MAX_PREFERENCES: usize = 8;
39
40/// Character budget for the rendered block (~100 tokens), on the same footing
41/// as the digest's ~400.
42pub const PROFILE_CHAR_BUDGET: usize = 400;
43
44/// Longest single preference kept, before it is skipped as an essay rather
45/// than a preference.
46const MAX_PREFERENCE_CHARS: usize = 160;
47
48/// One standing preference.
49#[derive(Debug, Clone, PartialEq)]
50pub struct Preference {
51    pub memory_id: String,
52    pub text: String,
53    /// True when this came from the cross-project user brain rather than this
54    /// project — worth knowing, because a global preference should not be
55    /// silently overridden by a project convention without the reader noticing.
56    pub global: bool,
57}
58
59/// Read the user's standing preferences from one brain, strongest first.
60///
61/// Ordered by proven usefulness before recency: a preference the agent has
62/// actually been rewarded for following outranks one stated yesterday.
63pub fn preferences(
64    conn: &Connection,
65    global: bool,
66    limit: usize,
67) -> KimetsuResult<Vec<Preference>> {
68    let mut stmt = conn.prepare(
69        "SELECT memory_id, text
70         FROM memories
71         WHERE kind = 'preference'
72           AND invalidated_at IS NULL
73           AND superseded_by IS NULL
74           AND (valid_to IS NULL OR valid_to > datetime('now'))
75         ORDER BY usefulness_score DESC, created_at DESC
76         LIMIT ?1",
77    )?;
78    let rows = stmt
79        .query_map(rusqlite::params![limit as i64], |row| {
80            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
81        })?
82        .collect::<Result<Vec<_>, _>>()?;
83    Ok(rows
84        .into_iter()
85        .map(|(memory_id, text)| Preference {
86            memory_id,
87            text,
88            global,
89        })
90        .collect())
91}
92
93/// Merge project and global preferences into one profile.
94///
95/// Project preferences lead: a preference stated for *this* repo is more
96/// specific than one carried across every project, and specificity should win
97/// when the budget runs out.
98pub fn build_profile(
99    project_conn: &Connection,
100    user_conn: Option<&Connection>,
101) -> KimetsuResult<Vec<Preference>> {
102    let mut profile = preferences(project_conn, false, MAX_PREFERENCES)?;
103    if let Some(user_conn) = user_conn {
104        let remaining = MAX_PREFERENCES.saturating_sub(profile.len());
105        if remaining > 0 {
106            let global = preferences(user_conn, true, remaining)?;
107            // A global preference whose text duplicates a project one adds
108            // nothing but tokens.
109            for pref in global {
110                if !profile.iter().any(|p| p.text == pref.text) {
111                    profile.push(pref);
112                }
113            }
114        }
115    }
116    Ok(profile)
117}
118
119/// Render the profile as a warm-start section, or `None` when there is nothing
120/// to say.
121///
122/// Framed as instructions rather than as retrieved facts, because that is what
123/// they are: the reader should follow them without being asked, which is the
124/// whole difference between a preference and a memory.
125pub fn render_profile(preferences: &[Preference]) -> Option<String> {
126    if preferences.is_empty() {
127        return None;
128    }
129    let mut lines = Vec::new();
130    let mut used = 0usize;
131    for pref in preferences {
132        let text = pref.text.trim();
133        if text.is_empty() || text.chars().count() > MAX_PREFERENCE_CHARS {
134            continue;
135        }
136        let line = if pref.global {
137            format!("- {text} (across all your projects)")
138        } else {
139            format!("- {text}")
140        };
141        if used + line.len() > PROFILE_CHAR_BUDGET {
142            break;
143        }
144        used += line.len();
145        lines.push(line);
146    }
147    if lines.is_empty() {
148        return None;
149    }
150    Some(format!(
151        "Standing preferences — follow these without being asked:\n{}",
152        lines.join("\n")
153    ))
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159
160    fn conn() -> Connection {
161        let conn = Connection::open_in_memory().expect("open");
162        crate::schema::initialize(&conn).expect("schema");
163        conn
164    }
165
166    fn insert(conn: &Connection, id: &str, kind: &str, text: &str, usefulness: f32) {
167        conn.execute(
168            "INSERT INTO memories
169             (memory_id, scope, kind, text, normalized_text, confidence,
170              provenance_snapshot_json, created_at, usefulness_score)
171             VALUES (?1, 'project', ?2, ?3, ?3, 0.9, '{}', '2026-01-01T00:00:00Z', ?4)",
172            rusqlite::params![id, kind, text, usefulness],
173        )
174        .expect("insert");
175    }
176
177    #[test]
178    fn only_preference_memories_make_the_profile() {
179        let c = conn();
180        insert(
181            &c,
182            "p",
183            "preference",
184            "prefer thiserror for library errors",
185            0.0,
186        );
187        insert(&c, "c", "convention", "always run cargo fmt", 0.0);
188        insert(&c, "f", "fact", "the schema is at v11", 0.0);
189
190        let profile = preferences(&c, false, 10).expect("preferences");
191        assert_eq!(profile.len(), 1, "got: {profile:?}");
192        assert!(profile[0].text.contains("thiserror"));
193    }
194
195    /// A preference the agent has been rewarded for following outranks one
196    /// merely stated.
197    #[test]
198    fn proven_preferences_come_first() {
199        let c = conn();
200        insert(&c, "unproven", "preference", "prefer tabs", 0.0);
201        insert(&c, "proven", "preference", "prefer thiserror", 5.0);
202        let profile = preferences(&c, false, 10).expect("preferences");
203        assert_eq!(profile[0].memory_id, "proven", "got: {profile:?}");
204    }
205
206    #[test]
207    fn retired_preferences_are_excluded() {
208        let c = conn();
209        insert(&c, "live", "preference", "prefer thiserror", 0.0);
210        insert(&c, "dead", "preference", "prefer failure crate", 0.0);
211        c.execute(
212            "UPDATE memories SET invalidated_at = '2026-02-01T00:00:00Z' WHERE memory_id = 'dead'",
213            [],
214        )
215        .unwrap();
216        let profile = preferences(&c, false, 10).expect("preferences");
217        assert_eq!(profile.len(), 1);
218        assert_eq!(profile[0].memory_id, "live");
219    }
220
221    /// Specificity wins when the budget runs out: a preference stated for this
222    /// repo beats one carried across every project.
223    #[test]
224    fn project_preferences_lead_and_globals_fill_the_remainder() {
225        let project = conn();
226        let user = conn();
227        insert(&project, "proj", "preference", "prefer thiserror here", 0.0);
228        insert(
229            &user,
230            "glob",
231            "preference",
232            "prefer 2-space indent everywhere",
233            0.0,
234        );
235
236        let profile = build_profile(&project, Some(&user)).expect("profile");
237        assert_eq!(profile.len(), 2);
238        assert!(!profile[0].global, "project first: {profile:?}");
239        assert!(profile[1].global);
240    }
241
242    #[test]
243    fn a_global_duplicate_is_not_repeated() {
244        let project = conn();
245        let user = conn();
246        insert(&project, "proj", "preference", "prefer thiserror", 0.0);
247        insert(&user, "glob", "preference", "prefer thiserror", 0.0);
248        let profile = build_profile(&project, Some(&user)).expect("profile");
249        assert_eq!(profile.len(), 1, "got: {profile:?}");
250    }
251
252    #[test]
253    fn the_profile_is_capped() {
254        let c = conn();
255        for i in 0..(MAX_PREFERENCES * 3) {
256            insert(
257                &c,
258                &format!("p{i}"),
259                "preference",
260                &format!("preference {i}"),
261                0.0,
262            );
263        }
264        let profile = build_profile(&c, None).expect("profile");
265        assert_eq!(profile.len(), MAX_PREFERENCES);
266    }
267
268    // ── Rendering ────────────────────────────────────────────────────────
269
270    #[test]
271    fn an_empty_profile_renders_nothing() {
272        assert!(render_profile(&[]).is_none());
273    }
274
275    /// Framed as instructions, not as retrieved facts — following them without
276    /// being asked is the whole difference between a preference and a memory.
277    #[test]
278    fn the_profile_reads_as_instructions() {
279        let rendered = render_profile(&[Preference {
280            memory_id: "p".into(),
281            text: "prefer thiserror for library errors".into(),
282            global: false,
283        }])
284        .expect("rendered");
285        assert!(
286            rendered.contains("follow these without being asked"),
287            "got: {rendered}"
288        );
289        assert!(rendered.contains("thiserror"));
290    }
291
292    #[test]
293    fn a_global_preference_is_marked_as_such() {
294        let rendered = render_profile(&[Preference {
295            memory_id: "p".into(),
296            text: "prefer 2-space indent".into(),
297            global: true,
298        }])
299        .expect("rendered");
300        assert!(
301            rendered.contains("across all your projects"),
302            "got: {rendered}"
303        );
304    }
305
306    /// An essay is not a preference, and a runaway profile is what makes users
307    /// turn warm start off.
308    #[test]
309    fn overlong_preferences_are_skipped_and_the_block_is_budgeted() {
310        let essay = Preference {
311            memory_id: "long".into(),
312            text: "x".repeat(MAX_PREFERENCE_CHARS + 1),
313            global: false,
314        };
315        assert!(
316            render_profile(std::slice::from_ref(&essay)).is_none(),
317            "a 160+ char 'preference' is an essay"
318        );
319
320        let many: Vec<Preference> = (0..MAX_PREFERENCES)
321            .map(|i| Preference {
322                memory_id: format!("p{i}"),
323                text: "prefer something quite specific and reasonably wordy".repeat(2),
324                global: false,
325            })
326            .collect();
327        let rendered = render_profile(&many).expect("rendered");
328        assert!(
329            rendered.len() <= PROFILE_CHAR_BUDGET + 80,
330            "block must stay budgeted: {} chars",
331            rendered.len()
332        );
333    }
334}