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 {
52 root: MemoryId,
53 delta: f32,
54 },
55}
56
57fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
58 if a.len() != b.len() || a.is_empty() {
59 return 0.0;
60 }
61 let mut dot = 0.0f32;
62 let mut norm_a = 0.0f32;
63 let mut norm_b = 0.0f32;
64 for i in 0..a.len() {
65 dot += a[i] * b[i];
66 norm_a += a[i] * a[i];
67 norm_b += b[i] * b[i];
68 }
69 let denom = norm_a.sqrt() * norm_b.sqrt();
70 if denom == 0.0 { 0.0 } else { dot / denom }
71}
72
73#[derive(Debug, Clone)]
75pub struct WriteInferenceConfig {
76 pub contradiction_threshold: f32,
78 pub obsolete_threshold: f32,
80 pub related_min: f32,
82 pub related_max: f32,
84 pub correction_threshold: f32,
86 pub confidence_decay_factor: f32,
88 pub confidence_floor: f32,
90 pub value_update_enabled: bool,
98 pub value_update_min_similarity: f32,
111 pub value_update_prefix_share: f32,
114 pub value_update_max_tail: usize,
117}
118
119impl Default for WriteInferenceConfig {
120 fn default() -> Self {
121 Self {
122 contradiction_threshold: 0.95,
123 obsolete_threshold: 0.85,
124 related_min: 0.6,
125 related_max: 0.85,
126 correction_threshold: 0.5,
127 confidence_decay_factor: 0.5,
128 confidence_floor: 0.1,
129 value_update_enabled: true,
130 value_update_min_similarity: 0.5,
131 value_update_prefix_share: 0.6,
132 value_update_max_tail: 4,
133 }
134 }
135}
136
137fn frame_tokens(s: &str) -> Vec<String> {
140 s.split(|c: char| !c.is_alphanumeric())
141 .filter(|t| !t.is_empty())
142 .map(|t| t.to_lowercase())
143 .collect()
144}
145
146fn is_value_update(new: &str, old: &str, prefix_share: f32, max_tail: usize) -> bool {
152 let nt = frame_tokens(new);
153 let ot = frame_tokens(old);
154 if nt.is_empty() || ot.is_empty() || nt == ot {
155 return false;
156 }
157 let prefix = nt.iter().zip(ot.iter()).take_while(|(a, b)| a == b).count();
158 let longest = nt.len().max(ot.len());
159 let tail = longest - prefix;
160 prefix as f32 / longest as f32 >= prefix_share && tail > 0 && tail <= max_tail
161}
162
163fn value_update_resolution(
171 new: &MemoryNode,
172 existing: &MemoryNode,
173) -> Option<(MemoryId, MemoryId, f32)> {
174 let new_correction = new.memory_type == MemoryType::Correction;
175 let old_correction = existing.memory_type == MemoryType::Correction;
176 if new_correction && !old_correction {
177 return Some((new.id, existing.id, existing.confidence));
178 }
179 if old_correction && !new_correction {
180 return Some((existing.id, new.id, new.confidence));
181 }
182 if new.created_at > existing.created_at {
183 return Some((new.id, existing.id, existing.confidence));
184 }
185 if existing.created_at > new.created_at {
186 return Some((existing.id, new.id, new.confidence));
187 }
188 None
189}
190
191pub struct WriteInferenceEngine {
192 config: WriteInferenceConfig,
193}
194
195impl WriteInferenceEngine {
196 pub fn new() -> Self {
197 Self {
198 config: WriteInferenceConfig::default(),
199 }
200 }
201
202 pub fn with_config(config: WriteInferenceConfig) -> Self {
203 Self { config }
204 }
205
206 pub fn infer_on_write(
207 &self,
208 new_memory: &MemoryNode,
209 existing_memories: &[MemoryNode],
210 existing_edges: &[(MemoryId, MemoryId, EdgeType)],
211 ) -> Vec<InferredAction> {
212 let _ = existing_edges; let mut actions = Vec::new();
214 let mut value_update_handled: Vec<MemoryId> = Vec::new();
217
218 for existing in existing_memories {
219 if existing.id == new_memory.id {
220 continue;
221 }
222
223 let sim = cosine_similarity(&new_memory.embedding, &existing.embedding);
224
225 if existing.content == new_memory.content
239 && sim > self.config.obsolete_threshold
240 && new_memory.created_at > existing.created_at
241 {
242 actions.push(InferredAction::DeduplicateExact {
243 duplicate: existing.id,
244 keeper: new_memory.id,
245 });
246 } else if self.config.value_update_enabled
247 && sim >= self.config.value_update_min_similarity
248 && existing.agent_id == new_memory.agent_id
249 && existing.user_id == new_memory.user_id
250 && is_value_update(
251 &new_memory.content,
252 &existing.content,
253 self.config.value_update_prefix_share,
254 self.config.value_update_max_tail,
255 )
256 {
257 value_update_handled.push(existing.id);
266 if let Some((winner, loser, loser_confidence)) =
267 value_update_resolution(new_memory, existing)
268 {
269 actions.push(InferredAction::CreateEdge {
270 source: winner,
271 target: loser,
272 edge_type: EdgeType::Supersedes,
273 weight: sim,
274 });
275 actions.push(InferredAction::UpdateConfidence {
276 memory: loser,
277 new_confidence: (loser_confidence * self.config.confidence_decay_factor)
278 .max(self.config.confidence_floor),
279 });
280 }
281 } else if sim > self.config.related_min && sim <= self.config.related_max {
285 actions.push(InferredAction::CreateEdge {
286 source: new_memory.id,
287 target: existing.id,
288 edge_type: EdgeType::Related,
289 weight: sim,
290 });
291 }
292 }
293
294 if new_memory.memory_type == MemoryType::Correction
297 && let Some(original) = existing_memories
298 .iter()
299 .filter(|m| m.id != new_memory.id && !value_update_handled.contains(&m.id))
300 .max_by(|a, b| {
301 cosine_similarity(&new_memory.embedding, &a.embedding)
302 .partial_cmp(&cosine_similarity(&new_memory.embedding, &b.embedding))
303 .unwrap_or(std::cmp::Ordering::Equal)
304 })
305 {
306 let sim = cosine_similarity(&new_memory.embedding, &original.embedding);
307 if sim > self.config.correction_threshold {
308 actions.push(InferredAction::CreateEdge {
309 source: new_memory.id,
310 target: original.id,
311 edge_type: EdgeType::Supersedes,
312 weight: 1.0,
313 });
314 actions.push(InferredAction::UpdateConfidence {
315 memory: original.id,
316 new_confidence: (original.confidence * self.config.confidence_decay_factor)
317 .max(self.config.confidence_floor),
318 });
319 }
320 }
321
322 actions
323 }
324
325 pub async fn infer_on_write_with_llm<J: LlmJudge>(
330 &self,
331 new_memory: &MemoryNode,
332 existing_memories: &[MemoryNode],
333 existing_edges: &[(MemoryId, MemoryId, EdgeType)],
334 llm: &CognitiveLlmService<J>,
335 ) -> Vec<InferredAction> {
336 let _ = existing_edges;
337 let mut actions = Vec::new();
338
339 let new_summary = memory_to_summary(new_memory);
340
341 for existing in existing_memories {
342 if existing.id == new_memory.id {
343 continue;
344 }
345
346 let sim = cosine_similarity(&new_memory.embedding, &existing.embedding);
347
348 if sim > 0.5 && existing.agent_id == new_memory.agent_id {
351 let old_summary = memory_to_summary(existing);
352
353 if let Ok(verdict) = llm.judge_invalidation(&old_summary, &new_summary).await {
355 match verdict {
356 InvalidationVerdict::Invalidate { reason: _ } => {
357 actions.push(InferredAction::InvalidateMemory {
360 memory: existing.id,
361 superseded_by: new_memory.id,
362 valid_until: new_memory.created_at,
363 });
364 actions.push(InferredAction::UpdateConfidence {
365 memory: existing.id,
366 new_confidence: (existing.confidence
367 * self.config.confidence_decay_factor)
368 .max(self.config.confidence_floor),
369 });
370 continue;
372 }
373 InvalidationVerdict::Update {
374 merged_content,
375 reason,
376 } => {
377 actions.push(InferredAction::UpdateContent {
378 memory: existing.id,
379 new_content: merged_content,
380 reason,
381 });
382 continue;
383 }
384 InvalidationVerdict::Keep { .. } => {
385 }
387 }
388 }
389
390 if sim > 0.7
392 && existing.content != new_memory.content
393 && let Ok(verdict) = llm.detect_contradiction(&old_summary, &new_summary).await
394 {
395 match verdict {
396 ContradictionVerdict::Contradicts { reason } => {
397 actions.push(InferredAction::FlagContradiction {
398 existing: existing.id,
399 new: new_memory.id,
400 reason,
401 });
402 }
403 ContradictionVerdict::Supersedes { winner, reason: _ } => {
404 let winner_is_new = winner == new_memory.id.to_string();
405 let (obsolete, superseder) = if winner_is_new {
406 (existing.id, new_memory.id)
407 } else {
408 (new_memory.id, existing.id)
409 };
410 actions.push(InferredAction::InvalidateMemory {
411 memory: obsolete,
412 superseded_by: superseder,
413 valid_until: new_memory.created_at,
414 });
415 }
416 ContradictionVerdict::Compatible { .. } => {}
417 }
418 }
419 }
420
421 if sim > self.config.related_min && sim <= self.config.related_max {
423 actions.push(InferredAction::CreateEdge {
424 source: new_memory.id,
425 target: existing.id,
426 edge_type: EdgeType::Related,
427 weight: sim,
428 });
429 }
430 }
431
432 if new_memory.memory_type == MemoryType::Correction
434 && let Some(original) = existing_memories
435 .iter()
436 .filter(|m| m.id != new_memory.id)
437 .max_by(|a, b| {
438 cosine_similarity(&new_memory.embedding, &a.embedding)
439 .partial_cmp(&cosine_similarity(&new_memory.embedding, &b.embedding))
440 .unwrap_or(std::cmp::Ordering::Equal)
441 })
442 {
443 let sim = cosine_similarity(&new_memory.embedding, &original.embedding);
444 if sim > self.config.correction_threshold {
445 actions.push(InferredAction::InvalidateMemory {
446 memory: original.id,
447 superseded_by: new_memory.id,
448 valid_until: new_memory.created_at,
449 });
450 actions.push(InferredAction::UpdateConfidence {
451 memory: original.id,
452 new_confidence: (original.confidence * self.config.confidence_decay_factor)
453 .max(self.config.confidence_floor),
454 });
455 }
456 }
457
458 actions
459 }
460}
461
462impl Default for WriteInferenceEngine {
463 fn default() -> Self {
464 Self::new()
465 }
466}
467
468fn memory_to_summary(m: &MemoryNode) -> MemorySummary {
469 MemorySummary {
470 id: m.id,
471 content: m.content.clone(),
472 memory_type: m.memory_type,
473 confidence: m.confidence,
474 created_at: m.created_at,
475 }
476}
477
478#[cfg(test)]
479mod tests {
480 use super::*;
481 use mentedb_core::memory::MemoryType;
482 use mentedb_core::types::AgentId;
483
484 use crate::llm::MockLlmJudge;
485
486 fn make_memory(content: &str, embedding: Vec<f32>, mem_type: MemoryType) -> MemoryNode {
487 let mut m = MemoryNode::new(AgentId::new(), mem_type, content.to_string(), embedding);
488 m.created_at = 1000;
489 m
490 }
491
492 #[test]
493 fn test_heuristic_does_not_flag_contradiction_from_similarity() {
494 let agent = AgentId::new();
501 let mut existing =
502 make_memory("uses PostgreSQL", vec![1.0, 0.0, 0.0], MemoryType::Semantic);
503 existing.agent_id = agent;
504
505 let mut new_mem = make_memory("uses MySQL", vec![0.99, 0.01, 0.0], MemoryType::Semantic);
506 new_mem.agent_id = agent;
507 new_mem.created_at = 2000;
508
509 let engine = WriteInferenceEngine::new();
510 let actions = engine.infer_on_write(&new_mem, &[existing], &[]);
511 assert!(
512 !actions
513 .iter()
514 .any(|a| matches!(a, InferredAction::FlagContradiction { .. })),
515 "Heuristic must not flag contradictions from bare similarity, got: {:?}",
516 actions
517 );
518 }
519
520 #[test]
521 fn value_update_supersedes_the_old_fact() {
522 let agent = AgentId::new();
526 let mut existing = make_memory(
527 "The user's favorite coffee order is a cortado",
528 vec![1.0, 0.0, 0.0],
529 MemoryType::Semantic,
530 );
531 existing.agent_id = agent;
532 let mut new_mem = make_memory(
533 "The user's favorite coffee order is a flat white",
534 vec![0.98, 0.02, 0.0],
535 MemoryType::Semantic,
536 );
537 new_mem.agent_id = agent;
538 new_mem.created_at = 2000;
539
540 let engine = WriteInferenceEngine::new();
541 let actions = engine.infer_on_write(&new_mem, &[existing.clone()], &[]);
542 assert!(
543 actions.iter().any(|a| matches!(
544 a,
545 InferredAction::CreateEdge { source, target, edge_type: EdgeType::Supersedes, .. }
546 if *source == new_mem.id && *target == existing.id
547 )),
548 "value update must create a Supersedes edge, got: {actions:?}"
549 );
550 assert!(
551 actions
552 .iter()
553 .any(|a| matches!(a, InferredAction::UpdateConfidence { memory, .. } if *memory == existing.id)),
554 "superseded fact's confidence must decay"
555 );
556 }
557
558 #[test]
559 fn value_update_fires_at_realistic_embedder_cosine() {
560 let agent = AgentId::new();
568 let mut existing = make_memory(
569 "The user's favorite coffee order is a cortado",
570 vec![1.0, 0.0, 0.0],
571 MemoryType::Semantic,
572 );
573 existing.agent_id = agent;
574 let mut new_mem = make_memory(
576 "The user's favorite coffee order is a flat white",
577 vec![0.6, 0.8, 0.0],
578 MemoryType::Semantic,
579 );
580 new_mem.agent_id = agent;
581 new_mem.created_at = 2000;
582
583 let actions =
584 WriteInferenceEngine::new().infer_on_write(&new_mem, &[existing.clone()], &[]);
585 assert!(
586 actions.iter().any(|a| matches!(
587 a,
588 InferredAction::CreateEdge { source, target, edge_type: EdgeType::Supersedes, .. }
589 if *source == new_mem.id && *target == existing.id
590 )),
591 "a frame-matched correction at realistic cosine 0.6 must supersede, got: {actions:?}"
592 );
593 }
594
595 #[test]
596 fn value_update_below_cosine_floor_does_not_supersede() {
597 let agent = AgentId::new();
602 let mut existing = make_memory(
603 "The user's favorite coffee order is a cortado",
604 vec![1.0, 0.0, 0.0],
605 MemoryType::Semantic,
606 );
607 existing.agent_id = agent;
608 let mut new_mem = make_memory(
610 "The user's favorite coffee order is a flat white",
611 vec![0.4, 0.917, 0.0],
612 MemoryType::Semantic,
613 );
614 new_mem.agent_id = agent;
615 new_mem.created_at = 2000;
616
617 let actions = WriteInferenceEngine::new().infer_on_write(&new_mem, &[existing], &[]);
618 assert!(
619 !actions.iter().any(|a| matches!(
620 a,
621 InferredAction::CreateEdge {
622 edge_type: EdgeType::Supersedes,
623 ..
624 }
625 )),
626 "below the cosine floor a frame match must not supersede, got: {actions:?}"
627 );
628 }
629
630 #[test]
631 fn correction_supersedes_regardless_of_store_order() {
632 let agent = AgentId::new();
637 let mut correction = make_memory(
638 "The user's favorite coffee is a flat white",
639 vec![1.0, 0.0, 0.0],
640 MemoryType::Correction,
641 );
642 correction.agent_id = agent;
643 correction.created_at = 5000;
644 let mut original = make_memory(
645 "The user's favorite coffee is a cortado",
646 vec![0.98, 0.02, 0.0],
647 MemoryType::Semantic,
648 );
649 original.agent_id = agent;
650 original.created_at = 5000; let actions =
654 WriteInferenceEngine::new().infer_on_write(&original, &[correction.clone()], &[]);
655 assert!(
656 actions.iter().any(|a| matches!(
657 a,
658 InferredAction::CreateEdge { source, target, edge_type: EdgeType::Supersedes, .. }
659 if *source == correction.id && *target == original.id
660 )),
661 "the correction must supersede the later-arriving original, got: {actions:?}"
662 );
663 }
664
665 #[test]
666 fn correction_supersedes_original_at_equal_timestamps() {
667 let agent = AgentId::new();
670 let mut original = make_memory(
671 "The user's favorite coffee is a cortado",
672 vec![1.0, 0.0, 0.0],
673 MemoryType::Semantic,
674 );
675 original.agent_id = agent;
676 original.created_at = 5000;
677 let mut correction = make_memory(
678 "The user's favorite coffee is a flat white",
679 vec![0.98, 0.02, 0.0],
680 MemoryType::Correction,
681 );
682 correction.agent_id = agent;
683 correction.created_at = 5000;
684
685 let actions =
686 WriteInferenceEngine::new().infer_on_write(&correction, &[original.clone()], &[]);
687 assert!(
688 actions.iter().any(|a| matches!(
689 a,
690 InferredAction::CreateEdge { source, target, edge_type: EdgeType::Supersedes, .. }
691 if *source == correction.id && *target == original.id
692 )),
693 "the correction must supersede the original at equal timestamps, got: {actions:?}"
694 );
695 }
696
697 #[test]
698 fn multivalued_facts_at_equal_timestamps_are_both_kept() {
699 let agent = AgentId::new();
704 let mut a = make_memory(
705 "The user knows Rust",
706 vec![1.0, 0.0, 0.0],
707 MemoryType::Semantic,
708 );
709 a.agent_id = agent;
710 a.created_at = 5000;
711 let mut b = make_memory(
712 "The user knows Python",
713 vec![0.97, 0.03, 0.0],
714 MemoryType::Semantic,
715 );
716 b.agent_id = agent;
717 b.created_at = 5000;
718
719 let actions = WriteInferenceEngine::new().infer_on_write(&b, &[a], &[]);
720 assert!(
721 !actions.iter().any(|x| matches!(
722 x,
723 InferredAction::CreateEdge {
724 edge_type: EdgeType::Supersedes,
725 ..
726 }
727 )),
728 "coexisting facts at equal timestamps must not supersede, got: {actions:?}"
729 );
730 }
731
732 #[test]
733 fn subject_change_is_not_a_value_update() {
734 let agent = AgentId::new();
737 let mut existing = make_memory(
738 "My dog is allergic to chicken",
739 vec![1.0, 0.0, 0.0],
740 MemoryType::Semantic,
741 );
742 existing.agent_id = agent;
743 let mut new_mem = make_memory(
744 "My cat is allergic to chicken",
745 vec![0.99, 0.01, 0.0],
746 MemoryType::Semantic,
747 );
748 new_mem.agent_id = agent;
749 new_mem.created_at = 2000;
750
751 let engine = WriteInferenceEngine::new();
752 let actions = engine.infer_on_write(&new_mem, &[existing], &[]);
753 assert!(
754 !actions.iter().any(|a| matches!(
755 a,
756 InferredAction::CreateEdge {
757 edge_type: EdgeType::Supersedes,
758 ..
759 }
760 )),
761 "a subject change must not supersede, got: {actions:?}"
762 );
763 }
764
765 #[test]
766 fn value_update_respects_owner_and_config() {
767 let mut existing = make_memory(
769 "The user's favorite coffee order is a cortado",
770 vec![1.0, 0.0, 0.0],
771 MemoryType::Semantic,
772 );
773 existing.agent_id = AgentId::new();
774 let mut new_mem = make_memory(
775 "The user's favorite coffee order is a flat white",
776 vec![0.98, 0.02, 0.0],
777 MemoryType::Semantic,
778 );
779 new_mem.agent_id = AgentId::new(); new_mem.created_at = 2000;
781
782 let engine = WriteInferenceEngine::new();
783 let actions = engine.infer_on_write(&new_mem, &[existing.clone()], &[]);
784 assert!(
785 !actions.iter().any(|a| matches!(
786 a,
787 InferredAction::CreateEdge {
788 edge_type: EdgeType::Supersedes,
789 ..
790 }
791 )),
792 "cross-owner memories must never supersede each other"
793 );
794
795 let agent = AgentId::new();
797 existing.agent_id = agent;
798 new_mem.agent_id = agent;
799 let engine_off = WriteInferenceEngine::with_config(WriteInferenceConfig {
800 value_update_enabled: false,
801 ..WriteInferenceConfig::default()
802 });
803 let actions = engine_off.infer_on_write(&new_mem, &[existing], &[]);
804 assert!(
805 !actions.iter().any(|a| matches!(
806 a,
807 InferredAction::CreateEdge {
808 edge_type: EdgeType::Supersedes,
809 ..
810 }
811 )),
812 "disabled rule must not supersede"
813 );
814 }
815
816 #[test]
817 fn is_value_update_frame_analysis() {
818 assert!(is_value_update(
820 "my phone number is 4321",
821 "my phone number is 1234",
822 0.6,
823 4
824 ));
825 assert!(!is_value_update(
827 "my cat is allergic to chicken",
828 "my dog is allergic to chicken",
829 0.6,
830 4
831 ));
832 assert!(!is_value_update("same fact", "same fact", 0.6, 4));
834 assert!(!is_value_update(
836 "the deploy runs every friday and sarah reviews it after standup",
837 "the deploy runs every friday unless the release train is frozen for the quarter end audit",
838 0.6,
839 4
840 ));
841 }
842
843 #[test]
844 fn test_byte_identical_duplicate_is_deduplicated() {
845 let agent = AgentId::new();
848 let mut existing = make_memory(
849 "Ran command: ls -la",
850 vec![1.0, 0.0, 0.0],
851 MemoryType::Episodic,
852 );
853 existing.agent_id = agent;
854
855 let mut new_mem = make_memory(
856 "Ran command: ls -la",
857 vec![1.0, 0.0, 0.0],
858 MemoryType::Episodic,
859 );
860 new_mem.agent_id = agent;
861 new_mem.created_at = 2000;
862
863 let engine = WriteInferenceEngine::new();
864 let actions = engine.infer_on_write(&new_mem, &[existing], &[]);
865 assert!(
866 actions
867 .iter()
868 .any(|a| matches!(a, InferredAction::DeduplicateExact { .. })),
869 "Expected identical duplicate to be deduplicated, got: {:?}",
870 actions
871 );
872 }
873
874 #[test]
875 fn test_moderate_similarity_creates_edge() {
876 let existing = make_memory("topic A", vec![1.0, 0.0, 0.0], MemoryType::Semantic);
877 let new_mem = make_memory("topic B", vec![0.7, 0.714, 0.0], MemoryType::Semantic);
879
880 let engine = WriteInferenceEngine::new();
881 let actions = engine.infer_on_write(&new_mem, &[existing], &[]);
882 assert!(
883 actions.iter().any(|a| matches!(
884 a,
885 InferredAction::CreateEdge {
886 edge_type: EdgeType::Related,
887 ..
888 }
889 )),
890 "Expected CreateEdge Related, got: {:?}",
891 actions
892 );
893 }
894
895 #[tokio::test]
896 async fn test_llm_invalidation_emits_temporal_actions() {
897 let agent = AgentId::new();
898 let mut existing = make_memory(
899 "Alice works at Acme",
900 vec![0.8, 0.6, 0.0],
901 MemoryType::Semantic,
902 );
903 existing.agent_id = agent;
904
905 let mut new_mem = make_memory(
906 "Alice joined Google last week",
907 vec![0.75, 0.65, 0.1],
908 MemoryType::Semantic,
909 );
910 new_mem.agent_id = agent;
911 new_mem.created_at = 2000;
912
913 let judge =
914 MockLlmJudge::new(r#"{"verdict": "invalidate", "reason": "Alice changed jobs"}"#);
915 let llm = CognitiveLlmService::new(judge);
916 let engine = WriteInferenceEngine::new();
917 let actions = engine
918 .infer_on_write_with_llm(&new_mem, &[existing.clone()], &[], &llm)
919 .await;
920
921 assert!(
922 actions
923 .iter()
924 .any(|a| matches!(a, InferredAction::InvalidateMemory { .. })),
925 "Expected InvalidateMemory from LLM verdict, got: {:?}",
926 actions
927 );
928 let invalidation_count = actions
932 .iter()
933 .filter(|a| {
934 matches!(
935 a,
936 InferredAction::InvalidateMemory { .. } | InferredAction::MarkObsolete { .. }
937 )
938 })
939 .count();
940 assert_eq!(
941 invalidation_count, 1,
942 "Expected a single invalidation action, got: {:?}",
943 actions
944 );
945 }
946
947 #[tokio::test]
948 async fn test_llm_update_emits_update_content() {
949 let agent = AgentId::new();
950 let mut existing = make_memory(
951 "Project uses React",
952 vec![0.8, 0.6, 0.0],
953 MemoryType::Semantic,
954 );
955 existing.agent_id = agent;
956
957 let mut new_mem = make_memory(
958 "Project migrated from React to Vue",
959 vec![0.75, 0.65, 0.1],
960 MemoryType::Semantic,
961 );
962 new_mem.agent_id = agent;
963 new_mem.created_at = 2000;
964
965 let judge = MockLlmJudge::new(
966 r#"{"verdict": "update", "merged_content": "Project migrated from React to Vue in Q2", "reason": "adds temporal context"}"#,
967 );
968 let llm = CognitiveLlmService::new(judge);
969 let engine = WriteInferenceEngine::new();
970 let actions = engine
971 .infer_on_write_with_llm(&new_mem, &[existing.clone()], &[], &llm)
972 .await;
973
974 assert!(
975 actions
976 .iter()
977 .any(|a| matches!(a, InferredAction::UpdateContent { .. })),
978 "Expected UpdateContent from LLM update verdict, got: {:?}",
979 actions
980 );
981 }
982
983 #[tokio::test]
984 async fn test_llm_keep_falls_through_to_contradiction_check() {
985 let agent = AgentId::new();
986 let mut existing = make_memory("Prefers tabs", vec![0.9, 0.44, 0.0], MemoryType::Semantic);
988 existing.agent_id = agent;
989
990 let mut new_mem = make_memory(
991 "Prefers spaces",
992 vec![0.88, 0.47, 0.0],
993 MemoryType::Semantic,
994 );
995 new_mem.agent_id = agent;
996 new_mem.created_at = 2000;
997
998 let judge = MockLlmJudge::new(
999 r#"{"verdict": "compatible", "reason": "different formatting preferences"}"#,
1000 );
1001 let llm = CognitiveLlmService::new(judge);
1002 let engine = WriteInferenceEngine::new();
1003 let actions = engine
1004 .infer_on_write_with_llm(&new_mem, &[existing.clone()], &[], &llm)
1005 .await;
1006
1007 assert!(
1010 !actions
1011 .iter()
1012 .any(|a| matches!(a, InferredAction::FlagContradiction { .. })),
1013 "Should not flag contradiction when LLM says compatible",
1014 );
1015 }
1016}