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}
91
92impl Default for WriteInferenceConfig {
93 fn default() -> Self {
94 Self {
95 contradiction_threshold: 0.95,
96 obsolete_threshold: 0.85,
97 related_min: 0.6,
98 related_max: 0.85,
99 correction_threshold: 0.5,
100 confidence_decay_factor: 0.5,
101 confidence_floor: 0.1,
102 }
103 }
104}
105
106pub struct WriteInferenceEngine {
107 config: WriteInferenceConfig,
108}
109
110impl WriteInferenceEngine {
111 pub fn new() -> Self {
112 Self {
113 config: WriteInferenceConfig::default(),
114 }
115 }
116
117 pub fn with_config(config: WriteInferenceConfig) -> Self {
118 Self { config }
119 }
120
121 pub fn infer_on_write(
122 &self,
123 new_memory: &MemoryNode,
124 existing_memories: &[MemoryNode],
125 existing_edges: &[(MemoryId, MemoryId, EdgeType)],
126 ) -> Vec<InferredAction> {
127 let _ = existing_edges; let mut actions = Vec::new();
129
130 for existing in existing_memories {
131 if existing.id == new_memory.id {
132 continue;
133 }
134
135 let sim = cosine_similarity(&new_memory.embedding, &existing.embedding);
136
137 if existing.content == new_memory.content
151 && sim > self.config.obsolete_threshold
152 && new_memory.created_at > existing.created_at
153 {
154 actions.push(InferredAction::DeduplicateExact {
155 duplicate: existing.id,
156 keeper: new_memory.id,
157 });
158 } else if sim > self.config.related_min && sim <= self.config.related_max {
159 actions.push(InferredAction::CreateEdge {
160 source: new_memory.id,
161 target: existing.id,
162 edge_type: EdgeType::Related,
163 weight: sim,
164 });
165 }
166 }
167
168 if new_memory.memory_type == MemoryType::Correction
170 && let Some(original) = existing_memories
171 .iter()
172 .filter(|m| m.id != new_memory.id)
173 .max_by(|a, b| {
174 cosine_similarity(&new_memory.embedding, &a.embedding)
175 .partial_cmp(&cosine_similarity(&new_memory.embedding, &b.embedding))
176 .unwrap_or(std::cmp::Ordering::Equal)
177 })
178 {
179 let sim = cosine_similarity(&new_memory.embedding, &original.embedding);
180 if sim > self.config.correction_threshold {
181 actions.push(InferredAction::CreateEdge {
182 source: new_memory.id,
183 target: original.id,
184 edge_type: EdgeType::Supersedes,
185 weight: 1.0,
186 });
187 actions.push(InferredAction::UpdateConfidence {
188 memory: original.id,
189 new_confidence: (original.confidence * self.config.confidence_decay_factor)
190 .max(self.config.confidence_floor),
191 });
192 }
193 }
194
195 actions
196 }
197
198 pub async fn infer_on_write_with_llm<J: LlmJudge>(
203 &self,
204 new_memory: &MemoryNode,
205 existing_memories: &[MemoryNode],
206 existing_edges: &[(MemoryId, MemoryId, EdgeType)],
207 llm: &CognitiveLlmService<J>,
208 ) -> Vec<InferredAction> {
209 let _ = existing_edges;
210 let mut actions = Vec::new();
211
212 let new_summary = memory_to_summary(new_memory);
213
214 for existing in existing_memories {
215 if existing.id == new_memory.id {
216 continue;
217 }
218
219 let sim = cosine_similarity(&new_memory.embedding, &existing.embedding);
220
221 if sim > 0.5 && existing.agent_id == new_memory.agent_id {
224 let old_summary = memory_to_summary(existing);
225
226 if let Ok(verdict) = llm.judge_invalidation(&old_summary, &new_summary).await {
228 match verdict {
229 InvalidationVerdict::Invalidate { reason: _ } => {
230 actions.push(InferredAction::InvalidateMemory {
233 memory: existing.id,
234 superseded_by: new_memory.id,
235 valid_until: new_memory.created_at,
236 });
237 actions.push(InferredAction::UpdateConfidence {
238 memory: existing.id,
239 new_confidence: (existing.confidence
240 * self.config.confidence_decay_factor)
241 .max(self.config.confidence_floor),
242 });
243 continue;
245 }
246 InvalidationVerdict::Update {
247 merged_content,
248 reason,
249 } => {
250 actions.push(InferredAction::UpdateContent {
251 memory: existing.id,
252 new_content: merged_content,
253 reason,
254 });
255 continue;
256 }
257 InvalidationVerdict::Keep { .. } => {
258 }
260 }
261 }
262
263 if sim > 0.7
265 && existing.content != new_memory.content
266 && let Ok(verdict) = llm.detect_contradiction(&old_summary, &new_summary).await
267 {
268 match verdict {
269 ContradictionVerdict::Contradicts { reason } => {
270 actions.push(InferredAction::FlagContradiction {
271 existing: existing.id,
272 new: new_memory.id,
273 reason,
274 });
275 }
276 ContradictionVerdict::Supersedes { winner, reason: _ } => {
277 let winner_is_new = winner == new_memory.id.to_string();
278 let (obsolete, superseder) = if winner_is_new {
279 (existing.id, new_memory.id)
280 } else {
281 (new_memory.id, existing.id)
282 };
283 actions.push(InferredAction::InvalidateMemory {
284 memory: obsolete,
285 superseded_by: superseder,
286 valid_until: new_memory.created_at,
287 });
288 }
289 ContradictionVerdict::Compatible { .. } => {}
290 }
291 }
292 }
293
294 if sim > self.config.related_min && sim <= self.config.related_max {
296 actions.push(InferredAction::CreateEdge {
297 source: new_memory.id,
298 target: existing.id,
299 edge_type: EdgeType::Related,
300 weight: sim,
301 });
302 }
303 }
304
305 if new_memory.memory_type == MemoryType::Correction
307 && let Some(original) = existing_memories
308 .iter()
309 .filter(|m| m.id != new_memory.id)
310 .max_by(|a, b| {
311 cosine_similarity(&new_memory.embedding, &a.embedding)
312 .partial_cmp(&cosine_similarity(&new_memory.embedding, &b.embedding))
313 .unwrap_or(std::cmp::Ordering::Equal)
314 })
315 {
316 let sim = cosine_similarity(&new_memory.embedding, &original.embedding);
317 if sim > self.config.correction_threshold {
318 actions.push(InferredAction::InvalidateMemory {
319 memory: original.id,
320 superseded_by: new_memory.id,
321 valid_until: new_memory.created_at,
322 });
323 actions.push(InferredAction::UpdateConfidence {
324 memory: original.id,
325 new_confidence: (original.confidence * self.config.confidence_decay_factor)
326 .max(self.config.confidence_floor),
327 });
328 }
329 }
330
331 actions
332 }
333}
334
335impl Default for WriteInferenceEngine {
336 fn default() -> Self {
337 Self::new()
338 }
339}
340
341fn memory_to_summary(m: &MemoryNode) -> MemorySummary {
342 MemorySummary {
343 id: m.id,
344 content: m.content.clone(),
345 memory_type: m.memory_type,
346 confidence: m.confidence,
347 created_at: m.created_at,
348 }
349}
350
351#[cfg(test)]
352mod tests {
353 use super::*;
354 use mentedb_core::memory::MemoryType;
355 use mentedb_core::types::AgentId;
356
357 use crate::llm::MockLlmJudge;
358
359 fn make_memory(content: &str, embedding: Vec<f32>, mem_type: MemoryType) -> MemoryNode {
360 let mut m = MemoryNode::new(AgentId::new(), mem_type, content.to_string(), embedding);
361 m.created_at = 1000;
362 m
363 }
364
365 #[test]
366 fn test_heuristic_does_not_flag_contradiction_from_similarity() {
367 let agent = AgentId::new();
374 let mut existing =
375 make_memory("uses PostgreSQL", vec![1.0, 0.0, 0.0], MemoryType::Semantic);
376 existing.agent_id = agent;
377
378 let mut new_mem = make_memory("uses MySQL", vec![0.99, 0.01, 0.0], MemoryType::Semantic);
379 new_mem.agent_id = agent;
380 new_mem.created_at = 2000;
381
382 let engine = WriteInferenceEngine::new();
383 let actions = engine.infer_on_write(&new_mem, &[existing], &[]);
384 assert!(
385 !actions
386 .iter()
387 .any(|a| matches!(a, InferredAction::FlagContradiction { .. })),
388 "Heuristic must not flag contradictions from bare similarity, got: {:?}",
389 actions
390 );
391 }
392
393 #[test]
394 fn test_byte_identical_duplicate_is_deduplicated() {
395 let agent = AgentId::new();
398 let mut existing = make_memory(
399 "Ran command: ls -la",
400 vec![1.0, 0.0, 0.0],
401 MemoryType::Episodic,
402 );
403 existing.agent_id = agent;
404
405 let mut new_mem = make_memory(
406 "Ran command: ls -la",
407 vec![1.0, 0.0, 0.0],
408 MemoryType::Episodic,
409 );
410 new_mem.agent_id = agent;
411 new_mem.created_at = 2000;
412
413 let engine = WriteInferenceEngine::new();
414 let actions = engine.infer_on_write(&new_mem, &[existing], &[]);
415 assert!(
416 actions
417 .iter()
418 .any(|a| matches!(a, InferredAction::DeduplicateExact { .. })),
419 "Expected identical duplicate to be deduplicated, got: {:?}",
420 actions
421 );
422 }
423
424 #[test]
425 fn test_moderate_similarity_creates_edge() {
426 let existing = make_memory("topic A", vec![1.0, 0.0, 0.0], MemoryType::Semantic);
427 let new_mem = make_memory("topic B", vec![0.7, 0.714, 0.0], MemoryType::Semantic);
429
430 let engine = WriteInferenceEngine::new();
431 let actions = engine.infer_on_write(&new_mem, &[existing], &[]);
432 assert!(
433 actions.iter().any(|a| matches!(
434 a,
435 InferredAction::CreateEdge {
436 edge_type: EdgeType::Related,
437 ..
438 }
439 )),
440 "Expected CreateEdge Related, got: {:?}",
441 actions
442 );
443 }
444
445 #[tokio::test]
446 async fn test_llm_invalidation_emits_temporal_actions() {
447 let agent = AgentId::new();
448 let mut existing = make_memory(
449 "Alice works at Acme",
450 vec![0.8, 0.6, 0.0],
451 MemoryType::Semantic,
452 );
453 existing.agent_id = agent;
454
455 let mut new_mem = make_memory(
456 "Alice joined Google last week",
457 vec![0.75, 0.65, 0.1],
458 MemoryType::Semantic,
459 );
460 new_mem.agent_id = agent;
461 new_mem.created_at = 2000;
462
463 let judge =
464 MockLlmJudge::new(r#"{"verdict": "invalidate", "reason": "Alice changed jobs"}"#);
465 let llm = CognitiveLlmService::new(judge);
466 let engine = WriteInferenceEngine::new();
467 let actions = engine
468 .infer_on_write_with_llm(&new_mem, &[existing.clone()], &[], &llm)
469 .await;
470
471 assert!(
472 actions
473 .iter()
474 .any(|a| matches!(a, InferredAction::InvalidateMemory { .. })),
475 "Expected InvalidateMemory from LLM verdict, got: {:?}",
476 actions
477 );
478 let invalidation_count = actions
482 .iter()
483 .filter(|a| {
484 matches!(
485 a,
486 InferredAction::InvalidateMemory { .. } | InferredAction::MarkObsolete { .. }
487 )
488 })
489 .count();
490 assert_eq!(
491 invalidation_count, 1,
492 "Expected a single invalidation action, got: {:?}",
493 actions
494 );
495 }
496
497 #[tokio::test]
498 async fn test_llm_update_emits_update_content() {
499 let agent = AgentId::new();
500 let mut existing = make_memory(
501 "Project uses React",
502 vec![0.8, 0.6, 0.0],
503 MemoryType::Semantic,
504 );
505 existing.agent_id = agent;
506
507 let mut new_mem = make_memory(
508 "Project migrated from React to Vue",
509 vec![0.75, 0.65, 0.1],
510 MemoryType::Semantic,
511 );
512 new_mem.agent_id = agent;
513 new_mem.created_at = 2000;
514
515 let judge = MockLlmJudge::new(
516 r#"{"verdict": "update", "merged_content": "Project migrated from React to Vue in Q2", "reason": "adds temporal context"}"#,
517 );
518 let llm = CognitiveLlmService::new(judge);
519 let engine = WriteInferenceEngine::new();
520 let actions = engine
521 .infer_on_write_with_llm(&new_mem, &[existing.clone()], &[], &llm)
522 .await;
523
524 assert!(
525 actions
526 .iter()
527 .any(|a| matches!(a, InferredAction::UpdateContent { .. })),
528 "Expected UpdateContent from LLM update verdict, got: {:?}",
529 actions
530 );
531 }
532
533 #[tokio::test]
534 async fn test_llm_keep_falls_through_to_contradiction_check() {
535 let agent = AgentId::new();
536 let mut existing = make_memory("Prefers tabs", vec![0.9, 0.44, 0.0], MemoryType::Semantic);
538 existing.agent_id = agent;
539
540 let mut new_mem = make_memory(
541 "Prefers spaces",
542 vec![0.88, 0.47, 0.0],
543 MemoryType::Semantic,
544 );
545 new_mem.agent_id = agent;
546 new_mem.created_at = 2000;
547
548 let judge = MockLlmJudge::new(
549 r#"{"verdict": "compatible", "reason": "different formatting preferences"}"#,
550 );
551 let llm = CognitiveLlmService::new(judge);
552 let engine = WriteInferenceEngine::new();
553 let actions = engine
554 .infer_on_write_with_llm(&new_mem, &[existing.clone()], &[], &llm)
555 .await;
556
557 assert!(
560 !actions
561 .iter()
562 .any(|a| matches!(a, InferredAction::FlagContradiction { .. })),
563 "Should not flag contradiction when LLM says compatible",
564 );
565 }
566}