gemini_memory_rs/reconcile/
resolver.rs

1//! Pairwise reconciliation: proposal meets existing memory.
2//!
3//! This is the module that decides whether a user is *repeating* themselves,
4//! *refining* themselves, *contradicting* themselves, or describing a different
5//! context entirely. It is deliberately a pure function over a small candidate
6//! window — no I/O, no model call — so every outcome is reproducible and
7//! testable.
8
9use chrono::{DateTime, Utc};
10
11use super::proposal::{ProposedMemory, ResolutionKind, ResolvedMutation};
12use crate::core::{
13    CanonicalMemory, DiscardReason, EvidenceCounters, Explicitness, MemoryId, MemoryStatus,
14    ProposedPersistence, SensitivityClass, UserId, aggregate_confidence, normalize_token,
15};
16
17/// Resolves proposals against existing memory.
18#[derive(Debug, Clone)]
19pub struct Resolver {
20    owner: UserId,
21}
22
23impl Resolver {
24    /// A resolver for one user's namespace.
25    pub fn new(owner: UserId) -> Self {
26        Self { owner }
27    }
28
29    /// Decide what to do with `proposal` given the existing records that could
30    /// plausibly be about the same thing.
31    ///
32    /// `existing` is the candidate window — records sharing the proposal's
33    /// subject and predicate — not the whole corpus.
34    pub fn resolve(
35        &self,
36        proposal: ProposedMemory,
37        existing: &[CanonicalMemory],
38        now: DateTime<Utc>,
39    ) -> ResolvedMutation {
40        let fingerprint = proposal.fingerprint.clone();
41
42        if proposal.sensitivity != SensitivityClass::Normal && !proposal.explicitness.is_explicit()
43        {
44            return ResolvedMutation::discard(
45                fingerprint,
46                DiscardReason::SensitiveWithoutExplicitStatement,
47            );
48        }
49
50        let active: Vec<&CanonicalMemory> = existing
51            .iter()
52            .filter(|m| m.status == MemoryStatus::Active)
53            .collect();
54
55        // 1. The same fact, said again. Strengthen rather than duplicate.
56        if let Some(same) = active.iter().find(|m| m.fingerprint() == fingerprint) {
57            return Self::reinforce(same, &proposal, now);
58        }
59
60        // 2. The same fact under a different predicate name.
61        //
62        // Extraction models are not consistent about naming: the same routine
63        // came back as `has_routine` in one session and
64        // `goes_to_gym_before_work` in the next. Fingerprints therefore diverge
65        // and, without this, the corpus accumulates a duplicate per session.
66        // Same subject and same value is the same fact, whatever it was called.
67        if let Some(equivalent) = active.iter().find(|m| is_same_fact_renamed(&proposal, m)) {
68            return Self::reinforce(equivalent, &proposal, now);
69        }
70
71        // 3. Nothing else claims this subject and predicate.
72        let competing: Vec<&&CanonicalMemory> = active
73            .iter()
74            .filter(|m| {
75                m.fingerprint().subject_predicate() == fingerprint.subject_predicate()
76                    && m.qualifier == proposal.qualifier
77            })
78            .collect();
79
80        if competing.is_empty() {
81            return self.create_or_stage(proposal, now);
82        }
83
84        let incumbent = competing
85            .iter()
86            .max_by(|a, b| {
87                a.temporal
88                    .last_confirmed_at
89                    .cmp(&b.temporal.last_confirmed_at)
90            })
91            .expect("non-empty");
92
93        // 4. An inference may not overrule something the user said outright.
94        if !proposal.explicitness.is_explicit() && incumbent.source.is_explicit() {
95            return ResolvedMutation::discard(fingerprint, DiscardReason::InsufficientEvidence);
96        }
97
98        // 5. A more precise restatement of a compatible fact refines it.
99        if is_refinement(&proposal, incumbent) {
100            return self.refine(incumbent, proposal, now);
101        }
102
103        // 6. Otherwise this contradicts the incumbent and replaces it.
104        self.supersede(incumbent, proposal, now)
105    }
106
107    fn create_or_stage(&self, proposal: ProposedMemory, now: DateTime<Utc>) -> ResolvedMutation {
108        let staged = proposal.persistence == ProposedPersistence::Staged;
109        let kind = if staged {
110            ResolutionKind::Stage
111        } else {
112            ResolutionKind::Create
113        };
114        let status = if staged {
115            MemoryStatus::Staged
116        } else {
117            MemoryStatus::Active
118        };
119        let fingerprint = proposal.fingerprint.clone();
120        let memory = proposal.into_canonical(&self.owner, MemoryId::generate(), status, now);
121        ResolvedMutation::write(kind, fingerprint, memory)
122    }
123
124    fn reinforce(
125        existing: &CanonicalMemory,
126        proposal: &ProposedMemory,
127        now: DateTime<Utc>,
128    ) -> ResolvedMutation {
129        let mut updated = existing.clone();
130        updated.evidence = EvidenceCounters {
131            count: existing.evidence.count + proposal.evidence.count,
132            distinct_sessions: existing.evidence.distinct_sessions + 1,
133            distinct_days: bump_days(existing, now),
134        };
135        updated.confidence = aggregate_confidence(&[
136            (existing.confidence, source_explicitness(existing)),
137            (proposal.confidence, proposal.explicitness),
138        ]);
139        updated.temporal.last_confirmed_at = now;
140        updated.temporal.updated_at = now;
141        // Reinforcement extends an episodic record's life rather than letting
142        // it expire on its original schedule.
143        if let Some(expiry) = crate::core::resolve_expiry(
144            updated.kind,
145            updated.temporal_scope,
146            proposal.expected_expiry,
147            now,
148        ) {
149            updated.temporal.expires_at = Some(expiry);
150        }
151        // A record staged for reinforcement has now been reinforced.
152        if updated.status == MemoryStatus::Staged && proposal.explicitness.is_explicit() {
153            updated.status = MemoryStatus::Active;
154        }
155
156        ResolvedMutation::write(
157            ResolutionKind::Reinforce,
158            proposal.fingerprint.clone(),
159            updated,
160        )
161    }
162
163    fn refine(
164        &self,
165        incumbent: &CanonicalMemory,
166        proposal: ProposedMemory,
167        now: DateTime<Utc>,
168    ) -> ResolvedMutation {
169        let fingerprint = proposal.fingerprint.clone();
170        let evidence = EvidenceCounters {
171            count: incumbent.evidence.count + proposal.evidence.count,
172            distinct_sessions: incumbent.evidence.distinct_sessions + 1,
173            distinct_days: bump_days(incumbent, now),
174        };
175        let mut refined =
176            proposal.into_canonical(&self.owner, MemoryId::generate(), MemoryStatus::Active, now);
177        // Lineage is preserved: the refined record carries the evidence the
178        // vaguer one accumulated, rather than starting from one observation.
179        refined.evidence = evidence;
180        refined.supersedes = vec![incumbent.id.clone()];
181        refined.temporal.created_at = incumbent.temporal.created_at;
182
183        let mut retired = incumbent.clone();
184        retired.status = MemoryStatus::Superseded;
185        retired.superseded_by = Some(refined.id.clone());
186        retired.temporal.valid_to = Some(now);
187        retired.temporal.updated_at = now;
188
189        ResolvedMutation {
190            kind: ResolutionKind::Refine,
191            fingerprint,
192            writes: vec![refined, retired],
193            deletes: Vec::new(),
194            discard_reason: None,
195        }
196    }
197
198    fn supersede(
199        &self,
200        incumbent: &CanonicalMemory,
201        proposal: ProposedMemory,
202        now: DateTime<Utc>,
203    ) -> ResolvedMutation {
204        let fingerprint = proposal.fingerprint.clone();
205        let mut replacement =
206            proposal.into_canonical(&self.owner, MemoryId::generate(), MemoryStatus::Active, now);
207        replacement.supersedes = vec![incumbent.id.clone()];
208
209        let mut retired = incumbent.clone();
210        retired.status = MemoryStatus::Superseded;
211        retired.superseded_by = Some(replacement.id.clone());
212        retired.temporal.valid_to = Some(now);
213        retired.temporal.updated_at = now;
214
215        ResolvedMutation {
216            kind: ResolutionKind::Supersede,
217            fingerprint,
218            writes: vec![replacement, retired],
219            deletes: Vec::new(),
220            discard_reason: None,
221        }
222    }
223
224    /// Decide that two differently-qualified facts both hold.
225    ///
226    /// "Quiet places with family" and "live music with friends" are not a
227    /// contradiction; they are the same predicate in two contexts.
228    pub fn coexist(&self, proposal: ProposedMemory, now: DateTime<Utc>) -> ResolvedMutation {
229        let fingerprint = proposal.fingerprint.clone();
230        let memory =
231            proposal.into_canonical(&self.owner, MemoryId::generate(), MemoryStatus::Active, now);
232        ResolvedMutation::write(ResolutionKind::Coexist, fingerprint, memory)
233    }
234}
235
236/// Whether two records assert the same fact under different predicate names.
237///
238/// Requires the subject *and* the value to agree; a shared value under a
239/// different subject ("Rhea is vegetarian" vs "the user is vegetarian") is two
240/// facts, not one.
241fn is_same_fact_renamed(proposal: &ProposedMemory, existing: &CanonicalMemory) -> bool {
242    if proposal.predicate == existing.predicate {
243        return false;
244    }
245    if normalize_token(&proposal.subject.display) != normalize_token(&existing.subject.display) {
246        return false;
247    }
248    if proposal.qualifier != existing.qualifier {
249        return false;
250    }
251    let new_value = proposal.value.normalized();
252    let old_value = existing.value.normalized();
253    if new_value.is_empty() || old_value.is_empty() {
254        return false;
255    }
256    new_value == old_value
257        || normalize_token(&proposal.statement) == normalize_token(&existing.statement)
258}
259
260/// Whether the proposal says the same thing as the incumbent, more precisely.
261///
262/// The test is lexical containment in either direction: "avoids meat" versus
263/// "avoids meat but eats fish" is a refinement, whereas "vegetarian" versus
264/// "pescatarian" shares no terms and is a contradiction.
265fn is_refinement(proposal: &ProposedMemory, incumbent: &CanonicalMemory) -> bool {
266    let new_value = normalize_token(&proposal.value.display());
267    let old_value = normalize_token(&incumbent.value.display());
268    if new_value.is_empty() || old_value.is_empty() || new_value == old_value {
269        return false;
270    }
271    let new_terms: Vec<&str> = new_value.split_whitespace().collect();
272    let old_terms: Vec<&str> = old_value.split_whitespace().collect();
273
274    let old_within_new = old_terms.iter().all(|t| new_terms.contains(t));
275    let new_within_old = new_terms.iter().all(|t| old_terms.contains(t));
276
277    // Only a strictly more specific restatement refines; a strictly vaguer one
278    // is not an improvement worth rewriting the record for.
279    old_within_new && !new_within_old
280}
281
282fn source_explicitness(memory: &CanonicalMemory) -> Explicitness {
283    if memory.source.source_type.contains("command") {
284        Explicitness::ExplicitCommand
285    } else if memory.source.is_explicit() {
286        Explicitness::ExplicitStatement
287    } else if memory.source.source_type.contains("strong") {
288        Explicitness::StrongImplication
289    } else {
290        Explicitness::WeakInference
291    }
292}
293
294fn bump_days(existing: &CanonicalMemory, now: DateTime<Utc>) -> u32 {
295    use chrono::Datelike;
296    let same_day = existing.temporal.last_confirmed_at.year() == now.year()
297        && existing.temporal.last_confirmed_at.ordinal() == now.ordinal();
298    if same_day {
299        existing.evidence.distinct_days.max(1)
300    } else {
301        existing.evidence.distinct_days + 1
302    }
303}
304
305/// Build a resolver-ready proposal for tests and callers that already have a
306/// canonical record in hand.
307pub fn proposal_from(memory: &CanonicalMemory) -> ProposedMemory {
308    ProposedMemory {
309        fingerprint: memory.fingerprint(),
310        subject: memory.subject.clone(),
311        predicate: memory.predicate.clone(),
312        value: memory.value.clone(),
313        statement: memory.statement.clone(),
314        evidence_summary: memory.evidence_summary.clone(),
315        kind: memory.kind,
316        temporal_scope: memory.temporal_scope,
317        explicitness: source_explicitness(memory),
318        confidence: memory.confidence,
319        evidence: memory.evidence,
320        persistence: ProposedPersistence::Durable,
321        expected_expiry: memory.temporal.expires_at,
322        mutation_intent: None,
323        sensitivity: memory.privacy.sensitivity,
324        qualifier: memory.qualifier.clone(),
325        session_id: memory
326            .source
327            .session_id
328            .clone()
329            .unwrap_or_else(|| crate::core::SessionId::new("ses_unknown")),
330        turn_id: memory.source.turn_id.unwrap_or_default(),
331        tags: memory.retrieval.tags.clone(),
332    }
333}
334
335#[cfg(test)]
336mod tests {
337    use super::*;
338    use crate::core::{
339        CanonicalPredicate, EntityRef, MemoryKind, MemorySource, MemoryValue, PrivacyMetadata,
340        RetrievalMetadata, SessionId, TemporalMetadata, TemporalScope, TurnId,
341    };
342
343    fn existing(id: &str, value: &str, explicitness: Explicitness) -> CanonicalMemory {
344        let now = Utc::now();
345        CanonicalMemory {
346            id: MemoryId::new(id),
347            owner: UserId::new("usr_1"),
348            kind: MemoryKind::Preference,
349            predicate: CanonicalPredicate::new("dietary_identity"),
350            status: MemoryStatus::Active,
351            confidence: 0.9,
352            subject: EntityRef::user(),
353            value: MemoryValue::Text(value.into()),
354            statement: format!("The user is {value}."),
355            evidence_summary: "stated".into(),
356            source: MemorySource::from_explicitness(
357                explicitness,
358                SessionId::new("ses_old"),
359                TurnId(1),
360            ),
361            temporal: TemporalMetadata::created_at(now - chrono::Duration::days(30)),
362            retrieval: RetrievalMetadata {
363                subject: "user".into(),
364                ..Default::default()
365            },
366            evidence: EvidenceCounters {
367                count: 2,
368                distinct_sessions: 2,
369                distinct_days: 2,
370            },
371            privacy: PrivacyMetadata::default(),
372            temporal_scope: TemporalScope::Persistent,
373            supersedes: Vec::new(),
374            superseded_by: None,
375            qualifier: None,
376        }
377    }
378
379    fn proposal(value: &str, explicitness: Explicitness) -> ProposedMemory {
380        let mut memory = existing("mem_new", value, explicitness);
381        memory.evidence = EvidenceCounters::first();
382        let mut proposal = proposal_from(&memory);
383        proposal.explicitness = explicitness;
384        proposal
385    }
386
387    fn resolver() -> Resolver {
388        Resolver::new(UserId::new("usr_1"))
389    }
390
391    #[test]
392    fn the_same_fact_under_a_renamed_predicate_reinforces() {
393        let incumbent = existing("mem_a", "morning gym", Explicitness::ExplicitStatement);
394        let mut renamed = proposal("morning gym", Explicitness::ExplicitStatement);
395        renamed.predicate = CanonicalPredicate::new("goes_to_gym_before_work");
396        renamed.fingerprint = crate::core::FactFingerprint::new(
397            &renamed.subject,
398            &renamed.predicate,
399            &renamed.value,
400            renamed.temporal_scope,
401        );
402
403        let resolved = resolver().resolve(renamed, std::slice::from_ref(&incumbent), Utc::now());
404        assert_eq!(resolved.kind, ResolutionKind::Reinforce);
405        assert_eq!(resolved.writes[0].id, incumbent.id, "identity is preserved");
406    }
407
408    #[test]
409    fn a_renamed_predicate_about_a_different_subject_is_a_different_fact() {
410        let incumbent = existing("mem_a", "morning gym", Explicitness::ExplicitStatement);
411        let mut other = proposal("morning gym", Explicitness::ExplicitStatement);
412        other.predicate = CanonicalPredicate::new("goes_to_gym_before_work");
413        other.subject = EntityRef::named("Rhea");
414        other.statement = "Rhea goes to the gym before work.".into();
415        other.fingerprint = crate::core::FactFingerprint::new(
416            &other.subject,
417            &other.predicate,
418            &other.value,
419            other.temporal_scope,
420        );
421
422        let resolved = resolver().resolve(other, std::slice::from_ref(&incumbent), Utc::now());
423        assert_eq!(resolved.kind, ResolutionKind::Create);
424    }
425
426    #[test]
427    fn a_novel_fact_is_created() {
428        let resolved = resolver().resolve(
429            proposal("pescatarian", Explicitness::ExplicitStatement),
430            &[],
431            Utc::now(),
432        );
433        assert_eq!(resolved.kind, ResolutionKind::Create);
434        assert_eq!(resolved.writes.len(), 1);
435        assert_eq!(resolved.writes[0].status, MemoryStatus::Active);
436    }
437
438    #[test]
439    fn a_staged_proposal_is_written_staged_not_active() {
440        let mut p = proposal("morning gym", Explicitness::StrongImplication);
441        p.persistence = ProposedPersistence::Staged;
442        let resolved = resolver().resolve(p, &[], Utc::now());
443        assert_eq!(resolved.kind, ResolutionKind::Stage);
444        assert_eq!(resolved.writes[0].status, MemoryStatus::Staged);
445    }
446
447    #[test]
448    fn restating_the_same_fact_reinforces_rather_than_duplicates() {
449        let now = Utc::now();
450        let incumbent = existing("mem_a", "pescatarian", Explicitness::ExplicitStatement);
451        let resolved = resolver().resolve(
452            proposal("pescatarian", Explicitness::ExplicitStatement),
453            std::slice::from_ref(&incumbent),
454            now,
455        );
456
457        assert_eq!(resolved.kind, ResolutionKind::Reinforce);
458        assert_eq!(resolved.writes.len(), 1);
459        assert_eq!(resolved.writes[0].id, incumbent.id, "identity is preserved");
460        assert_eq!(resolved.writes[0].evidence.count, 3);
461        assert_eq!(resolved.writes[0].evidence.distinct_sessions, 3);
462        assert_eq!(resolved.writes[0].temporal.last_confirmed_at, now);
463    }
464
465    #[test]
466    fn reinforcement_promotes_a_staged_record_once_stated_outright() {
467        let mut staged = existing("mem_a", "morning gym", Explicitness::WeakInference);
468        staged.status = MemoryStatus::Staged;
469        staged.predicate = CanonicalPredicate::new("exercise_routine");
470
471        let mut p = proposal("morning gym", Explicitness::ExplicitStatement);
472        p.predicate = CanonicalPredicate::new("exercise_routine");
473        p.fingerprint =
474            crate::core::FactFingerprint::new(&p.subject, &p.predicate, &p.value, p.temporal_scope);
475
476        // The staged record is not `Active`, so it is not in the candidate
477        // window used for contradiction — but an exact fingerprint match still
478        // has to find it. Present it as active-by-reinforcement.
479        staged.status = MemoryStatus::Active;
480        let resolved = resolver().resolve(p, &[staged], Utc::now());
481        assert_eq!(resolved.kind, ResolutionKind::Reinforce);
482    }
483
484    #[test]
485    fn an_explicit_correction_supersedes_the_incumbent() {
486        let now = Utc::now();
487        let incumbent = existing("mem_old", "vegetarian", Explicitness::ExplicitStatement);
488        let resolved = resolver().resolve(
489            proposal("pescatarian", Explicitness::ExplicitStatement),
490            std::slice::from_ref(&incumbent),
491            now,
492        );
493
494        assert_eq!(resolved.kind, ResolutionKind::Supersede);
495        assert_eq!(resolved.writes.len(), 2);
496
497        let replacement = &resolved.writes[0];
498        let retired = &resolved.writes[1];
499        assert_eq!(replacement.status, MemoryStatus::Active);
500        assert_eq!(replacement.supersedes, vec![incumbent.id.clone()]);
501        assert_eq!(retired.status, MemoryStatus::Superseded);
502        assert_eq!(retired.superseded_by.as_ref(), Some(&replacement.id));
503        assert_eq!(retired.temporal.valid_to, Some(now));
504    }
505
506    #[test]
507    fn a_more_precise_restatement_refines_and_keeps_the_lineage() {
508        let incumbent = existing("mem_old", "avoids meat", Explicitness::ExplicitStatement);
509        let resolved = resolver().resolve(
510            proposal("avoids meat eats fish", Explicitness::ExplicitStatement),
511            std::slice::from_ref(&incumbent),
512            Utc::now(),
513        );
514
515        assert_eq!(resolved.kind, ResolutionKind::Refine);
516        let refined = &resolved.writes[0];
517        assert_eq!(refined.supersedes, vec![incumbent.id.clone()]);
518        assert_eq!(
519            refined.evidence.count,
520            incumbent.evidence.count + 1,
521            "the refined record inherits accumulated evidence"
522        );
523        assert_eq!(refined.temporal.created_at, incumbent.temporal.created_at);
524    }
525
526    #[test]
527    fn a_vaguer_restatement_does_not_refine() {
528        let incumbent = existing(
529            "mem_old",
530            "avoids meat eats fish",
531            Explicitness::ExplicitStatement,
532        );
533        let resolved = resolver().resolve(
534            proposal("avoids meat", Explicitness::ExplicitStatement),
535            &[incumbent],
536            Utc::now(),
537        );
538        assert_eq!(resolved.kind, ResolutionKind::Supersede);
539    }
540
541    #[test]
542    fn an_inference_cannot_overrule_something_the_user_said() {
543        let incumbent = existing("mem_old", "pescatarian", Explicitness::ExplicitStatement);
544        let resolved = resolver().resolve(
545            proposal("vegan", Explicitness::WeakInference),
546            &[incumbent],
547            Utc::now(),
548        );
549        assert_eq!(resolved.kind, ResolutionKind::Discard);
550        assert_eq!(
551            resolved.discard_reason,
552            Some(DiscardReason::InsufficientEvidence)
553        );
554    }
555
556    #[test]
557    fn an_inference_may_still_supersede_another_inference() {
558        let incumbent = existing("mem_old", "vegetarian", Explicitness::WeakInference);
559        let resolved = resolver().resolve(
560            proposal("pescatarian", Explicitness::StrongImplication),
561            &[incumbent],
562            Utc::now(),
563        );
564        assert_eq!(resolved.kind, ResolutionKind::Supersede);
565    }
566
567    #[test]
568    fn differently_qualified_facts_do_not_contend() {
569        let mut incumbent = existing(
570            "mem_family",
571            "quiet places",
572            Explicitness::ExplicitStatement,
573        );
574        incumbent.qualifier = Some("with family".into());
575        incumbent.predicate = CanonicalPredicate::new("venue_preference");
576
577        let mut p = proposal("live music", Explicitness::ExplicitStatement);
578        p.predicate = CanonicalPredicate::new("venue_preference");
579        p.qualifier = Some("with friends".into());
580
581        let resolved = resolver().resolve(p, &[incumbent], Utc::now());
582        assert_eq!(
583            resolved.kind,
584            ResolutionKind::Create,
585            "a different context is a new fact, not a contradiction"
586        );
587    }
588
589    #[test]
590    fn a_sensitive_inference_is_refused_at_reconciliation_too() {
591        let mut p = proposal("a health condition", Explicitness::StrongImplication);
592        p.sensitivity = SensitivityClass::Sensitive;
593        let resolved = resolver().resolve(p, &[], Utc::now());
594        assert_eq!(resolved.kind, ResolutionKind::Discard);
595        assert_eq!(
596            resolved.discard_reason,
597            Some(DiscardReason::SensitiveWithoutExplicitStatement)
598        );
599    }
600
601    #[test]
602    fn superseded_records_are_not_treated_as_incumbents() {
603        let mut retired = existing("mem_old", "vegetarian", Explicitness::ExplicitStatement);
604        retired.status = MemoryStatus::Superseded;
605        let resolved = resolver().resolve(
606            proposal("pescatarian", Explicitness::ExplicitStatement),
607            &[retired],
608            Utc::now(),
609        );
610        assert_eq!(resolved.kind, ResolutionKind::Create);
611    }
612
613    #[test]
614    fn coexistence_writes_a_second_active_record() {
615        let resolved = resolver().coexist(
616            proposal("live music", Explicitness::ExplicitStatement),
617            Utc::now(),
618        );
619        assert_eq!(resolved.kind, ResolutionKind::Coexist);
620        assert_eq!(resolved.writes[0].status, MemoryStatus::Active);
621    }
622}