Skip to main content

mnemo_core/query/
current_fact_resolver.rs

1//! v0.4.7 — Current-fact resolver: an opt-in post-processor over the
2//! standard `recall` result set.
3//!
4//! # Anchor
5//!
6//! [arXiv:2605.18565](https://arxiv.org/abs/2605.18565) (MINTEval —
7//! Memory Interference under Targeted Edits) measures how often
8//! memory systems return a *superseded* value of a fact after the
9//! same fact has been revised K times. The default mnemo recall
10//! path (semantic + BM25 + graph + recency) is unaware of fact
11//! identity — every write is a separate `MemoryRecord`. Under
12//! interference, the recency lane alone is often not enough: high-
13//! semantic-score older versions can still outrank the most recent
14//! write.
15//!
16//! The current-fact resolver runs *after* the normal recall result
17//! set is computed. Given a `fact_key` (a metadata JSON pointer the
18//! operator chose to scope fact identity by — typical convention
19//! is `"fact_id"`), the resolver groups candidates by their value
20//! under that key, picks the most-recent write per group, and
21//! optionally returns the older writes in that group as a
22//! supersession chain.
23//!
24//! # Design contract
25//!
26//! - **Opt-in.** Triggered only when
27//!   [`RecallRequest::current_fact_resolver`][crate::query::recall::RecallRequest::current_fact_resolver]
28//!   is `Some`. The default read path is unchanged.
29//! - **Post-processor, not a replacement.** The resolver runs over
30//!   whatever candidates the underlying `RetrievalMode` produced.
31//!   It does not re-issue a query.
32//! - **Most-recent wins.** Within each fact-identity group, the
33//!   record with the latest `updated_at` (falling back to
34//!   `created_at`) is the *current* fact. If two records share the
35//!   same timestamp, the higher base score wins; if scores tie,
36//!   the higher UUID v7 wins (which is itself time-sortable).
37//! - **Supersession chain.** Older writes in a group are returned
38//!   in
39//!   [`RecallResponse::superseded`][crate::query::recall::RecallResponse::superseded]
40//!   when [`CurrentFactResolverConfig::include_supersession_chain`]
41//!   is `true`. Chain is ordered newest-superseded → oldest.
42//! - **Records without the `fact_key`** stay in the result set
43//!   untouched (they have no fact identity to resolve against).
44//!
45//! # What this module is NOT
46//!
47//! - **Not a contradiction detector.** Two records with the same
48//!   `fact_key` value are treated as versions of one fact; the
49//!   resolver does NOT inspect content to detect semantic
50//!   contradiction. The operator chooses `fact_key` to mean what
51//!   they want it to mean.
52//! - **Not a write-side guard.** The resolver only re-ranks reads.
53//!   Operators wanting to *prevent* contradictory writes use the
54//!   existing conflict-resolution path (`crate::query::conflict`).
55//! - **Not a benchmark.** The MINTEval-shaped interference scenario
56//!   that exercises this resolver lives in
57//!   [`bench/locomo/src/bin/interference.rs`](../../../../bench/locomo/src/bin/interference.rs).
58
59use serde::{Deserialize, Serialize};
60
61use crate::query::recall::{ScoredMemory, SupersededRecord};
62
63/// Opt-in config for the current-fact resolver. Carried on
64/// [`RecallRequest::current_fact_resolver`][crate::query::recall::RecallRequest::current_fact_resolver].
65#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
66pub struct CurrentFactResolverConfig {
67    /// JSON metadata key the resolver groups candidates by. Convention
68    /// is `"fact_id"`; the operator chooses a key that fits their
69    /// fact-identity scheme. Records missing this key are passed
70    /// through untouched.
71    pub fact_key: String,
72    /// When `true`, the response carries the older records as a
73    /// supersession chain in
74    /// [`RecallResponse::superseded`][crate::query::recall::RecallResponse::superseded].
75    /// When `false` (default), older records are silently dropped.
76    #[serde(default)]
77    pub include_supersession_chain: bool,
78}
79
80impl CurrentFactResolverConfig {
81    pub fn new<S: Into<String>>(fact_key: S) -> Self {
82        Self {
83            fact_key: fact_key.into(),
84            include_supersession_chain: false,
85        }
86    }
87    pub fn with_supersession_chain(mut self) -> Self {
88        self.include_supersession_chain = true;
89        self
90    }
91}
92
93/// Output of the resolver: the kept (current) candidates + (when
94/// requested) the superseded ones grouped by fact identity.
95#[derive(Debug, Clone)]
96pub struct ResolverOutput {
97    pub kept: Vec<ScoredMemory>,
98    pub superseded: Vec<SupersededRecord>,
99}
100
101/// Apply the current-fact resolver to a result set produced by the
102/// standard recall path. Groups by `cfg.fact_key`, keeps the most
103/// recent write per group, and optionally collects the older
104/// writes as a supersession chain.
105///
106/// Records missing the `fact_key` field in their metadata are
107/// passed through to `kept` untouched.
108pub fn resolve(cfg: &CurrentFactResolverConfig, candidates: Vec<ScoredMemory>) -> ResolverOutput {
109    use std::collections::BTreeMap;
110
111    let mut without_key: Vec<ScoredMemory> = Vec::new();
112    let mut groups: BTreeMap<String, Vec<ScoredMemory>> = BTreeMap::new();
113
114    for cand in candidates {
115        match extract_fact_id(&cand, &cfg.fact_key) {
116            Some(id) => groups.entry(id).or_default().push(cand),
117            None => without_key.push(cand),
118        }
119    }
120
121    let mut kept: Vec<ScoredMemory> = without_key;
122    let mut superseded: Vec<SupersededRecord> = Vec::new();
123
124    for (fact_id, mut group) in groups {
125        // Sort newest → oldest. Tie-break by score (higher wins),
126        // then by UUID v7 (which is itself time-sortable).
127        group.sort_by(|a, b| {
128            cmp_recency_desc(a, b)
129                .then_with(|| {
130                    b.score
131                        .partial_cmp(&a.score)
132                        .unwrap_or(std::cmp::Ordering::Equal)
133                })
134                .then_with(|| b.id.cmp(&a.id))
135        });
136
137        let mut it = group.into_iter();
138        if let Some(current) = it.next() {
139            if cfg.include_supersession_chain {
140                let current_id = current.id;
141                let current_updated_at = current.updated_at.clone();
142                for older in it {
143                    superseded.push(SupersededRecord {
144                        id: older.id,
145                        fact_id: fact_id.clone(),
146                        superseded_by: current_id,
147                        superseded_at: current_updated_at.clone(),
148                        prior_updated_at: older.updated_at.clone(),
149                    });
150                }
151            }
152            kept.push(current);
153        }
154    }
155
156    // Re-sort kept by the original-style score-desc so the response
157    // shape stays consistent with the non-resolver path.
158    kept.sort_by(|a, b| {
159        b.score
160            .partial_cmp(&a.score)
161            .unwrap_or(std::cmp::Ordering::Equal)
162    });
163
164    ResolverOutput { kept, superseded }
165}
166
167fn extract_fact_id(m: &ScoredMemory, fact_key: &str) -> Option<String> {
168    let v = m.metadata.get(fact_key)?;
169    if let Some(s) = v.as_str() {
170        return Some(s.to_string());
171    }
172    if let Some(n) = v.as_i64() {
173        return Some(n.to_string());
174    }
175    if let Some(b) = v.as_bool() {
176        return Some(b.to_string());
177    }
178    None
179}
180
181fn cmp_recency_desc(a: &ScoredMemory, b: &ScoredMemory) -> std::cmp::Ordering {
182    let a_t = effective_timestamp(a);
183    let b_t = effective_timestamp(b);
184    b_t.cmp(a_t)
185}
186
187fn effective_timestamp(m: &ScoredMemory) -> &str {
188    if m.updated_at.is_empty() {
189        m.created_at.as_str()
190    } else {
191        m.updated_at.as_str()
192    }
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198    use crate::model::memory::{MemoryType, Scope};
199    use serde_json::json;
200    use uuid::Uuid;
201
202    fn hit(fact: Option<&str>, updated_at: &str, score: f32, content: &str) -> ScoredMemory {
203        ScoredMemory {
204            id: Uuid::now_v7(),
205            content: content.to_string(),
206            agent_id: "test".to_string(),
207            memory_type: MemoryType::Episodic,
208            scope: Scope::Private,
209            importance: 0.5,
210            tags: vec![],
211            metadata: match fact {
212                Some(f) => json!({ "fact_id": f }),
213                None => json!({}),
214            },
215            score,
216            access_count: 0,
217            created_at: updated_at.to_string(),
218            updated_at: updated_at.to_string(),
219            score_breakdown: None,
220        }
221    }
222
223    #[test]
224    fn keeps_most_recent_per_fact_group() {
225        let cfg = CurrentFactResolverConfig::new("fact_id");
226        let out = resolve(
227            &cfg,
228            vec![
229                hit(Some("f1"), "2026-05-22T08:00:00Z", 0.9, "old v1"),
230                hit(Some("f1"), "2026-05-22T09:00:00Z", 0.5, "new v2"),
231                hit(Some("f1"), "2026-05-22T07:00:00Z", 0.7, "older v0"),
232            ],
233        );
234        assert_eq!(out.kept.len(), 1);
235        assert_eq!(out.kept[0].content, "new v2");
236        assert!(out.superseded.is_empty(), "chain off by default");
237    }
238
239    #[test]
240    fn supersession_chain_when_enabled() {
241        let cfg = CurrentFactResolverConfig::new("fact_id").with_supersession_chain();
242        let out = resolve(
243            &cfg,
244            vec![
245                hit(Some("f1"), "2026-05-22T08:00:00Z", 0.9, "v1"),
246                hit(Some("f1"), "2026-05-22T09:00:00Z", 0.5, "v2 current"),
247                hit(Some("f1"), "2026-05-22T07:00:00Z", 0.7, "v0 oldest"),
248            ],
249        );
250        assert_eq!(out.kept.len(), 1);
251        assert_eq!(out.kept[0].content, "v2 current");
252        assert_eq!(out.superseded.len(), 2);
253        // Chain ordered newest-superseded → oldest.
254        assert_eq!(out.superseded[0].prior_updated_at, "2026-05-22T08:00:00Z");
255        assert_eq!(out.superseded[1].prior_updated_at, "2026-05-22T07:00:00Z");
256        for s in &out.superseded {
257            assert_eq!(s.fact_id, "f1");
258            assert_eq!(s.superseded_by, out.kept[0].id);
259        }
260    }
261
262    #[test]
263    fn records_without_fact_key_pass_through() {
264        let cfg = CurrentFactResolverConfig::new("fact_id");
265        let out = resolve(
266            &cfg,
267            vec![
268                hit(None, "2026-05-22T08:00:00Z", 0.9, "no-fact-a"),
269                hit(None, "2026-05-22T09:00:00Z", 0.4, "no-fact-b"),
270                hit(Some("f1"), "2026-05-22T07:00:00Z", 0.7, "fact"),
271            ],
272        );
273        // All three kept; the two without fact_id pass through, the
274        // single fact-id group has one member.
275        assert_eq!(out.kept.len(), 3);
276        let contents: Vec<_> = out.kept.iter().map(|m| m.content.as_str()).collect();
277        assert!(contents.contains(&"no-fact-a"));
278        assert!(contents.contains(&"no-fact-b"));
279        assert!(contents.contains(&"fact"));
280    }
281
282    #[test]
283    fn multi_group_resolution() {
284        let cfg = CurrentFactResolverConfig::new("fact_id").with_supersession_chain();
285        let out = resolve(
286            &cfg,
287            vec![
288                hit(Some("city"), "2026-05-22T08:00:00Z", 0.9, "Paris"),
289                hit(Some("city"), "2026-05-22T09:00:00Z", 0.5, "Berlin"),
290                hit(Some("color"), "2026-05-22T07:00:00Z", 0.7, "red"),
291                hit(Some("color"), "2026-05-22T08:30:00Z", 0.4, "blue"),
292            ],
293        );
294        assert_eq!(out.kept.len(), 2);
295        assert_eq!(out.superseded.len(), 2);
296        let kept_contents: Vec<_> = out.kept.iter().map(|m| m.content.as_str()).collect();
297        assert!(kept_contents.contains(&"Berlin"));
298        assert!(kept_contents.contains(&"blue"));
299    }
300
301    #[test]
302    fn empty_candidate_set_returns_empty_output() {
303        let cfg = CurrentFactResolverConfig::new("fact_id");
304        let out = resolve(&cfg, vec![]);
305        assert!(out.kept.is_empty());
306        assert!(out.superseded.is_empty());
307    }
308
309    #[test]
310    fn fact_id_can_be_integer() {
311        let cfg = CurrentFactResolverConfig::new("fact_id");
312        let mut newer = hit(None, "2026-05-22T09:00:00Z", 0.5, "newer");
313        newer.metadata = json!({"fact_id": 42});
314        let mut older = hit(None, "2026-05-22T08:00:00Z", 0.9, "older");
315        older.metadata = json!({"fact_id": 42});
316        let out = resolve(&cfg, vec![older, newer]);
317        assert_eq!(out.kept.len(), 1);
318        assert_eq!(out.kept[0].content, "newer");
319    }
320}