1use mentedb_core::MemoryNode;
2use mentedb_core::edge::EdgeType;
3use mentedb_core::memory::MemoryType;
4use mentedb_core::types::MemoryId;
5
6use crate::llm::{
7 CognitiveLlmService, ContradictionVerdict, InvalidationVerdict, LlmJudge, MemorySummary,
8};
9
10#[derive(Debug, Clone)]
11pub enum InferredAction {
12 FlagContradiction {
13 existing: MemoryId,
14 new: MemoryId,
15 reason: String,
16 },
17 MarkObsolete {
18 memory: MemoryId,
19 superseded_by: MemoryId,
20 },
21 InvalidateMemory {
23 memory: MemoryId,
24 superseded_by: MemoryId,
25 valid_until: u64,
26 },
27 DeduplicateExact {
32 duplicate: MemoryId,
33 keeper: MemoryId,
34 },
35 UpdateContent {
37 memory: MemoryId,
38 new_content: String,
39 reason: String,
40 },
41 CreateEdge {
42 source: MemoryId,
43 target: MemoryId,
44 edge_type: EdgeType,
45 weight: f32,
46 },
47 UpdateConfidence {
48 memory: MemoryId,
49 new_confidence: f32,
50 },
51 PropagateBeliefChange { root: MemoryId, delta: f32 },
57}
58
59fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
60 if a.len() != b.len() || a.is_empty() {
61 return 0.0;
62 }
63 let mut dot = 0.0f32;
64 let mut norm_a = 0.0f32;
65 let mut norm_b = 0.0f32;
66 for i in 0..a.len() {
67 dot += a[i] * b[i];
68 norm_a += a[i] * a[i];
69 norm_b += b[i] * b[i];
70 }
71 let denom = norm_a.sqrt() * norm_b.sqrt();
72 if denom == 0.0 { 0.0 } else { dot / denom }
73}
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
79#[serde(rename_all = "snake_case")]
80pub enum ContradictionPolicy {
81 #[default]
84 Supersede,
85 Flag,
91}
92
93impl std::str::FromStr for ContradictionPolicy {
94 type Err = String;
95
96 fn from_str(s: &str) -> Result<Self, Self::Err> {
97 match s.trim().to_ascii_lowercase().as_str() {
98 "supersede" => Ok(Self::Supersede),
99 "flag" => Ok(Self::Flag),
100 other => Err(format!(
101 "unknown contradiction policy '{other}' (expected supersede or flag)"
102 )),
103 }
104 }
105}
106
107#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
109#[serde(default)]
110pub struct WriteInferenceConfig {
111 pub contradiction_threshold: f32,
113 pub obsolete_threshold: f32,
115 pub related_min: f32,
117 pub related_max: f32,
119 pub correction_threshold: f32,
121 pub confidence_decay_factor: f32,
123 pub confidence_floor: f32,
125 pub value_update_enabled: bool,
133 pub value_update_min_similarity: f32,
149 pub value_update_prefix_share: f32,
153 pub value_update_max_tail: usize,
156 pub contradiction_policy: ContradictionPolicy,
159 pub belief_propagation_enabled: bool,
163 pub belief_propagation_min_delta: f32,
166}
167
168impl Default for WriteInferenceConfig {
169 fn default() -> Self {
170 Self {
171 contradiction_threshold: 0.95,
172 obsolete_threshold: 0.85,
173 related_min: 0.6,
174 related_max: 0.85,
175 correction_threshold: 0.5,
176 confidence_decay_factor: 0.5,
177 confidence_floor: 0.1,
178 value_update_enabled: true,
179 value_update_min_similarity: 0.3,
180 value_update_prefix_share: 0.6,
181 value_update_max_tail: 4,
182 contradiction_policy: ContradictionPolicy::Supersede,
183 belief_propagation_enabled: true,
184 belief_propagation_min_delta: 0.05,
185 }
186 }
187}
188
189fn frame_tokens(s: &str) -> Vec<String> {
192 s.split(|c: char| !c.is_alphanumeric())
193 .filter(|t| !t.is_empty())
194 .map(|t| t.to_lowercase())
195 .collect()
196}
197
198fn is_value_update(new: &str, old: &str, frame_share: f32, max_value: usize) -> bool {
220 let nt = frame_tokens(new);
221 let ot = frame_tokens(old);
222 if nt.is_empty() || ot.is_empty() || nt == ot {
223 return false;
224 }
225 if nt.len().min(ot.len()) < 4 {
230 return false;
231 }
232 let prefix = nt.iter().zip(ot.iter()).take_while(|(a, b)| a == b).count();
233 if prefix < 2 {
234 return false;
235 }
236 let suffix = nt
237 .iter()
238 .rev()
239 .zip(ot.iter().rev())
240 .take_while(|(a, b)| a == b)
241 .count()
242 .min(nt.len().min(ot.len()) - prefix);
244 let longest = nt.len().max(ot.len());
245 let value_new = nt.len() - prefix - suffix;
246 let value_old = ot.len() - prefix - suffix;
247 if suffix > 0 {
248 let all_numeric =
249 |tokens: &[String]| tokens.iter().all(|t| t.chars().all(|c| c.is_ascii_digit()));
250 if all_numeric(&nt[prefix..nt.len() - suffix])
251 && all_numeric(&ot[prefix..ot.len() - suffix])
252 {
253 return false;
254 }
255 }
256 (prefix + suffix) as f32 / longest as f32 >= frame_share
257 && value_new.max(value_old) > 0
258 && value_new <= max_value
259 && value_old <= max_value
260}
261
262fn value_update_resolution(
270 new: &MemoryNode,
271 existing: &MemoryNode,
272) -> Option<(MemoryId, MemoryId, f32)> {
273 let new_correction = new.memory_type == MemoryType::Correction;
274 let old_correction = existing.memory_type == MemoryType::Correction;
275 if new_correction && !old_correction {
276 return Some((new.id, existing.id, existing.confidence));
277 }
278 if old_correction && !new_correction {
279 return Some((existing.id, new.id, new.confidence));
280 }
281 if new.created_at > existing.created_at {
282 return Some((new.id, existing.id, existing.confidence));
283 }
284 if existing.created_at > new.created_at {
285 return Some((existing.id, new.id, new.confidence));
286 }
287 None
288}
289
290pub struct WriteInferenceEngine {
291 config: WriteInferenceConfig,
292}
293
294impl WriteInferenceEngine {
295 pub fn new() -> Self {
296 Self {
297 config: WriteInferenceConfig::default(),
298 }
299 }
300
301 pub fn with_config(config: WriteInferenceConfig) -> Self {
302 Self { config }
303 }
304
305 pub fn looks_like_value_update(&self, new: &str, old: &str) -> bool {
312 is_value_update(
313 new,
314 old,
315 self.config.value_update_prefix_share,
316 self.config.value_update_max_tail,
317 )
318 }
319
320 pub fn infer_on_write(
321 &self,
322 new_memory: &MemoryNode,
323 existing_memories: &[MemoryNode],
324 existing_edges: &[(MemoryId, MemoryId, EdgeType)],
325 ) -> Vec<InferredAction> {
326 let _ = existing_edges; let mut actions = Vec::new();
328 let mut value_update_handled: Vec<MemoryId> = Vec::new();
331
332 for existing in existing_memories {
333 if existing.id == new_memory.id {
334 continue;
335 }
336
337 let sim = cosine_similarity(&new_memory.embedding, &existing.embedding);
338
339 if existing.content == new_memory.content
353 && sim > self.config.obsolete_threshold
354 && new_memory.created_at > existing.created_at
355 {
356 actions.push(InferredAction::DeduplicateExact {
357 duplicate: existing.id,
358 keeper: new_memory.id,
359 });
360 } else if self.config.value_update_enabled
361 && existing.agent_id == new_memory.agent_id
362 && existing.user_id == new_memory.user_id
363 && is_value_update(
364 &new_memory.content,
365 &existing.content,
366 self.config.value_update_prefix_share,
367 self.config.value_update_max_tail,
368 )
369 {
370 if sim < self.config.value_update_min_similarity {
371 tracing::info!(
376 similarity = sim,
377 "value update frame matched below the cosine floor, skipped"
378 );
379 continue;
380 }
381 value_update_handled.push(existing.id);
390 if let Some((winner, loser, loser_confidence)) =
391 value_update_resolution(new_memory, existing)
392 {
393 let now = std::time::SystemTime::now()
399 .duration_since(std::time::UNIX_EPOCH)
400 .unwrap_or_default()
401 .as_micros() as u64;
402 self.resolve_contradiction(
403 &mut actions,
404 existing.id,
405 new_memory.id,
406 loser,
407 winner,
408 loser_confidence,
409 now,
410 "value update: same sentence frame with a different value",
411 );
412 }
413 } else if sim > self.config.related_min && sim <= self.config.related_max {
417 actions.push(InferredAction::CreateEdge {
418 source: new_memory.id,
419 target: existing.id,
420 edge_type: EdgeType::Related,
421 weight: sim,
422 });
423 }
424 }
425
426 if new_memory.memory_type == MemoryType::Correction
429 && let Some(original) = existing_memories
430 .iter()
431 .filter(|m| m.id != new_memory.id && !value_update_handled.contains(&m.id))
432 .max_by(|a, b| {
433 cosine_similarity(&new_memory.embedding, &a.embedding)
434 .partial_cmp(&cosine_similarity(&new_memory.embedding, &b.embedding))
435 .unwrap_or(std::cmp::Ordering::Equal)
436 })
437 {
438 let sim = cosine_similarity(&new_memory.embedding, &original.embedding);
439 if sim > self.config.correction_threshold {
440 actions.push(InferredAction::CreateEdge {
441 source: new_memory.id,
442 target: original.id,
443 edge_type: EdgeType::Supersedes,
444 weight: 1.0,
445 });
446 self.push_confidence_change(
447 &mut actions,
448 original.id,
449 original.confidence,
450 self.decayed_confidence(original.confidence),
451 );
452 }
453 }
454
455 actions
456 }
457
458 pub async fn infer_on_write_with_llm<J: LlmJudge>(
463 &self,
464 new_memory: &MemoryNode,
465 existing_memories: &[MemoryNode],
466 existing_edges: &[(MemoryId, MemoryId, EdgeType)],
467 llm: &CognitiveLlmService<J>,
468 ) -> Vec<InferredAction> {
469 let _ = existing_edges;
470 let mut actions = Vec::new();
471
472 let new_summary = memory_to_summary(new_memory);
473
474 for existing in existing_memories {
475 if existing.id == new_memory.id {
476 continue;
477 }
478
479 let sim = cosine_similarity(&new_memory.embedding, &existing.embedding);
480
481 if sim > 0.5 && existing.agent_id == new_memory.agent_id {
484 let old_summary = memory_to_summary(existing);
485
486 if let Ok(verdict) = llm.judge_invalidation(&old_summary, &new_summary).await {
488 match verdict {
489 InvalidationVerdict::Invalidate { reason } => {
490 self.resolve_contradiction(
493 &mut actions,
494 existing.id,
495 new_memory.id,
496 existing.id,
497 new_memory.id,
498 existing.confidence,
499 new_memory.created_at,
500 &reason,
501 );
502 continue;
504 }
505 InvalidationVerdict::Update {
506 merged_content,
507 reason,
508 } => {
509 actions.push(InferredAction::UpdateContent {
510 memory: existing.id,
511 new_content: merged_content,
512 reason,
513 });
514 continue;
515 }
516 InvalidationVerdict::Keep { .. } => {
517 }
519 }
520 }
521
522 if sim > 0.7
524 && existing.content != new_memory.content
525 && let Ok(verdict) = llm.detect_contradiction(&old_summary, &new_summary).await
526 {
527 match verdict {
528 ContradictionVerdict::Contradicts { reason } => {
529 actions.push(InferredAction::FlagContradiction {
530 existing: existing.id,
531 new: new_memory.id,
532 reason,
533 });
534 }
535 ContradictionVerdict::Supersedes { winner, reason } => {
536 let winner_is_new = winner == new_memory.id.to_string();
537 let (obsolete, superseder) = if winner_is_new {
538 (existing.id, new_memory.id)
539 } else {
540 (new_memory.id, existing.id)
541 };
542 match self.config.contradiction_policy {
543 ContradictionPolicy::Supersede => {
544 actions.push(InferredAction::InvalidateMemory {
545 memory: obsolete,
546 superseded_by: superseder,
547 valid_until: new_memory.created_at,
548 });
549 }
550 ContradictionPolicy::Flag => {
551 actions.push(InferredAction::FlagContradiction {
552 existing: existing.id,
553 new: new_memory.id,
554 reason,
555 });
556 }
557 }
558 }
559 ContradictionVerdict::Compatible { .. } => {}
560 }
561 }
562 }
563
564 if sim > self.config.related_min && sim <= self.config.related_max {
566 actions.push(InferredAction::CreateEdge {
567 source: new_memory.id,
568 target: existing.id,
569 edge_type: EdgeType::Related,
570 weight: sim,
571 });
572 }
573 }
574
575 if new_memory.memory_type == MemoryType::Correction
577 && let Some(original) = existing_memories
578 .iter()
579 .filter(|m| m.id != new_memory.id)
580 .max_by(|a, b| {
581 cosine_similarity(&new_memory.embedding, &a.embedding)
582 .partial_cmp(&cosine_similarity(&new_memory.embedding, &b.embedding))
583 .unwrap_or(std::cmp::Ordering::Equal)
584 })
585 {
586 let sim = cosine_similarity(&new_memory.embedding, &original.embedding);
587 if sim > self.config.correction_threshold {
588 actions.push(InferredAction::InvalidateMemory {
589 memory: original.id,
590 superseded_by: new_memory.id,
591 valid_until: new_memory.created_at,
592 });
593 self.push_confidence_change(
594 &mut actions,
595 original.id,
596 original.confidence,
597 self.decayed_confidence(original.confidence),
598 );
599 }
600 }
601
602 actions
603 }
604}
605
606impl WriteInferenceEngine {
607 fn decayed_confidence(&self, old: f32) -> f32 {
609 (old * self.config.confidence_decay_factor).max(self.config.confidence_floor)
610 }
611
612 fn push_confidence_change(
618 &self,
619 actions: &mut Vec<InferredAction>,
620 memory: MemoryId,
621 old: f32,
622 new: f32,
623 ) {
624 actions.push(InferredAction::UpdateConfidence {
625 memory,
626 new_confidence: new,
627 });
628 let delta = new - old;
629 if self.config.belief_propagation_enabled
630 && delta.is_finite()
631 && delta != 0.0
632 && delta.abs() >= self.config.belief_propagation_min_delta
633 {
634 actions.push(InferredAction::PropagateBeliefChange {
635 root: memory,
636 delta,
637 });
638 }
639 }
640
641 #[allow(clippy::too_many_arguments)]
645 fn resolve_contradiction(
646 &self,
647 actions: &mut Vec<InferredAction>,
648 existing: MemoryId,
649 new: MemoryId,
650 loser: MemoryId,
651 winner: MemoryId,
652 loser_confidence: f32,
653 valid_until: u64,
654 reason: &str,
655 ) {
656 match self.config.contradiction_policy {
657 ContradictionPolicy::Supersede => {
658 actions.push(InferredAction::InvalidateMemory {
659 memory: loser,
660 superseded_by: winner,
661 valid_until,
662 });
663 self.push_confidence_change(
664 actions,
665 loser,
666 loser_confidence,
667 self.decayed_confidence(loser_confidence),
668 );
669 }
670 ContradictionPolicy::Flag => {
671 actions.push(InferredAction::FlagContradiction {
672 existing,
673 new,
674 reason: reason.to_string(),
675 });
676 }
677 }
678 }
679}
680
681impl Default for WriteInferenceEngine {
682 fn default() -> Self {
683 Self::new()
684 }
685}
686
687fn memory_to_summary(m: &MemoryNode) -> MemorySummary {
688 MemorySummary {
689 id: m.id,
690 content: m.content.clone(),
691 memory_type: m.memory_type,
692 confidence: m.confidence,
693 created_at: m.created_at,
694 }
695}
696
697#[cfg(test)]
698mod tests {
699 use super::*;
700
701 #[test]
702 fn confidence_drop_emits_propagation_after_the_update() {
703 let agent = AgentId::new();
704 let mut existing = make_memory(
705 "The user's favorite coffee order is a cortado",
706 vec![1.0, 0.0, 0.0],
707 MemoryType::Semantic,
708 );
709 existing.agent_id = agent;
710 existing.confidence = 0.8;
711 let mut new_mem = make_memory(
712 "The user's favorite coffee order is a flat white",
713 vec![0.98, 0.02, 0.0],
714 MemoryType::Semantic,
715 );
716 new_mem.agent_id = agent;
717 new_mem.created_at = 2000;
718
719 let actions =
720 WriteInferenceEngine::new().infer_on_write(&new_mem, &[existing.clone()], &[]);
721 let update = actions.iter().position(|a| {
722 matches!(a, InferredAction::UpdateConfidence { memory, new_confidence }
723 if *memory == existing.id && (*new_confidence - 0.4).abs() < 1e-6)
724 });
725 let propagate = actions.iter().position(|a| {
726 matches!(a, InferredAction::PropagateBeliefChange { root, delta }
727 if *root == existing.id && (*delta + 0.4).abs() < 1e-6)
728 });
729 assert!(
730 matches!((update, propagate), (Some(u), Some(p)) if p == u + 1),
731 "the drop must be set then propagated, got: {actions:?}"
732 );
733
734 for cfg in [
736 WriteInferenceConfig {
737 belief_propagation_enabled: false,
738 ..WriteInferenceConfig::default()
739 },
740 WriteInferenceConfig {
741 belief_propagation_min_delta: 0.5,
742 ..WriteInferenceConfig::default()
743 },
744 ] {
745 let actions = WriteInferenceEngine::with_config(cfg).infer_on_write(
746 &new_mem,
747 &[existing.clone()],
748 &[],
749 );
750 assert!(
751 actions
752 .iter()
753 .any(|a| matches!(a, InferredAction::UpdateConfidence { .. }))
754 );
755 assert!(
756 !actions
757 .iter()
758 .any(|a| matches!(a, InferredAction::PropagateBeliefChange { .. })),
759 "got: {actions:?}"
760 );
761 }
762 }
763 use mentedb_core::memory::MemoryType;
764 use mentedb_core::types::AgentId;
765
766 use crate::llm::MockLlmJudge;
767
768 fn make_memory(content: &str, embedding: Vec<f32>, mem_type: MemoryType) -> MemoryNode {
769 let mut m = MemoryNode::new(AgentId::new(), mem_type, content.to_string(), embedding);
770 m.created_at = 1000;
771 m
772 }
773
774 #[test]
775 fn test_heuristic_does_not_flag_contradiction_from_similarity() {
776 let agent = AgentId::new();
783 let mut existing =
784 make_memory("uses PostgreSQL", vec![1.0, 0.0, 0.0], MemoryType::Semantic);
785 existing.agent_id = agent;
786
787 let mut new_mem = make_memory("uses MySQL", vec![0.99, 0.01, 0.0], MemoryType::Semantic);
788 new_mem.agent_id = agent;
789 new_mem.created_at = 2000;
790
791 let engine = WriteInferenceEngine::new();
792 let actions = engine.infer_on_write(&new_mem, &[existing], &[]);
793 assert!(
794 !actions
795 .iter()
796 .any(|a| matches!(a, InferredAction::FlagContradiction { .. })),
797 "Heuristic must not flag contradictions from bare similarity, got: {:?}",
798 actions
799 );
800 }
801
802 #[test]
803 fn value_update_supersedes_the_old_fact() {
804 let agent = AgentId::new();
808 let mut existing = make_memory(
809 "The user's favorite coffee order is a cortado",
810 vec![1.0, 0.0, 0.0],
811 MemoryType::Semantic,
812 );
813 existing.agent_id = agent;
814 let mut new_mem = make_memory(
815 "The user's favorite coffee order is a flat white",
816 vec![0.98, 0.02, 0.0],
817 MemoryType::Semantic,
818 );
819 new_mem.agent_id = agent;
820 new_mem.created_at = 2000;
821
822 let engine = WriteInferenceEngine::new();
823 let actions = engine.infer_on_write(&new_mem, &[existing.clone()], &[]);
824 assert!(
825 actions.iter().any(|a| matches!(
826 a,
827 InferredAction::InvalidateMemory { memory, superseded_by, .. }
828 if *superseded_by == new_mem.id && *memory == existing.id
829 )),
830 "value update must invalidate the stale fact, got: {actions:?}"
831 );
832 assert!(
833 actions
834 .iter()
835 .any(|a| matches!(a, InferredAction::UpdateConfidence { memory, .. } if *memory == existing.id)),
836 "superseded fact's confidence must decay"
837 );
838 }
839
840 #[test]
841 fn value_update_fires_at_realistic_embedder_cosine() {
842 let agent = AgentId::new();
850 let mut existing = make_memory(
851 "The user's favorite coffee order is a cortado",
852 vec![1.0, 0.0, 0.0],
853 MemoryType::Semantic,
854 );
855 existing.agent_id = agent;
856 let mut new_mem = make_memory(
858 "The user's favorite coffee order is a flat white",
859 vec![0.6, 0.8, 0.0],
860 MemoryType::Semantic,
861 );
862 new_mem.agent_id = agent;
863 new_mem.created_at = 2000;
864
865 let actions =
866 WriteInferenceEngine::new().infer_on_write(&new_mem, &[existing.clone()], &[]);
867 assert!(
868 actions.iter().any(|a| matches!(
869 a,
870 InferredAction::InvalidateMemory { memory, superseded_by, .. }
871 if *superseded_by == new_mem.id && *memory == existing.id
872 )),
873 "a frame-matched correction at realistic cosine 0.6 must supersede, got: {actions:?}"
874 );
875 }
876
877 #[test]
878 fn value_update_fires_below_the_old_floor() {
879 let agent = AgentId::new();
883 let mut existing = make_memory(
884 "The user uses Next.js for the front end",
885 vec![1.0, 0.0, 0.0],
886 MemoryType::Semantic,
887 );
888 existing.agent_id = agent;
889 let mut new_mem = make_memory(
890 "The user uses React for the front end",
891 vec![0.4, 0.9165, 0.0],
892 MemoryType::Semantic,
893 );
894 new_mem.agent_id = agent;
895 new_mem.created_at = 2000;
896
897 let actions =
898 WriteInferenceEngine::new().infer_on_write(&new_mem, &[existing.clone()], &[]);
899 assert!(
900 actions.iter().any(|a| matches!(
901 a,
902 InferredAction::InvalidateMemory { memory, superseded_by, .. }
903 if *superseded_by == new_mem.id && *memory == existing.id
904 )),
905 "an identical frame pair at cosine 0.4 must supersede, got: {actions:?}"
906 );
907 }
908
909 #[test]
910 fn value_update_below_cosine_floor_does_not_supersede() {
911 let agent = AgentId::new();
916 let mut existing = make_memory(
917 "The user's favorite coffee order is a cortado",
918 vec![1.0, 0.0, 0.0],
919 MemoryType::Semantic,
920 );
921 existing.agent_id = agent;
922 let mut new_mem = make_memory(
924 "The user's favorite coffee order is a flat white",
925 vec![0.2, 0.9798, 0.0],
926 MemoryType::Semantic,
927 );
928 new_mem.agent_id = agent;
929 new_mem.created_at = 2000;
930
931 let actions = WriteInferenceEngine::new().infer_on_write(&new_mem, &[existing], &[]);
932 assert!(
933 !actions
934 .iter()
935 .any(|a| matches!(a, InferredAction::InvalidateMemory { .. })),
936 "below the cosine floor a frame match must not supersede, got: {actions:?}"
937 );
938 }
939
940 #[test]
941 fn correction_supersedes_regardless_of_store_order() {
942 let agent = AgentId::new();
947 let mut correction = make_memory(
948 "The user's favorite coffee is a flat white",
949 vec![1.0, 0.0, 0.0],
950 MemoryType::Correction,
951 );
952 correction.agent_id = agent;
953 correction.created_at = 5000;
954 let mut original = make_memory(
955 "The user's favorite coffee is a cortado",
956 vec![0.98, 0.02, 0.0],
957 MemoryType::Semantic,
958 );
959 original.agent_id = agent;
960 original.created_at = 5000; let actions =
964 WriteInferenceEngine::new().infer_on_write(&original, &[correction.clone()], &[]);
965 assert!(
966 actions.iter().any(|a| matches!(
967 a,
968 InferredAction::InvalidateMemory { memory, superseded_by, .. }
969 if *superseded_by == correction.id && *memory == original.id
970 )),
971 "the correction must invalidate the later-arriving original, got: {actions:?}"
972 );
973 }
974
975 #[test]
976 fn correction_supersedes_original_at_equal_timestamps() {
977 let agent = AgentId::new();
980 let mut original = make_memory(
981 "The user's favorite coffee is a cortado",
982 vec![1.0, 0.0, 0.0],
983 MemoryType::Semantic,
984 );
985 original.agent_id = agent;
986 original.created_at = 5000;
987 let mut correction = make_memory(
988 "The user's favorite coffee is a flat white",
989 vec![0.98, 0.02, 0.0],
990 MemoryType::Correction,
991 );
992 correction.agent_id = agent;
993 correction.created_at = 5000;
994
995 let actions =
996 WriteInferenceEngine::new().infer_on_write(&correction, &[original.clone()], &[]);
997 assert!(
998 actions.iter().any(|a| matches!(
999 a,
1000 InferredAction::InvalidateMemory { memory, superseded_by, .. }
1001 if *superseded_by == correction.id && *memory == original.id
1002 )),
1003 "the correction must invalidate the original at equal timestamps, got: {actions:?}"
1004 );
1005 }
1006
1007 #[test]
1008 fn multivalued_facts_at_equal_timestamps_are_both_kept() {
1009 let agent = AgentId::new();
1014 let mut a = make_memory(
1015 "The user knows Rust",
1016 vec![1.0, 0.0, 0.0],
1017 MemoryType::Semantic,
1018 );
1019 a.agent_id = agent;
1020 a.created_at = 5000;
1021 let mut b = make_memory(
1022 "The user knows Python",
1023 vec![0.97, 0.03, 0.0],
1024 MemoryType::Semantic,
1025 );
1026 b.agent_id = agent;
1027 b.created_at = 5000;
1028
1029 let actions = WriteInferenceEngine::new().infer_on_write(&b, &[a], &[]);
1030 assert!(
1031 !actions.iter().any(|x| matches!(
1032 x,
1033 InferredAction::CreateEdge {
1034 edge_type: EdgeType::Supersedes,
1035 ..
1036 }
1037 )),
1038 "coexisting facts at equal timestamps must not supersede, got: {actions:?}"
1039 );
1040 }
1041
1042 #[test]
1043 fn subject_change_is_not_a_value_update() {
1044 let agent = AgentId::new();
1047 let mut existing = make_memory(
1048 "My dog is allergic to chicken",
1049 vec![1.0, 0.0, 0.0],
1050 MemoryType::Semantic,
1051 );
1052 existing.agent_id = agent;
1053 let mut new_mem = make_memory(
1054 "My cat is allergic to chicken",
1055 vec![0.99, 0.01, 0.0],
1056 MemoryType::Semantic,
1057 );
1058 new_mem.agent_id = agent;
1059 new_mem.created_at = 2000;
1060
1061 let engine = WriteInferenceEngine::new();
1062 let actions = engine.infer_on_write(&new_mem, &[existing], &[]);
1063 assert!(
1064 !actions.iter().any(|a| matches!(
1065 a,
1066 InferredAction::CreateEdge {
1067 edge_type: EdgeType::Supersedes,
1068 ..
1069 }
1070 )),
1071 "a subject change must not supersede, got: {actions:?}"
1072 );
1073 }
1074
1075 #[test]
1076 fn value_update_respects_owner_and_config() {
1077 let mut existing = make_memory(
1079 "The user's favorite coffee order is a cortado",
1080 vec![1.0, 0.0, 0.0],
1081 MemoryType::Semantic,
1082 );
1083 existing.agent_id = AgentId::new();
1084 let mut new_mem = make_memory(
1085 "The user's favorite coffee order is a flat white",
1086 vec![0.98, 0.02, 0.0],
1087 MemoryType::Semantic,
1088 );
1089 new_mem.agent_id = AgentId::new(); new_mem.created_at = 2000;
1091
1092 let engine = WriteInferenceEngine::new();
1093 let actions = engine.infer_on_write(&new_mem, &[existing.clone()], &[]);
1094 assert!(
1095 !actions.iter().any(|a| matches!(
1096 a,
1097 InferredAction::CreateEdge {
1098 edge_type: EdgeType::Supersedes,
1099 ..
1100 }
1101 )),
1102 "cross-owner memories must never supersede each other"
1103 );
1104
1105 let agent = AgentId::new();
1107 existing.agent_id = agent;
1108 new_mem.agent_id = agent;
1109 let engine_off = WriteInferenceEngine::with_config(WriteInferenceConfig {
1110 value_update_enabled: false,
1111 ..WriteInferenceConfig::default()
1112 });
1113 let actions = engine_off.infer_on_write(&new_mem, &[existing], &[]);
1114 assert!(
1115 !actions.iter().any(|a| matches!(
1116 a,
1117 InferredAction::CreateEdge {
1118 edge_type: EdgeType::Supersedes,
1119 ..
1120 }
1121 )),
1122 "disabled rule must not supersede"
1123 );
1124 }
1125
1126 #[test]
1127 fn is_value_update_frame_analysis() {
1128 assert!(is_value_update(
1130 "my phone number is 4321",
1131 "my phone number is 1234",
1132 0.6,
1133 4
1134 ));
1135 assert!(!is_value_update(
1137 "my cat is allergic to chicken",
1138 "my dog is allergic to chicken",
1139 0.6,
1140 4
1141 ));
1142 assert!(!is_value_update("same fact", "same fact", 0.6, 4));
1144 assert!(is_value_update(
1149 "The user uses React for the front end",
1150 "The user uses Next.js for the front end",
1151 0.6,
1152 4
1153 ));
1154 assert!(is_value_update(
1155 "i use react for the front end",
1156 "i use next.js for the front end",
1157 0.6,
1158 4
1159 ));
1160 assert!(!is_value_update(
1163 "the cat is allergic to chicken and needs the special food",
1164 "the dog is allergic to chicken and needs the special food",
1165 0.6,
1166 4
1167 ));
1168 assert!(!is_value_update(
1172 "API sibling rule 1 about payload naming",
1173 "API sibling rule 0 about payload naming",
1174 0.6,
1175 4
1176 ));
1177 assert!(!is_value_update(
1181 "Directive number 1",
1182 "Directive number 0",
1183 0.6,
1184 4
1185 ));
1186 assert!(!is_value_update(
1188 "the deploy runs every friday and sarah reviews it after standup",
1189 "the deploy runs every friday unless the release train is frozen for the quarter end audit",
1190 0.6,
1191 4
1192 ));
1193 }
1194
1195 #[test]
1196 fn test_byte_identical_duplicate_is_deduplicated() {
1197 let agent = AgentId::new();
1200 let mut existing = make_memory(
1201 "Ran command: ls -la",
1202 vec![1.0, 0.0, 0.0],
1203 MemoryType::Episodic,
1204 );
1205 existing.agent_id = agent;
1206
1207 let mut new_mem = make_memory(
1208 "Ran command: ls -la",
1209 vec![1.0, 0.0, 0.0],
1210 MemoryType::Episodic,
1211 );
1212 new_mem.agent_id = agent;
1213 new_mem.created_at = 2000;
1214
1215 let engine = WriteInferenceEngine::new();
1216 let actions = engine.infer_on_write(&new_mem, &[existing], &[]);
1217 assert!(
1218 actions
1219 .iter()
1220 .any(|a| matches!(a, InferredAction::DeduplicateExact { .. })),
1221 "Expected identical duplicate to be deduplicated, got: {:?}",
1222 actions
1223 );
1224 }
1225
1226 #[test]
1227 fn test_moderate_similarity_creates_edge() {
1228 let existing = make_memory("topic A", vec![1.0, 0.0, 0.0], MemoryType::Semantic);
1229 let new_mem = make_memory("topic B", vec![0.7, 0.714, 0.0], MemoryType::Semantic);
1231
1232 let engine = WriteInferenceEngine::new();
1233 let actions = engine.infer_on_write(&new_mem, &[existing], &[]);
1234 assert!(
1235 actions.iter().any(|a| matches!(
1236 a,
1237 InferredAction::CreateEdge {
1238 edge_type: EdgeType::Related,
1239 ..
1240 }
1241 )),
1242 "Expected CreateEdge Related, got: {:?}",
1243 actions
1244 );
1245 }
1246
1247 #[tokio::test]
1248 async fn test_llm_invalidation_emits_temporal_actions() {
1249 let agent = AgentId::new();
1250 let mut existing = make_memory(
1251 "Alice works at Acme",
1252 vec![0.8, 0.6, 0.0],
1253 MemoryType::Semantic,
1254 );
1255 existing.agent_id = agent;
1256
1257 let mut new_mem = make_memory(
1258 "Alice joined Google last week",
1259 vec![0.75, 0.65, 0.1],
1260 MemoryType::Semantic,
1261 );
1262 new_mem.agent_id = agent;
1263 new_mem.created_at = 2000;
1264
1265 let judge =
1266 MockLlmJudge::new(r#"{"verdict": "invalidate", "reason": "Alice changed jobs"}"#);
1267 let llm = CognitiveLlmService::new(judge);
1268 let engine = WriteInferenceEngine::new();
1269 let actions = engine
1270 .infer_on_write_with_llm(&new_mem, &[existing.clone()], &[], &llm)
1271 .await;
1272
1273 assert!(
1274 actions
1275 .iter()
1276 .any(|a| matches!(a, InferredAction::InvalidateMemory { .. })),
1277 "Expected InvalidateMemory from LLM verdict, got: {:?}",
1278 actions
1279 );
1280 let invalidation_count = actions
1284 .iter()
1285 .filter(|a| {
1286 matches!(
1287 a,
1288 InferredAction::InvalidateMemory { .. } | InferredAction::MarkObsolete { .. }
1289 )
1290 })
1291 .count();
1292 assert_eq!(
1293 invalidation_count, 1,
1294 "Expected a single invalidation action, got: {:?}",
1295 actions
1296 );
1297 }
1298
1299 #[tokio::test]
1300 async fn test_llm_update_emits_update_content() {
1301 let agent = AgentId::new();
1302 let mut existing = make_memory(
1303 "Project uses React",
1304 vec![0.8, 0.6, 0.0],
1305 MemoryType::Semantic,
1306 );
1307 existing.agent_id = agent;
1308
1309 let mut new_mem = make_memory(
1310 "Project migrated from React to Vue",
1311 vec![0.75, 0.65, 0.1],
1312 MemoryType::Semantic,
1313 );
1314 new_mem.agent_id = agent;
1315 new_mem.created_at = 2000;
1316
1317 let judge = MockLlmJudge::new(
1318 r#"{"verdict": "update", "merged_content": "Project migrated from React to Vue in Q2", "reason": "adds temporal context"}"#,
1319 );
1320 let llm = CognitiveLlmService::new(judge);
1321 let engine = WriteInferenceEngine::new();
1322 let actions = engine
1323 .infer_on_write_with_llm(&new_mem, &[existing.clone()], &[], &llm)
1324 .await;
1325
1326 assert!(
1327 actions
1328 .iter()
1329 .any(|a| matches!(a, InferredAction::UpdateContent { .. })),
1330 "Expected UpdateContent from LLM update verdict, got: {:?}",
1331 actions
1332 );
1333 }
1334
1335 #[tokio::test]
1336 async fn test_llm_keep_falls_through_to_contradiction_check() {
1337 let agent = AgentId::new();
1338 let mut existing = make_memory("Prefers tabs", vec![0.9, 0.44, 0.0], MemoryType::Semantic);
1340 existing.agent_id = agent;
1341
1342 let mut new_mem = make_memory(
1343 "Prefers spaces",
1344 vec![0.88, 0.47, 0.0],
1345 MemoryType::Semantic,
1346 );
1347 new_mem.agent_id = agent;
1348 new_mem.created_at = 2000;
1349
1350 let judge = MockLlmJudge::new(
1351 r#"{"verdict": "compatible", "reason": "different formatting preferences"}"#,
1352 );
1353 let llm = CognitiveLlmService::new(judge);
1354 let engine = WriteInferenceEngine::new();
1355 let actions = engine
1356 .infer_on_write_with_llm(&new_mem, &[existing.clone()], &[], &llm)
1357 .await;
1358
1359 assert!(
1362 !actions
1363 .iter()
1364 .any(|a| matches!(a, InferredAction::FlagContradiction { .. })),
1365 "Should not flag contradiction when LLM says compatible",
1366 );
1367 }
1368}