1use 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#[derive(Debug, Clone)]
19pub struct Resolver {
20 owner: UserId,
21}
22
23impl Resolver {
24 pub fn new(owner: UserId) -> Self {
26 Self { owner }
27 }
28
29 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 if let Some(same) = active.iter().find(|m| m.fingerprint() == fingerprint) {
57 return Self::reinforce(same, &proposal, now);
58 }
59
60 if let Some(equivalent) = active.iter().find(|m| is_same_fact_renamed(&proposal, m)) {
68 return Self::reinforce(equivalent, &proposal, now);
69 }
70
71 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 if !proposal.explicitness.is_explicit() && incumbent.source.is_explicit() {
95 return ResolvedMutation::discard(fingerprint, DiscardReason::InsufficientEvidence);
96 }
97
98 if is_refinement(&proposal, incumbent) {
100 return self.refine(incumbent, proposal, now);
101 }
102
103 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 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 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 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 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
236fn 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
260fn 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 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
305pub 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 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}