use serde::{Deserialize, Serialize};
use crate::query::recall::{ScoredMemory, SupersededRecord};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CurrentFactResolverConfig {
pub fact_key: String,
#[serde(default)]
pub include_supersession_chain: bool,
}
impl CurrentFactResolverConfig {
pub fn new<S: Into<String>>(fact_key: S) -> Self {
Self {
fact_key: fact_key.into(),
include_supersession_chain: false,
}
}
pub fn with_supersession_chain(mut self) -> Self {
self.include_supersession_chain = true;
self
}
}
#[derive(Debug, Clone)]
pub struct ResolverOutput {
pub kept: Vec<ScoredMemory>,
pub superseded: Vec<SupersededRecord>,
}
pub fn resolve(cfg: &CurrentFactResolverConfig, candidates: Vec<ScoredMemory>) -> ResolverOutput {
use std::collections::BTreeMap;
let mut without_key: Vec<ScoredMemory> = Vec::new();
let mut groups: BTreeMap<String, Vec<ScoredMemory>> = BTreeMap::new();
for cand in candidates {
match extract_fact_id(&cand, &cfg.fact_key) {
Some(id) => groups.entry(id).or_default().push(cand),
None => without_key.push(cand),
}
}
let mut kept: Vec<ScoredMemory> = without_key;
let mut superseded: Vec<SupersededRecord> = Vec::new();
for (fact_id, mut group) in groups {
group.sort_by(|a, b| {
cmp_recency_desc(a, b)
.then_with(|| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
})
.then_with(|| b.id.cmp(&a.id))
});
let mut it = group.into_iter();
if let Some(current) = it.next() {
if cfg.include_supersession_chain {
let current_id = current.id;
let current_updated_at = current.updated_at.clone();
for older in it {
superseded.push(SupersededRecord {
id: older.id,
fact_id: fact_id.clone(),
superseded_by: current_id,
superseded_at: current_updated_at.clone(),
prior_updated_at: older.updated_at.clone(),
});
}
}
kept.push(current);
}
}
kept.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
});
ResolverOutput { kept, superseded }
}
fn extract_fact_id(m: &ScoredMemory, fact_key: &str) -> Option<String> {
let v = m.metadata.get(fact_key)?;
if let Some(s) = v.as_str() {
return Some(s.to_string());
}
if let Some(n) = v.as_i64() {
return Some(n.to_string());
}
if let Some(b) = v.as_bool() {
return Some(b.to_string());
}
None
}
fn cmp_recency_desc(a: &ScoredMemory, b: &ScoredMemory) -> std::cmp::Ordering {
let a_t = effective_timestamp(a);
let b_t = effective_timestamp(b);
b_t.cmp(a_t)
}
fn effective_timestamp(m: &ScoredMemory) -> &str {
if m.updated_at.is_empty() {
m.created_at.as_str()
} else {
m.updated_at.as_str()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::memory::{MemoryType, Scope};
use serde_json::json;
use uuid::Uuid;
fn hit(fact: Option<&str>, updated_at: &str, score: f32, content: &str) -> ScoredMemory {
ScoredMemory {
id: Uuid::now_v7(),
content: content.to_string(),
agent_id: "test".to_string(),
memory_type: MemoryType::Episodic,
scope: Scope::Private,
importance: 0.5,
tags: vec![],
metadata: match fact {
Some(f) => json!({ "fact_id": f }),
None => json!({}),
},
score,
access_count: 0,
created_at: updated_at.to_string(),
updated_at: updated_at.to_string(),
score_breakdown: None,
}
}
#[test]
fn keeps_most_recent_per_fact_group() {
let cfg = CurrentFactResolverConfig::new("fact_id");
let out = resolve(
&cfg,
vec![
hit(Some("f1"), "2026-05-22T08:00:00Z", 0.9, "old v1"),
hit(Some("f1"), "2026-05-22T09:00:00Z", 0.5, "new v2"),
hit(Some("f1"), "2026-05-22T07:00:00Z", 0.7, "older v0"),
],
);
assert_eq!(out.kept.len(), 1);
assert_eq!(out.kept[0].content, "new v2");
assert!(out.superseded.is_empty(), "chain off by default");
}
#[test]
fn supersession_chain_when_enabled() {
let cfg = CurrentFactResolverConfig::new("fact_id").with_supersession_chain();
let out = resolve(
&cfg,
vec![
hit(Some("f1"), "2026-05-22T08:00:00Z", 0.9, "v1"),
hit(Some("f1"), "2026-05-22T09:00:00Z", 0.5, "v2 current"),
hit(Some("f1"), "2026-05-22T07:00:00Z", 0.7, "v0 oldest"),
],
);
assert_eq!(out.kept.len(), 1);
assert_eq!(out.kept[0].content, "v2 current");
assert_eq!(out.superseded.len(), 2);
assert_eq!(out.superseded[0].prior_updated_at, "2026-05-22T08:00:00Z");
assert_eq!(out.superseded[1].prior_updated_at, "2026-05-22T07:00:00Z");
for s in &out.superseded {
assert_eq!(s.fact_id, "f1");
assert_eq!(s.superseded_by, out.kept[0].id);
}
}
#[test]
fn records_without_fact_key_pass_through() {
let cfg = CurrentFactResolverConfig::new("fact_id");
let out = resolve(
&cfg,
vec![
hit(None, "2026-05-22T08:00:00Z", 0.9, "no-fact-a"),
hit(None, "2026-05-22T09:00:00Z", 0.4, "no-fact-b"),
hit(Some("f1"), "2026-05-22T07:00:00Z", 0.7, "fact"),
],
);
assert_eq!(out.kept.len(), 3);
let contents: Vec<_> = out.kept.iter().map(|m| m.content.as_str()).collect();
assert!(contents.contains(&"no-fact-a"));
assert!(contents.contains(&"no-fact-b"));
assert!(contents.contains(&"fact"));
}
#[test]
fn multi_group_resolution() {
let cfg = CurrentFactResolverConfig::new("fact_id").with_supersession_chain();
let out = resolve(
&cfg,
vec![
hit(Some("city"), "2026-05-22T08:00:00Z", 0.9, "Paris"),
hit(Some("city"), "2026-05-22T09:00:00Z", 0.5, "Berlin"),
hit(Some("color"), "2026-05-22T07:00:00Z", 0.7, "red"),
hit(Some("color"), "2026-05-22T08:30:00Z", 0.4, "blue"),
],
);
assert_eq!(out.kept.len(), 2);
assert_eq!(out.superseded.len(), 2);
let kept_contents: Vec<_> = out.kept.iter().map(|m| m.content.as_str()).collect();
assert!(kept_contents.contains(&"Berlin"));
assert!(kept_contents.contains(&"blue"));
}
#[test]
fn empty_candidate_set_returns_empty_output() {
let cfg = CurrentFactResolverConfig::new("fact_id");
let out = resolve(&cfg, vec![]);
assert!(out.kept.is_empty());
assert!(out.superseded.is_empty());
}
#[test]
fn fact_id_can_be_integer() {
let cfg = CurrentFactResolverConfig::new("fact_id");
let mut newer = hit(None, "2026-05-22T09:00:00Z", 0.5, "newer");
newer.metadata = json!({"fact_id": 42});
let mut older = hit(None, "2026-05-22T08:00:00Z", 0.9, "older");
older.metadata = json!({"fact_id": 42});
let out = resolve(&cfg, vec![older, newer]);
assert_eq!(out.kept.len(), 1);
assert_eq!(out.kept[0].content, "newer");
}
}