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 UpdateContent {
29 memory: MemoryId,
30 new_content: String,
31 reason: String,
32 },
33 CreateEdge {
34 source: MemoryId,
35 target: MemoryId,
36 edge_type: EdgeType,
37 weight: f32,
38 },
39 UpdateConfidence {
40 memory: MemoryId,
41 new_confidence: f32,
42 },
43 PropagateBeliefChange {
44 root: MemoryId,
45 delta: f32,
46 },
47}
48
49fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
50 if a.len() != b.len() || a.is_empty() {
51 return 0.0;
52 }
53 let mut dot = 0.0f32;
54 let mut norm_a = 0.0f32;
55 let mut norm_b = 0.0f32;
56 for i in 0..a.len() {
57 dot += a[i] * b[i];
58 norm_a += a[i] * a[i];
59 norm_b += b[i] * b[i];
60 }
61 let denom = norm_a.sqrt() * norm_b.sqrt();
62 if denom == 0.0 { 0.0 } else { dot / denom }
63}
64
65#[derive(Debug, Clone)]
67pub struct WriteInferenceConfig {
68 pub contradiction_threshold: f32,
70 pub obsolete_threshold: f32,
72 pub related_min: f32,
74 pub related_max: f32,
76 pub correction_threshold: f32,
78 pub confidence_decay_factor: f32,
80 pub confidence_floor: f32,
82}
83
84impl Default for WriteInferenceConfig {
85 fn default() -> Self {
86 Self {
87 contradiction_threshold: 0.95,
88 obsolete_threshold: 0.85,
89 related_min: 0.6,
90 related_max: 0.85,
91 correction_threshold: 0.5,
92 confidence_decay_factor: 0.5,
93 confidence_floor: 0.1,
94 }
95 }
96}
97
98pub struct WriteInferenceEngine {
99 config: WriteInferenceConfig,
100}
101
102impl WriteInferenceEngine {
103 pub fn new() -> Self {
104 Self {
105 config: WriteInferenceConfig::default(),
106 }
107 }
108
109 pub fn with_config(config: WriteInferenceConfig) -> Self {
110 Self { config }
111 }
112
113 pub fn infer_on_write(
114 &self,
115 new_memory: &MemoryNode,
116 existing_memories: &[MemoryNode],
117 existing_edges: &[(MemoryId, MemoryId, EdgeType)],
118 ) -> Vec<InferredAction> {
119 let _ = existing_edges; let mut actions = Vec::new();
121
122 for existing in existing_memories {
123 if existing.id == new_memory.id {
124 continue;
125 }
126
127 let sim = cosine_similarity(&new_memory.embedding, &existing.embedding);
128
129 if existing.content == new_memory.content
143 && sim > self.config.obsolete_threshold
144 && new_memory.created_at > existing.created_at
145 {
146 actions.push(InferredAction::InvalidateMemory {
147 memory: existing.id,
148 superseded_by: new_memory.id,
149 valid_until: new_memory.created_at,
150 });
151 } else if sim > self.config.related_min && sim <= self.config.related_max {
152 actions.push(InferredAction::CreateEdge {
153 source: new_memory.id,
154 target: existing.id,
155 edge_type: EdgeType::Related,
156 weight: sim,
157 });
158 }
159 }
160
161 if new_memory.memory_type == MemoryType::Correction
163 && let Some(original) = existing_memories
164 .iter()
165 .filter(|m| m.id != new_memory.id)
166 .max_by(|a, b| {
167 cosine_similarity(&new_memory.embedding, &a.embedding)
168 .partial_cmp(&cosine_similarity(&new_memory.embedding, &b.embedding))
169 .unwrap_or(std::cmp::Ordering::Equal)
170 })
171 {
172 let sim = cosine_similarity(&new_memory.embedding, &original.embedding);
173 if sim > self.config.correction_threshold {
174 actions.push(InferredAction::CreateEdge {
175 source: new_memory.id,
176 target: original.id,
177 edge_type: EdgeType::Supersedes,
178 weight: 1.0,
179 });
180 actions.push(InferredAction::UpdateConfidence {
181 memory: original.id,
182 new_confidence: (original.confidence * self.config.confidence_decay_factor)
183 .max(self.config.confidence_floor),
184 });
185 }
186 }
187
188 actions
189 }
190
191 pub async fn infer_on_write_with_llm<J: LlmJudge>(
196 &self,
197 new_memory: &MemoryNode,
198 existing_memories: &[MemoryNode],
199 existing_edges: &[(MemoryId, MemoryId, EdgeType)],
200 llm: &CognitiveLlmService<J>,
201 ) -> Vec<InferredAction> {
202 let _ = existing_edges;
203 let mut actions = Vec::new();
204
205 let new_summary = memory_to_summary(new_memory);
206
207 for existing in existing_memories {
208 if existing.id == new_memory.id {
209 continue;
210 }
211
212 let sim = cosine_similarity(&new_memory.embedding, &existing.embedding);
213
214 if sim > 0.5 && existing.agent_id == new_memory.agent_id {
217 let old_summary = memory_to_summary(existing);
218
219 if let Ok(verdict) = llm.judge_invalidation(&old_summary, &new_summary).await {
221 match verdict {
222 InvalidationVerdict::Invalidate { reason: _ } => {
223 actions.push(InferredAction::InvalidateMemory {
226 memory: existing.id,
227 superseded_by: new_memory.id,
228 valid_until: new_memory.created_at,
229 });
230 actions.push(InferredAction::UpdateConfidence {
231 memory: existing.id,
232 new_confidence: (existing.confidence
233 * self.config.confidence_decay_factor)
234 .max(self.config.confidence_floor),
235 });
236 continue;
238 }
239 InvalidationVerdict::Update {
240 merged_content,
241 reason,
242 } => {
243 actions.push(InferredAction::UpdateContent {
244 memory: existing.id,
245 new_content: merged_content,
246 reason,
247 });
248 continue;
249 }
250 InvalidationVerdict::Keep { .. } => {
251 }
253 }
254 }
255
256 if sim > 0.7
258 && existing.content != new_memory.content
259 && let Ok(verdict) = llm.detect_contradiction(&old_summary, &new_summary).await
260 {
261 match verdict {
262 ContradictionVerdict::Contradicts { reason } => {
263 actions.push(InferredAction::FlagContradiction {
264 existing: existing.id,
265 new: new_memory.id,
266 reason,
267 });
268 }
269 ContradictionVerdict::Supersedes { winner, reason: _ } => {
270 let winner_is_new = winner == new_memory.id.to_string();
271 let (obsolete, superseder) = if winner_is_new {
272 (existing.id, new_memory.id)
273 } else {
274 (new_memory.id, existing.id)
275 };
276 actions.push(InferredAction::InvalidateMemory {
277 memory: obsolete,
278 superseded_by: superseder,
279 valid_until: new_memory.created_at,
280 });
281 }
282 ContradictionVerdict::Compatible { .. } => {}
283 }
284 }
285 }
286
287 if sim > self.config.related_min && sim <= self.config.related_max {
289 actions.push(InferredAction::CreateEdge {
290 source: new_memory.id,
291 target: existing.id,
292 edge_type: EdgeType::Related,
293 weight: sim,
294 });
295 }
296 }
297
298 if new_memory.memory_type == MemoryType::Correction
300 && let Some(original) = existing_memories
301 .iter()
302 .filter(|m| m.id != new_memory.id)
303 .max_by(|a, b| {
304 cosine_similarity(&new_memory.embedding, &a.embedding)
305 .partial_cmp(&cosine_similarity(&new_memory.embedding, &b.embedding))
306 .unwrap_or(std::cmp::Ordering::Equal)
307 })
308 {
309 let sim = cosine_similarity(&new_memory.embedding, &original.embedding);
310 if sim > self.config.correction_threshold {
311 actions.push(InferredAction::InvalidateMemory {
312 memory: original.id,
313 superseded_by: new_memory.id,
314 valid_until: new_memory.created_at,
315 });
316 actions.push(InferredAction::UpdateConfidence {
317 memory: original.id,
318 new_confidence: (original.confidence * self.config.confidence_decay_factor)
319 .max(self.config.confidence_floor),
320 });
321 }
322 }
323
324 actions
325 }
326}
327
328impl Default for WriteInferenceEngine {
329 fn default() -> Self {
330 Self::new()
331 }
332}
333
334fn memory_to_summary(m: &MemoryNode) -> MemorySummary {
335 MemorySummary {
336 id: m.id,
337 content: m.content.clone(),
338 memory_type: m.memory_type,
339 confidence: m.confidence,
340 created_at: m.created_at,
341 }
342}
343
344#[cfg(test)]
345mod tests {
346 use super::*;
347 use mentedb_core::memory::MemoryType;
348 use mentedb_core::types::AgentId;
349
350 use crate::llm::MockLlmJudge;
351
352 fn make_memory(content: &str, embedding: Vec<f32>, mem_type: MemoryType) -> MemoryNode {
353 let mut m = MemoryNode::new(AgentId::new(), mem_type, content.to_string(), embedding);
354 m.created_at = 1000;
355 m
356 }
357
358 #[test]
359 fn test_heuristic_does_not_flag_contradiction_from_similarity() {
360 let agent = AgentId::new();
367 let mut existing =
368 make_memory("uses PostgreSQL", vec![1.0, 0.0, 0.0], MemoryType::Semantic);
369 existing.agent_id = agent;
370
371 let mut new_mem = make_memory("uses MySQL", vec![0.99, 0.01, 0.0], MemoryType::Semantic);
372 new_mem.agent_id = agent;
373 new_mem.created_at = 2000;
374
375 let engine = WriteInferenceEngine::new();
376 let actions = engine.infer_on_write(&new_mem, &[existing], &[]);
377 assert!(
378 !actions
379 .iter()
380 .any(|a| matches!(a, InferredAction::FlagContradiction { .. })),
381 "Heuristic must not flag contradictions from bare similarity, got: {:?}",
382 actions
383 );
384 }
385
386 #[test]
387 fn test_byte_identical_duplicate_is_superseded() {
388 let agent = AgentId::new();
390 let mut existing = make_memory(
391 "Ran command: ls -la",
392 vec![1.0, 0.0, 0.0],
393 MemoryType::Episodic,
394 );
395 existing.agent_id = agent;
396
397 let mut new_mem = make_memory(
398 "Ran command: ls -la",
399 vec![1.0, 0.0, 0.0],
400 MemoryType::Episodic,
401 );
402 new_mem.agent_id = agent;
403 new_mem.created_at = 2000;
404
405 let engine = WriteInferenceEngine::new();
406 let actions = engine.infer_on_write(&new_mem, &[existing], &[]);
407 assert!(
408 actions
409 .iter()
410 .any(|a| matches!(a, InferredAction::InvalidateMemory { .. })),
411 "Expected identical duplicate to be superseded, got: {:?}",
412 actions
413 );
414 }
415
416 #[test]
417 fn test_moderate_similarity_creates_edge() {
418 let existing = make_memory("topic A", vec![1.0, 0.0, 0.0], MemoryType::Semantic);
419 let new_mem = make_memory("topic B", vec![0.7, 0.714, 0.0], MemoryType::Semantic);
421
422 let engine = WriteInferenceEngine::new();
423 let actions = engine.infer_on_write(&new_mem, &[existing], &[]);
424 assert!(
425 actions.iter().any(|a| matches!(
426 a,
427 InferredAction::CreateEdge {
428 edge_type: EdgeType::Related,
429 ..
430 }
431 )),
432 "Expected CreateEdge Related, got: {:?}",
433 actions
434 );
435 }
436
437 #[tokio::test]
438 async fn test_llm_invalidation_emits_temporal_actions() {
439 let agent = AgentId::new();
440 let mut existing = make_memory(
441 "Alice works at Acme",
442 vec![0.8, 0.6, 0.0],
443 MemoryType::Semantic,
444 );
445 existing.agent_id = agent;
446
447 let mut new_mem = make_memory(
448 "Alice joined Google last week",
449 vec![0.75, 0.65, 0.1],
450 MemoryType::Semantic,
451 );
452 new_mem.agent_id = agent;
453 new_mem.created_at = 2000;
454
455 let judge =
456 MockLlmJudge::new(r#"{"verdict": "invalidate", "reason": "Alice changed jobs"}"#);
457 let llm = CognitiveLlmService::new(judge);
458 let engine = WriteInferenceEngine::new();
459 let actions = engine
460 .infer_on_write_with_llm(&new_mem, &[existing.clone()], &[], &llm)
461 .await;
462
463 assert!(
464 actions
465 .iter()
466 .any(|a| matches!(a, InferredAction::InvalidateMemory { .. })),
467 "Expected InvalidateMemory from LLM verdict, got: {:?}",
468 actions
469 );
470 let invalidation_count = actions
474 .iter()
475 .filter(|a| {
476 matches!(
477 a,
478 InferredAction::InvalidateMemory { .. } | InferredAction::MarkObsolete { .. }
479 )
480 })
481 .count();
482 assert_eq!(
483 invalidation_count, 1,
484 "Expected a single invalidation action, got: {:?}",
485 actions
486 );
487 }
488
489 #[tokio::test]
490 async fn test_llm_update_emits_update_content() {
491 let agent = AgentId::new();
492 let mut existing = make_memory(
493 "Project uses React",
494 vec![0.8, 0.6, 0.0],
495 MemoryType::Semantic,
496 );
497 existing.agent_id = agent;
498
499 let mut new_mem = make_memory(
500 "Project migrated from React to Vue",
501 vec![0.75, 0.65, 0.1],
502 MemoryType::Semantic,
503 );
504 new_mem.agent_id = agent;
505 new_mem.created_at = 2000;
506
507 let judge = MockLlmJudge::new(
508 r#"{"verdict": "update", "merged_content": "Project migrated from React to Vue in Q2", "reason": "adds temporal context"}"#,
509 );
510 let llm = CognitiveLlmService::new(judge);
511 let engine = WriteInferenceEngine::new();
512 let actions = engine
513 .infer_on_write_with_llm(&new_mem, &[existing.clone()], &[], &llm)
514 .await;
515
516 assert!(
517 actions
518 .iter()
519 .any(|a| matches!(a, InferredAction::UpdateContent { .. })),
520 "Expected UpdateContent from LLM update verdict, got: {:?}",
521 actions
522 );
523 }
524
525 #[tokio::test]
526 async fn test_llm_keep_falls_through_to_contradiction_check() {
527 let agent = AgentId::new();
528 let mut existing = make_memory("Prefers tabs", vec![0.9, 0.44, 0.0], MemoryType::Semantic);
530 existing.agent_id = agent;
531
532 let mut new_mem = make_memory(
533 "Prefers spaces",
534 vec![0.88, 0.47, 0.0],
535 MemoryType::Semantic,
536 );
537 new_mem.agent_id = agent;
538 new_mem.created_at = 2000;
539
540 let judge = MockLlmJudge::new(
541 r#"{"verdict": "compatible", "reason": "different formatting preferences"}"#,
542 );
543 let llm = CognitiveLlmService::new(judge);
544 let engine = WriteInferenceEngine::new();
545 let actions = engine
546 .infer_on_write_with_llm(&new_mem, &[existing.clone()], &[], &llm)
547 .await;
548
549 assert!(
552 !actions
553 .iter()
554 .any(|a| matches!(a, InferredAction::FlagContradiction { .. })),
555 "Should not flag contradiction when LLM says compatible",
556 );
557 }
558}