1use serde::{Deserialize, Serialize};
60
61use crate::query::recall::{ScoredMemory, SupersededRecord};
62
63#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
66pub struct CurrentFactResolverConfig {
67 pub fact_key: String,
72 #[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#[derive(Debug, Clone)]
96pub struct ResolverOutput {
97 pub kept: Vec<ScoredMemory>,
98 pub superseded: Vec<SupersededRecord>,
99}
100
101pub 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 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 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 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 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}