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
163pub struct WriteInferenceEngine {
164 config: WriteInferenceConfig,
165}
166
167impl WriteInferenceEngine {
168 pub fn new() -> Self {
169 Self {
170 config: WriteInferenceConfig::default(),
171 }
172 }
173
174 pub fn with_config(config: WriteInferenceConfig) -> Self {
175 Self { config }
176 }
177
178 pub fn infer_on_write(
179 &self,
180 new_memory: &MemoryNode,
181 existing_memories: &[MemoryNode],
182 existing_edges: &[(MemoryId, MemoryId, EdgeType)],
183 ) -> Vec<InferredAction> {
184 let _ = existing_edges; let mut actions = Vec::new();
186
187 for existing in existing_memories {
188 if existing.id == new_memory.id {
189 continue;
190 }
191
192 let sim = cosine_similarity(&new_memory.embedding, &existing.embedding);
193
194 if existing.content == new_memory.content
208 && sim > self.config.obsolete_threshold
209 && new_memory.created_at > existing.created_at
210 {
211 actions.push(InferredAction::DeduplicateExact {
212 duplicate: existing.id,
213 keeper: new_memory.id,
214 });
215 } else if self.config.value_update_enabled
216 && sim >= self.config.value_update_min_similarity
217 && new_memory.created_at > existing.created_at
218 && existing.agent_id == new_memory.agent_id
219 && existing.user_id == new_memory.user_id
220 && is_value_update(
221 &new_memory.content,
222 &existing.content,
223 self.config.value_update_prefix_share,
224 self.config.value_update_max_tail,
225 )
226 {
227 actions.push(InferredAction::CreateEdge {
230 source: new_memory.id,
231 target: existing.id,
232 edge_type: EdgeType::Supersedes,
233 weight: sim,
234 });
235 actions.push(InferredAction::UpdateConfidence {
236 memory: existing.id,
237 new_confidence: (existing.confidence * self.config.confidence_decay_factor)
238 .max(self.config.confidence_floor),
239 });
240 } else if sim > self.config.related_min && sim <= self.config.related_max {
241 actions.push(InferredAction::CreateEdge {
242 source: new_memory.id,
243 target: existing.id,
244 edge_type: EdgeType::Related,
245 weight: sim,
246 });
247 }
248 }
249
250 if new_memory.memory_type == MemoryType::Correction
252 && let Some(original) = existing_memories
253 .iter()
254 .filter(|m| m.id != new_memory.id)
255 .max_by(|a, b| {
256 cosine_similarity(&new_memory.embedding, &a.embedding)
257 .partial_cmp(&cosine_similarity(&new_memory.embedding, &b.embedding))
258 .unwrap_or(std::cmp::Ordering::Equal)
259 })
260 {
261 let sim = cosine_similarity(&new_memory.embedding, &original.embedding);
262 if sim > self.config.correction_threshold {
263 actions.push(InferredAction::CreateEdge {
264 source: new_memory.id,
265 target: original.id,
266 edge_type: EdgeType::Supersedes,
267 weight: 1.0,
268 });
269 actions.push(InferredAction::UpdateConfidence {
270 memory: original.id,
271 new_confidence: (original.confidence * self.config.confidence_decay_factor)
272 .max(self.config.confidence_floor),
273 });
274 }
275 }
276
277 actions
278 }
279
280 pub async fn infer_on_write_with_llm<J: LlmJudge>(
285 &self,
286 new_memory: &MemoryNode,
287 existing_memories: &[MemoryNode],
288 existing_edges: &[(MemoryId, MemoryId, EdgeType)],
289 llm: &CognitiveLlmService<J>,
290 ) -> Vec<InferredAction> {
291 let _ = existing_edges;
292 let mut actions = Vec::new();
293
294 let new_summary = memory_to_summary(new_memory);
295
296 for existing in existing_memories {
297 if existing.id == new_memory.id {
298 continue;
299 }
300
301 let sim = cosine_similarity(&new_memory.embedding, &existing.embedding);
302
303 if sim > 0.5 && existing.agent_id == new_memory.agent_id {
306 let old_summary = memory_to_summary(existing);
307
308 if let Ok(verdict) = llm.judge_invalidation(&old_summary, &new_summary).await {
310 match verdict {
311 InvalidationVerdict::Invalidate { reason: _ } => {
312 actions.push(InferredAction::InvalidateMemory {
315 memory: existing.id,
316 superseded_by: new_memory.id,
317 valid_until: new_memory.created_at,
318 });
319 actions.push(InferredAction::UpdateConfidence {
320 memory: existing.id,
321 new_confidence: (existing.confidence
322 * self.config.confidence_decay_factor)
323 .max(self.config.confidence_floor),
324 });
325 continue;
327 }
328 InvalidationVerdict::Update {
329 merged_content,
330 reason,
331 } => {
332 actions.push(InferredAction::UpdateContent {
333 memory: existing.id,
334 new_content: merged_content,
335 reason,
336 });
337 continue;
338 }
339 InvalidationVerdict::Keep { .. } => {
340 }
342 }
343 }
344
345 if sim > 0.7
347 && existing.content != new_memory.content
348 && let Ok(verdict) = llm.detect_contradiction(&old_summary, &new_summary).await
349 {
350 match verdict {
351 ContradictionVerdict::Contradicts { reason } => {
352 actions.push(InferredAction::FlagContradiction {
353 existing: existing.id,
354 new: new_memory.id,
355 reason,
356 });
357 }
358 ContradictionVerdict::Supersedes { winner, reason: _ } => {
359 let winner_is_new = winner == new_memory.id.to_string();
360 let (obsolete, superseder) = if winner_is_new {
361 (existing.id, new_memory.id)
362 } else {
363 (new_memory.id, existing.id)
364 };
365 actions.push(InferredAction::InvalidateMemory {
366 memory: obsolete,
367 superseded_by: superseder,
368 valid_until: new_memory.created_at,
369 });
370 }
371 ContradictionVerdict::Compatible { .. } => {}
372 }
373 }
374 }
375
376 if sim > self.config.related_min && sim <= self.config.related_max {
378 actions.push(InferredAction::CreateEdge {
379 source: new_memory.id,
380 target: existing.id,
381 edge_type: EdgeType::Related,
382 weight: sim,
383 });
384 }
385 }
386
387 if new_memory.memory_type == MemoryType::Correction
389 && let Some(original) = existing_memories
390 .iter()
391 .filter(|m| m.id != new_memory.id)
392 .max_by(|a, b| {
393 cosine_similarity(&new_memory.embedding, &a.embedding)
394 .partial_cmp(&cosine_similarity(&new_memory.embedding, &b.embedding))
395 .unwrap_or(std::cmp::Ordering::Equal)
396 })
397 {
398 let sim = cosine_similarity(&new_memory.embedding, &original.embedding);
399 if sim > self.config.correction_threshold {
400 actions.push(InferredAction::InvalidateMemory {
401 memory: original.id,
402 superseded_by: new_memory.id,
403 valid_until: new_memory.created_at,
404 });
405 actions.push(InferredAction::UpdateConfidence {
406 memory: original.id,
407 new_confidence: (original.confidence * self.config.confidence_decay_factor)
408 .max(self.config.confidence_floor),
409 });
410 }
411 }
412
413 actions
414 }
415}
416
417impl Default for WriteInferenceEngine {
418 fn default() -> Self {
419 Self::new()
420 }
421}
422
423fn memory_to_summary(m: &MemoryNode) -> MemorySummary {
424 MemorySummary {
425 id: m.id,
426 content: m.content.clone(),
427 memory_type: m.memory_type,
428 confidence: m.confidence,
429 created_at: m.created_at,
430 }
431}
432
433#[cfg(test)]
434mod tests {
435 use super::*;
436 use mentedb_core::memory::MemoryType;
437 use mentedb_core::types::AgentId;
438
439 use crate::llm::MockLlmJudge;
440
441 fn make_memory(content: &str, embedding: Vec<f32>, mem_type: MemoryType) -> MemoryNode {
442 let mut m = MemoryNode::new(AgentId::new(), mem_type, content.to_string(), embedding);
443 m.created_at = 1000;
444 m
445 }
446
447 #[test]
448 fn test_heuristic_does_not_flag_contradiction_from_similarity() {
449 let agent = AgentId::new();
456 let mut existing =
457 make_memory("uses PostgreSQL", vec![1.0, 0.0, 0.0], MemoryType::Semantic);
458 existing.agent_id = agent;
459
460 let mut new_mem = make_memory("uses MySQL", vec![0.99, 0.01, 0.0], MemoryType::Semantic);
461 new_mem.agent_id = agent;
462 new_mem.created_at = 2000;
463
464 let engine = WriteInferenceEngine::new();
465 let actions = engine.infer_on_write(&new_mem, &[existing], &[]);
466 assert!(
467 !actions
468 .iter()
469 .any(|a| matches!(a, InferredAction::FlagContradiction { .. })),
470 "Heuristic must not flag contradictions from bare similarity, got: {:?}",
471 actions
472 );
473 }
474
475 #[test]
476 fn value_update_supersedes_the_old_fact() {
477 let agent = AgentId::new();
481 let mut existing = make_memory(
482 "The user's favorite coffee order is a cortado",
483 vec![1.0, 0.0, 0.0],
484 MemoryType::Semantic,
485 );
486 existing.agent_id = agent;
487 let mut new_mem = make_memory(
488 "The user's favorite coffee order is a flat white",
489 vec![0.98, 0.02, 0.0],
490 MemoryType::Semantic,
491 );
492 new_mem.agent_id = agent;
493 new_mem.created_at = 2000;
494
495 let engine = WriteInferenceEngine::new();
496 let actions = engine.infer_on_write(&new_mem, &[existing.clone()], &[]);
497 assert!(
498 actions.iter().any(|a| matches!(
499 a,
500 InferredAction::CreateEdge { source, target, edge_type: EdgeType::Supersedes, .. }
501 if *source == new_mem.id && *target == existing.id
502 )),
503 "value update must create a Supersedes edge, got: {actions:?}"
504 );
505 assert!(
506 actions
507 .iter()
508 .any(|a| matches!(a, InferredAction::UpdateConfidence { memory, .. } if *memory == existing.id)),
509 "superseded fact's confidence must decay"
510 );
511 }
512
513 #[test]
514 fn value_update_fires_at_realistic_embedder_cosine() {
515 let agent = AgentId::new();
523 let mut existing = make_memory(
524 "The user's favorite coffee order is a cortado",
525 vec![1.0, 0.0, 0.0],
526 MemoryType::Semantic,
527 );
528 existing.agent_id = agent;
529 let mut new_mem = make_memory(
531 "The user's favorite coffee order is a flat white",
532 vec![0.6, 0.8, 0.0],
533 MemoryType::Semantic,
534 );
535 new_mem.agent_id = agent;
536 new_mem.created_at = 2000;
537
538 let actions =
539 WriteInferenceEngine::new().infer_on_write(&new_mem, &[existing.clone()], &[]);
540 assert!(
541 actions.iter().any(|a| matches!(
542 a,
543 InferredAction::CreateEdge { source, target, edge_type: EdgeType::Supersedes, .. }
544 if *source == new_mem.id && *target == existing.id
545 )),
546 "a frame-matched correction at realistic cosine 0.6 must supersede, got: {actions:?}"
547 );
548 }
549
550 #[test]
551 fn value_update_below_cosine_floor_does_not_supersede() {
552 let agent = AgentId::new();
557 let mut existing = make_memory(
558 "The user's favorite coffee order is a cortado",
559 vec![1.0, 0.0, 0.0],
560 MemoryType::Semantic,
561 );
562 existing.agent_id = agent;
563 let mut new_mem = make_memory(
565 "The user's favorite coffee order is a flat white",
566 vec![0.4, 0.917, 0.0],
567 MemoryType::Semantic,
568 );
569 new_mem.agent_id = agent;
570 new_mem.created_at = 2000;
571
572 let actions = WriteInferenceEngine::new().infer_on_write(&new_mem, &[existing], &[]);
573 assert!(
574 !actions.iter().any(|a| matches!(
575 a,
576 InferredAction::CreateEdge {
577 edge_type: EdgeType::Supersedes,
578 ..
579 }
580 )),
581 "below the cosine floor a frame match must not supersede, got: {actions:?}"
582 );
583 }
584
585 #[test]
586 fn subject_change_is_not_a_value_update() {
587 let agent = AgentId::new();
590 let mut existing = make_memory(
591 "My dog is allergic to chicken",
592 vec![1.0, 0.0, 0.0],
593 MemoryType::Semantic,
594 );
595 existing.agent_id = agent;
596 let mut new_mem = make_memory(
597 "My cat is allergic to chicken",
598 vec![0.99, 0.01, 0.0],
599 MemoryType::Semantic,
600 );
601 new_mem.agent_id = agent;
602 new_mem.created_at = 2000;
603
604 let engine = WriteInferenceEngine::new();
605 let actions = engine.infer_on_write(&new_mem, &[existing], &[]);
606 assert!(
607 !actions.iter().any(|a| matches!(
608 a,
609 InferredAction::CreateEdge {
610 edge_type: EdgeType::Supersedes,
611 ..
612 }
613 )),
614 "a subject change must not supersede, got: {actions:?}"
615 );
616 }
617
618 #[test]
619 fn value_update_respects_owner_and_config() {
620 let mut existing = make_memory(
622 "The user's favorite coffee order is a cortado",
623 vec![1.0, 0.0, 0.0],
624 MemoryType::Semantic,
625 );
626 existing.agent_id = AgentId::new();
627 let mut new_mem = make_memory(
628 "The user's favorite coffee order is a flat white",
629 vec![0.98, 0.02, 0.0],
630 MemoryType::Semantic,
631 );
632 new_mem.agent_id = AgentId::new(); new_mem.created_at = 2000;
634
635 let engine = WriteInferenceEngine::new();
636 let actions = engine.infer_on_write(&new_mem, &[existing.clone()], &[]);
637 assert!(
638 !actions.iter().any(|a| matches!(
639 a,
640 InferredAction::CreateEdge {
641 edge_type: EdgeType::Supersedes,
642 ..
643 }
644 )),
645 "cross-owner memories must never supersede each other"
646 );
647
648 let agent = AgentId::new();
650 existing.agent_id = agent;
651 new_mem.agent_id = agent;
652 let engine_off = WriteInferenceEngine::with_config(WriteInferenceConfig {
653 value_update_enabled: false,
654 ..WriteInferenceConfig::default()
655 });
656 let actions = engine_off.infer_on_write(&new_mem, &[existing], &[]);
657 assert!(
658 !actions.iter().any(|a| matches!(
659 a,
660 InferredAction::CreateEdge {
661 edge_type: EdgeType::Supersedes,
662 ..
663 }
664 )),
665 "disabled rule must not supersede"
666 );
667 }
668
669 #[test]
670 fn is_value_update_frame_analysis() {
671 assert!(is_value_update(
673 "my phone number is 4321",
674 "my phone number is 1234",
675 0.6,
676 4
677 ));
678 assert!(!is_value_update(
680 "my cat is allergic to chicken",
681 "my dog is allergic to chicken",
682 0.6,
683 4
684 ));
685 assert!(!is_value_update("same fact", "same fact", 0.6, 4));
687 assert!(!is_value_update(
689 "the deploy runs every friday and sarah reviews it after standup",
690 "the deploy runs every friday unless the release train is frozen for the quarter end audit",
691 0.6,
692 4
693 ));
694 }
695
696 #[test]
697 fn test_byte_identical_duplicate_is_deduplicated() {
698 let agent = AgentId::new();
701 let mut existing = make_memory(
702 "Ran command: ls -la",
703 vec![1.0, 0.0, 0.0],
704 MemoryType::Episodic,
705 );
706 existing.agent_id = agent;
707
708 let mut new_mem = make_memory(
709 "Ran command: ls -la",
710 vec![1.0, 0.0, 0.0],
711 MemoryType::Episodic,
712 );
713 new_mem.agent_id = agent;
714 new_mem.created_at = 2000;
715
716 let engine = WriteInferenceEngine::new();
717 let actions = engine.infer_on_write(&new_mem, &[existing], &[]);
718 assert!(
719 actions
720 .iter()
721 .any(|a| matches!(a, InferredAction::DeduplicateExact { .. })),
722 "Expected identical duplicate to be deduplicated, got: {:?}",
723 actions
724 );
725 }
726
727 #[test]
728 fn test_moderate_similarity_creates_edge() {
729 let existing = make_memory("topic A", vec![1.0, 0.0, 0.0], MemoryType::Semantic);
730 let new_mem = make_memory("topic B", vec![0.7, 0.714, 0.0], MemoryType::Semantic);
732
733 let engine = WriteInferenceEngine::new();
734 let actions = engine.infer_on_write(&new_mem, &[existing], &[]);
735 assert!(
736 actions.iter().any(|a| matches!(
737 a,
738 InferredAction::CreateEdge {
739 edge_type: EdgeType::Related,
740 ..
741 }
742 )),
743 "Expected CreateEdge Related, got: {:?}",
744 actions
745 );
746 }
747
748 #[tokio::test]
749 async fn test_llm_invalidation_emits_temporal_actions() {
750 let agent = AgentId::new();
751 let mut existing = make_memory(
752 "Alice works at Acme",
753 vec![0.8, 0.6, 0.0],
754 MemoryType::Semantic,
755 );
756 existing.agent_id = agent;
757
758 let mut new_mem = make_memory(
759 "Alice joined Google last week",
760 vec![0.75, 0.65, 0.1],
761 MemoryType::Semantic,
762 );
763 new_mem.agent_id = agent;
764 new_mem.created_at = 2000;
765
766 let judge =
767 MockLlmJudge::new(r#"{"verdict": "invalidate", "reason": "Alice changed jobs"}"#);
768 let llm = CognitiveLlmService::new(judge);
769 let engine = WriteInferenceEngine::new();
770 let actions = engine
771 .infer_on_write_with_llm(&new_mem, &[existing.clone()], &[], &llm)
772 .await;
773
774 assert!(
775 actions
776 .iter()
777 .any(|a| matches!(a, InferredAction::InvalidateMemory { .. })),
778 "Expected InvalidateMemory from LLM verdict, got: {:?}",
779 actions
780 );
781 let invalidation_count = actions
785 .iter()
786 .filter(|a| {
787 matches!(
788 a,
789 InferredAction::InvalidateMemory { .. } | InferredAction::MarkObsolete { .. }
790 )
791 })
792 .count();
793 assert_eq!(
794 invalidation_count, 1,
795 "Expected a single invalidation action, got: {:?}",
796 actions
797 );
798 }
799
800 #[tokio::test]
801 async fn test_llm_update_emits_update_content() {
802 let agent = AgentId::new();
803 let mut existing = make_memory(
804 "Project uses React",
805 vec![0.8, 0.6, 0.0],
806 MemoryType::Semantic,
807 );
808 existing.agent_id = agent;
809
810 let mut new_mem = make_memory(
811 "Project migrated from React to Vue",
812 vec![0.75, 0.65, 0.1],
813 MemoryType::Semantic,
814 );
815 new_mem.agent_id = agent;
816 new_mem.created_at = 2000;
817
818 let judge = MockLlmJudge::new(
819 r#"{"verdict": "update", "merged_content": "Project migrated from React to Vue in Q2", "reason": "adds temporal context"}"#,
820 );
821 let llm = CognitiveLlmService::new(judge);
822 let engine = WriteInferenceEngine::new();
823 let actions = engine
824 .infer_on_write_with_llm(&new_mem, &[existing.clone()], &[], &llm)
825 .await;
826
827 assert!(
828 actions
829 .iter()
830 .any(|a| matches!(a, InferredAction::UpdateContent { .. })),
831 "Expected UpdateContent from LLM update verdict, got: {:?}",
832 actions
833 );
834 }
835
836 #[tokio::test]
837 async fn test_llm_keep_falls_through_to_contradiction_check() {
838 let agent = AgentId::new();
839 let mut existing = make_memory("Prefers tabs", vec![0.9, 0.44, 0.0], MemoryType::Semantic);
841 existing.agent_id = agent;
842
843 let mut new_mem = make_memory(
844 "Prefers spaces",
845 vec![0.88, 0.47, 0.0],
846 MemoryType::Semantic,
847 );
848 new_mem.agent_id = agent;
849 new_mem.created_at = 2000;
850
851 let judge = MockLlmJudge::new(
852 r#"{"verdict": "compatible", "reason": "different formatting preferences"}"#,
853 );
854 let llm = CognitiveLlmService::new(judge);
855 let engine = WriteInferenceEngine::new();
856 let actions = engine
857 .infer_on_write_with_llm(&new_mem, &[existing.clone()], &[], &llm)
858 .await;
859
860 assert!(
863 !actions
864 .iter()
865 .any(|a| matches!(a, InferredAction::FlagContradiction { .. })),
866 "Should not flag contradiction when LLM says compatible",
867 );
868 }
869}