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