Skip to main content

kimetsu_brain/
ordering.rs

1//! v2.6: answering "which came first".
2//!
3//! Event ordering is Kimetsu's worst measured ability by a wide margin — 32.5%
4//! on BEAM 100K, 30% at 1M — and the reason is visible the moment you look at
5//! what the reader actually receives.
6//!
7//! Memories carry `created_at`. Capsules do not. The broker renders the bundle
8//! as an unordered set, sorted by relevance, with no timestamps anywhere in the
9//! text. So a reader asked "did we switch to thiserror before or after the
10//! schema migration?" is handed two memories in score order, carrying no dates,
11//! and has nothing to order them *by*. It guesses. At two events that is a coin
12//! flip, which is roughly what 30% looks like.
13//!
14//! Nothing about retrieval is wrong here; the information exists and is
15//! selected. It is thrown away at render time.
16//!
17//! ## What this does
18//!
19//! When the query is asking about order, the bundle is re-rendered
20//! chronologically with each memory's date attached. Relevance still decides
21//! *which* memories are selected — this changes presentation, not selection, so
22//! it cannot drop a capsule the broker chose or admit one it rejected.
23//!
24//! Deliberately narrow. Timestamping every capsule on every query would spend
25//! tokens on the large majority of questions that are not about time, and
26//! reordering a normal bundle away from relevance order would bury the best
27//! answer. So [`is_ordering_query`] gates it on the question actually asking.
28
29use crate::context::ContextCapsule;
30
31/// Words that mark a question as being about sequence or time.
32///
33/// Matched on whole words against the lowercased query. Kept to terms whose
34/// presence really does signal an ordering question — "when", "before",
35/// "after", "first" — rather than anything vaguely temporal, because a false
36/// positive reorders a bundle away from relevance for no reason.
37const ORDERING_MARKERS: &[&str] = &[
38    "before",
39    "after",
40    "first",
41    "last",
42    "latest",
43    "earliest",
44    "earlier",
45    "later",
46    "order",
47    "ordering",
48    "sequence",
49    "chronological",
50    "chronologically",
51    "timeline",
52    "when",
53    "then",
54    "initially",
55    "originally",
56    "eventually",
57    "previously",
58    "subsequently",
59    "recent",
60    "recently",
61    "since",
62    "until",
63    "history",
64];
65
66/// True when the query is asking about order or time.
67///
68/// `_` counts as a word character, so an identifier like `ordering_service`
69/// stays one token and does not trip the check — matching how
70/// `context::content_tokens` splits, and avoiding the false positive where
71/// refactoring a module named after a marker reorders the whole bundle.
72pub fn is_ordering_query(query: &str) -> bool {
73    let lower = query.to_ascii_lowercase();
74    lower
75        .split(|c: char| !c.is_ascii_alphanumeric() && c != '_')
76        .any(|word| ORDERING_MARKERS.contains(&word))
77}
78
79/// The date part of an RFC 3339 timestamp (`2026-03-01T…` → `2026-03-01`).
80///
81/// Day granularity on purpose: it is what an ordering question is about, and a
82/// full timestamp per capsule would cost tokens to say the same thing.
83fn date_of(created_at: &str) -> &str {
84    created_at.split('T').next().unwrap_or(created_at)
85}
86
87/// Insert `date` into a capsule summary, in front of the text.
88///
89/// A memory capsule's summary is `"scope:kind - text"`, and the context hook
90/// renders only the part after the first `" - "` so the reader is not shown
91/// Kimetsu's internal taxonomy. A date placed in front of the whole summary
92/// would therefore be stripped by exactly the readability step it is meant to
93/// survive — so it goes in front of the *text*, which is where the reader is
94/// looking anyway. Summaries without that prefix (any future capsule shape)
95/// get it at the front, which is the same position relative to their text.
96fn dated_summary(summary: &str, date: &str) -> String {
97    match summary.split_once(" - ") {
98        Some((prefix, text)) => format!("{prefix} - [{date}] {text}"),
99        None => format!("[{date}] {summary}"),
100    }
101}
102
103/// Re-render `capsules` chronologically, dating each one.
104///
105/// `created_at` maps a capsule's `expansion_handle` to its RFC 3339 creation
106/// time. A capsule with no entry — a repo file or manifest, which has no
107/// position in the memory timeline — keeps its relative order after the dated
108/// ones rather than being dropped or given a fabricated date.
109///
110/// Returns the capsules in chronological order, oldest first, because that is
111/// the order the question is about.
112pub fn render_chronologically(
113    capsules: Vec<ContextCapsule>,
114    created_at: &std::collections::HashMap<String, String>,
115) -> Vec<ContextCapsule> {
116    let mut dated: Vec<(String, ContextCapsule)> = Vec::new();
117    let mut undated: Vec<ContextCapsule> = Vec::new();
118
119    for capsule in capsules {
120        match created_at.get(&capsule.expansion_handle) {
121            Some(ts) => dated.push((ts.clone(), capsule)),
122            None => undated.push(capsule),
123        }
124    }
125
126    // Stable sort: equal timestamps keep the broker's relevance order, so a
127    // batch of memories written in the same second stays sensibly ranked.
128    dated.sort_by(|a, b| a.0.cmp(&b.0));
129
130    let mut out: Vec<ContextCapsule> = dated
131        .into_iter()
132        .map(|(ts, mut capsule)| {
133            capsule.summary = dated_summary(&capsule.summary, date_of(&ts));
134            // The date is real tokens, so account for them.
135            capsule.token_estimate = capsule.token_estimate.saturating_add(4);
136            capsule
137        })
138        .collect();
139    out.append(&mut undated);
140    out
141}
142
143/// A one-line preamble telling the reader the bundle is in time order.
144///
145/// Without it a chronological bundle looks like a relevance-ranked one whose
146/// ranking has gone wrong.
147pub const CHRONOLOGICAL_NOTE: &str =
148    "These memories are in chronological order, oldest first, with the date each was recorded.";
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153    use std::collections::HashMap;
154
155    fn capsule(handle: &str, summary: &str) -> ContextCapsule {
156        ContextCapsule {
157            id: String::new(),
158            kind: "memory".to_string(),
159            summary: summary.to_string(),
160            token_estimate: 10,
161            expansion_handle: handle.to_string(),
162            provenance: Vec::new(),
163            confidence: 0.9,
164            freshness: 0.5,
165            relevance: 0.0,
166            scope_weight: 0.9,
167            score: 0.5,
168            superseded_hint: false,
169            rerank_policy_tier: 0,
170            claim_revision: None,
171            facts: vec![],
172            rerank_usefulness: None,
173            rerank_trust: None,
174        }
175    }
176
177    fn handles(capsules: &[ContextCapsule]) -> Vec<&str> {
178        capsules
179            .iter()
180            .map(|c| c.expansion_handle.as_str())
181            .collect()
182    }
183
184    #[test]
185    fn ordering_questions_are_recognised() {
186        for query in [
187            "did we switch to thiserror before or after the migration",
188            "what came first, the parser or the lexer",
189            "when did we adopt edition 2024",
190            "show me the timeline of schema changes",
191            "what did we do most recently",
192        ] {
193            assert!(is_ordering_query(query), "should be ordering: {query:?}");
194        }
195    }
196
197    /// A false positive reorders a bundle away from relevance for no reason,
198    /// so ordinary questions must not trip it.
199    #[test]
200    fn ordinary_questions_are_not_ordering_questions() {
201        for query in [
202            "how do I checkpoint the wal",
203            "add error handling to the parser",
204            "why does the build fail",
205            "what is the schema version",
206        ] {
207            assert!(
208                !is_ordering_query(query),
209                "should not be ordering: {query:?}"
210            );
211        }
212    }
213
214    /// Matching must be on whole words: "afterthought" is not "after".
215    #[test]
216    fn markers_match_whole_words_only() {
217        assert!(!is_ordering_query("this was an afterthought"));
218        assert!(!is_ordering_query("refactor the ordering_service module"));
219        assert!(is_ordering_query("refactor the ordering service"));
220    }
221
222    /// The fix itself: relevance chose the memories, time decides how they are
223    /// shown, and each carries the date the reader needs to compare.
224    #[test]
225    fn capsules_are_reordered_by_time_and_dated() {
226        let mut created = HashMap::new();
227        created.insert("memory:b".to_string(), "2026-01-15T10:00:00Z".to_string());
228        created.insert("memory:a".to_string(), "2026-06-01T09:00:00Z".to_string());
229
230        // Broker order is by relevance: `a` first.
231        let ordered = render_chronologically(
232            vec![
233                capsule("memory:a", "project:fact - switched to thiserror"),
234                capsule("memory:b", "project:fact - migrated the schema"),
235            ],
236            &created,
237        );
238
239        assert_eq!(
240            handles(&ordered),
241            vec!["memory:b", "memory:a"],
242            "oldest first"
243        );
244        assert_eq!(
245            ordered[0].summary,
246            "project:fact - [2026-01-15] migrated the schema"
247        );
248        assert_eq!(
249            ordered[1].summary,
250            "project:fact - [2026-06-01] switched to thiserror"
251        );
252    }
253
254    /// The context hook renders only the part of a summary after the first
255    /// `" - "`. A date the reader never sees orders nothing, so the date has to
256    /// survive that step — this is the regression that made it a suffix of the
257    /// prefix rather than a prefix of the whole line.
258    #[test]
259    fn the_date_survives_the_hooks_summary_stripping() {
260        let mut created = HashMap::new();
261        created.insert("memory:a".to_string(), "2026-01-15T10:00:00Z".to_string());
262        let ordered = render_chronologically(
263            vec![capsule("memory:a", "project:fact - switched to thiserror")],
264            &created,
265        );
266        let shown = ordered[0]
267            .summary
268            .split(" - ")
269            .nth(1)
270            .expect("hook renders the text half");
271        assert!(shown.starts_with("[2026-01-15] "), "got: {shown}");
272    }
273
274    /// A summary with no `scope:kind - ` prefix still gets dated, in the same
275    /// position relative to its text.
276    #[test]
277    fn a_prefixless_summary_is_dated_at_the_front() {
278        let mut created = HashMap::new();
279        created.insert("memory:a".to_string(), "2026-01-15T10:00:00Z".to_string());
280        let ordered = render_chronologically(vec![capsule("memory:a", "bare text")], &created);
281        assert_eq!(ordered[0].summary, "[2026-01-15] bare text");
282    }
283
284    /// A repo file has no position in the memory timeline. It must not be
285    /// dropped, and must not be given a date it does not have.
286    #[test]
287    fn undated_capsules_are_kept_after_the_timeline() {
288        let mut created = HashMap::new();
289        created.insert("memory:a".to_string(), "2026-01-01T00:00:00Z".to_string());
290
291        let ordered = render_chronologically(
292            vec![
293                capsule("repo_file:src/main.rs", "repo_file:src/main.rs - fn main"),
294                capsule("memory:a", "project:fact - a thing"),
295            ],
296            &created,
297        );
298        assert_eq!(
299            handles(&ordered),
300            vec!["memory:a", "repo_file:src/main.rs"],
301            "dated first, undated kept: {:?}",
302            handles(&ordered)
303        );
304        assert!(
305            !ordered[1].summary.contains('['),
306            "an undated capsule must not be given a date: {}",
307            ordered[1].summary
308        );
309    }
310
311    #[test]
312    fn equal_timestamps_keep_the_brokers_order() {
313        let mut created = HashMap::new();
314        created.insert("memory:a".to_string(), "2026-01-01T00:00:00Z".to_string());
315        created.insert("memory:b".to_string(), "2026-01-01T00:00:00Z".to_string());
316        let ordered = render_chronologically(
317            vec![
318                capsule("memory:a", "project:fact - best match"),
319                capsule("memory:b", "project:fact - second"),
320            ],
321            &created,
322        );
323        assert_eq!(handles(&ordered), vec!["memory:a", "memory:b"]);
324    }
325
326    /// Presentation only: the same capsules come out, never more or fewer.
327    #[test]
328    fn reordering_never_adds_or_drops_a_capsule() {
329        let mut created = HashMap::new();
330        created.insert("memory:a".to_string(), "2026-03-01T00:00:00Z".to_string());
331        let input = vec![
332            capsule("memory:a", "a"),
333            capsule("memory:b", "b"),
334            capsule("repo_file:x", "x"),
335        ];
336        let ordered = render_chronologically(input.clone(), &created);
337        assert_eq!(ordered.len(), input.len());
338        let mut got = handles(&ordered);
339        got.sort_unstable();
340        let mut want = handles(&input);
341        want.sort_unstable();
342        assert_eq!(got, want);
343    }
344
345    #[test]
346    fn the_token_estimate_accounts_for_the_date_prefix() {
347        let mut created = HashMap::new();
348        created.insert("memory:a".to_string(), "2026-03-01T00:00:00Z".to_string());
349        let ordered = render_chronologically(vec![capsule("memory:a", "a")], &created);
350        assert!(ordered[0].token_estimate > 10, "the prefix costs tokens");
351    }
352}