Skip to main content

mentedb_cognitive/
write_inference.rs

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    /// Set valid_until on the old memory instead of deleting it.
22    InvalidateMemory {
23        memory: MemoryId,
24        superseded_by: MemoryId,
25        valid_until: u64,
26    },
27    /// Update the content of an existing memory with merged information.
28    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/// Configuration for write-time inference thresholds.
66#[derive(Debug, Clone)]
67pub struct WriteInferenceConfig {
68    /// Similarity above which two memories may contradict (default: 0.95).
69    pub contradiction_threshold: f32,
70    /// Similarity above which an older memory is marked obsolete (default: 0.85).
71    pub obsolete_threshold: f32,
72    /// Minimum similarity for creating a Related edge (default: 0.6).
73    pub related_min: f32,
74    /// Maximum similarity for creating a Related edge (default: 0.85).
75    pub related_max: f32,
76    /// Minimum similarity for a Correction to supersede (default: 0.5).
77    pub correction_threshold: f32,
78    /// Multiplier applied to original confidence on correction (default: 0.5).
79    pub confidence_decay_factor: f32,
80    /// Minimum confidence after decay (default: 0.1).
81    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; // reserved for future graph-aware inference
120        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            // The bands are mutually exclusive: each pair gets at most one
130            // action. A contradiction is flagged for review, NOT silently
131            // invalidated; an exact duplicate (same content) is superseded.
132            if sim > self.config.contradiction_threshold {
133                if existing.agent_id == new_memory.agent_id
134                    && existing.content != new_memory.content
135                {
136                    actions.push(InferredAction::FlagContradiction {
137                        existing: existing.id,
138                        new: new_memory.id,
139                        reason: format!(
140                            "High embedding similarity ({:.3}) with different content from same agent",
141                            sim
142                        ),
143                    });
144                } else if new_memory.created_at > existing.created_at {
145                    // Same content (or cross-agent duplicate): supersede the old copy.
146                    actions.push(InferredAction::InvalidateMemory {
147                        memory: existing.id,
148                        superseded_by: new_memory.id,
149                        valid_until: new_memory.created_at,
150                    });
151                }
152            } else if sim > self.config.obsolete_threshold
153                && new_memory.created_at > existing.created_at
154            {
155                // InvalidateMemory both sets valid_until and creates the
156                // Supersedes edge; emitting MarkObsolete as well would
157                // duplicate the edge and invalidate twice.
158                actions.push(InferredAction::InvalidateMemory {
159                    memory: existing.id,
160                    superseded_by: new_memory.id,
161                    valid_until: new_memory.created_at,
162                });
163            } else if sim > self.config.related_min && sim <= self.config.related_max {
164                actions.push(InferredAction::CreateEdge {
165                    source: new_memory.id,
166                    target: existing.id,
167                    edge_type: EdgeType::Related,
168                    weight: sim,
169                });
170            }
171        }
172
173        // Correction type: find the most similar existing memory and supersede it
174        if new_memory.memory_type == MemoryType::Correction
175            && let Some(original) = existing_memories
176                .iter()
177                .filter(|m| m.id != new_memory.id)
178                .max_by(|a, b| {
179                    cosine_similarity(&new_memory.embedding, &a.embedding)
180                        .partial_cmp(&cosine_similarity(&new_memory.embedding, &b.embedding))
181                        .unwrap_or(std::cmp::Ordering::Equal)
182                })
183        {
184            let sim = cosine_similarity(&new_memory.embedding, &original.embedding);
185            if sim > self.config.correction_threshold {
186                actions.push(InferredAction::CreateEdge {
187                    source: new_memory.id,
188                    target: original.id,
189                    edge_type: EdgeType::Supersedes,
190                    weight: 1.0,
191                });
192                actions.push(InferredAction::UpdateConfidence {
193                    memory: original.id,
194                    new_confidence: (original.confidence * self.config.confidence_decay_factor)
195                        .max(self.config.confidence_floor),
196                });
197            }
198        }
199
200        actions
201    }
202
203    /// LLM enhanced write inference. Uses the CognitiveLlmService when available
204    /// to make smarter invalidation and contradiction decisions. Falls back to
205    /// cosine similarity heuristics for moderate similarity (Related edges) and
206    /// correction handling.
207    pub async fn infer_on_write_with_llm<J: LlmJudge>(
208        &self,
209        new_memory: &MemoryNode,
210        existing_memories: &[MemoryNode],
211        existing_edges: &[(MemoryId, MemoryId, EdgeType)],
212        llm: &CognitiveLlmService<J>,
213    ) -> Vec<InferredAction> {
214        let _ = existing_edges;
215        let mut actions = Vec::new();
216
217        let new_summary = memory_to_summary(new_memory);
218
219        for existing in existing_memories {
220            if existing.id == new_memory.id {
221                continue;
222            }
223
224            let sim = cosine_similarity(&new_memory.embedding, &existing.embedding);
225
226            // Only consult LLM for memories with moderate+ similarity (> 0.5)
227            // to avoid burning tokens on completely unrelated pairs
228            if sim > 0.5 && existing.agent_id == new_memory.agent_id {
229                let old_summary = memory_to_summary(existing);
230
231                // LLM invalidation check
232                if let Ok(verdict) = llm.judge_invalidation(&old_summary, &new_summary).await {
233                    match verdict {
234                        InvalidationVerdict::Invalidate { reason: _ } => {
235                            // InvalidateMemory sets valid_until and creates the
236                            // Supersedes edge; no separate MarkObsolete/CreateEdge.
237                            actions.push(InferredAction::InvalidateMemory {
238                                memory: existing.id,
239                                superseded_by: new_memory.id,
240                                valid_until: new_memory.created_at,
241                            });
242                            actions.push(InferredAction::UpdateConfidence {
243                                memory: existing.id,
244                                new_confidence: (existing.confidence
245                                    * self.config.confidence_decay_factor)
246                                    .max(self.config.confidence_floor),
247                            });
248                            // Skip contradiction check since we already know the relationship
249                            continue;
250                        }
251                        InvalidationVerdict::Update {
252                            merged_content,
253                            reason,
254                        } => {
255                            actions.push(InferredAction::UpdateContent {
256                                memory: existing.id,
257                                new_content: merged_content,
258                                reason,
259                            });
260                            continue;
261                        }
262                        InvalidationVerdict::Keep { .. } => {
263                            // Fall through to contradiction check
264                        }
265                    }
266                }
267
268                // LLM contradiction check for high similarity pairs that weren't invalidated
269                if sim > 0.7
270                    && existing.content != new_memory.content
271                    && let Ok(verdict) = llm.detect_contradiction(&old_summary, &new_summary).await
272                {
273                    match verdict {
274                        ContradictionVerdict::Contradicts { reason } => {
275                            actions.push(InferredAction::FlagContradiction {
276                                existing: existing.id,
277                                new: new_memory.id,
278                                reason,
279                            });
280                        }
281                        ContradictionVerdict::Supersedes { winner, reason: _ } => {
282                            let winner_is_new = winner == new_memory.id.to_string();
283                            let (obsolete, superseder) = if winner_is_new {
284                                (existing.id, new_memory.id)
285                            } else {
286                                (new_memory.id, existing.id)
287                            };
288                            actions.push(InferredAction::InvalidateMemory {
289                                memory: obsolete,
290                                superseded_by: superseder,
291                                valid_until: new_memory.created_at,
292                            });
293                        }
294                        ContradictionVerdict::Compatible { .. } => {}
295                    }
296                }
297            }
298
299            // Moderate similarity: create Related edge (heuristic, no LLM needed)
300            if sim > self.config.related_min && sim <= self.config.related_max {
301                actions.push(InferredAction::CreateEdge {
302                    source: new_memory.id,
303                    target: existing.id,
304                    edge_type: EdgeType::Related,
305                    weight: sim,
306                });
307            }
308        }
309
310        // Correction type still uses heuristic (find most similar and supersede)
311        if new_memory.memory_type == MemoryType::Correction
312            && let Some(original) = existing_memories
313                .iter()
314                .filter(|m| m.id != new_memory.id)
315                .max_by(|a, b| {
316                    cosine_similarity(&new_memory.embedding, &a.embedding)
317                        .partial_cmp(&cosine_similarity(&new_memory.embedding, &b.embedding))
318                        .unwrap_or(std::cmp::Ordering::Equal)
319                })
320        {
321            let sim = cosine_similarity(&new_memory.embedding, &original.embedding);
322            if sim > self.config.correction_threshold {
323                actions.push(InferredAction::InvalidateMemory {
324                    memory: original.id,
325                    superseded_by: new_memory.id,
326                    valid_until: new_memory.created_at,
327                });
328                actions.push(InferredAction::UpdateConfidence {
329                    memory: original.id,
330                    new_confidence: (original.confidence * self.config.confidence_decay_factor)
331                        .max(self.config.confidence_floor),
332                });
333            }
334        }
335
336        actions
337    }
338}
339
340impl Default for WriteInferenceEngine {
341    fn default() -> Self {
342        Self::new()
343    }
344}
345
346fn memory_to_summary(m: &MemoryNode) -> MemorySummary {
347    MemorySummary {
348        id: m.id,
349        content: m.content.clone(),
350        memory_type: m.memory_type,
351        confidence: m.confidence,
352        created_at: m.created_at,
353    }
354}
355
356#[cfg(test)]
357mod tests {
358    use super::*;
359    use mentedb_core::memory::MemoryType;
360    use mentedb_core::types::AgentId;
361
362    use crate::llm::MockLlmJudge;
363
364    fn make_memory(content: &str, embedding: Vec<f32>, mem_type: MemoryType) -> MemoryNode {
365        let mut m = MemoryNode::new(AgentId::new(), mem_type, content.to_string(), embedding);
366        m.created_at = 1000;
367        m
368    }
369
370    #[test]
371    fn test_flag_contradiction() {
372        let agent = AgentId::new();
373        let mut existing =
374            make_memory("uses PostgreSQL", vec![1.0, 0.0, 0.0], MemoryType::Semantic);
375        existing.agent_id = agent;
376
377        let mut new_mem = make_memory("uses MySQL", vec![0.99, 0.01, 0.0], MemoryType::Semantic);
378        new_mem.agent_id = agent;
379        new_mem.created_at = 2000;
380
381        let engine = WriteInferenceEngine::new();
382        let actions = engine.infer_on_write(&new_mem, &[existing], &[]);
383        assert!(
384            actions
385                .iter()
386                .any(|a| matches!(a, InferredAction::FlagContradiction { .. })),
387            "Expected FlagContradiction, got: {:?}",
388            actions
389        );
390    }
391
392    #[test]
393    fn test_moderate_similarity_creates_edge() {
394        let existing = make_memory("topic A", vec![1.0, 0.0, 0.0], MemoryType::Semantic);
395        // ~0.7 similarity
396        let new_mem = make_memory("topic B", vec![0.7, 0.714, 0.0], MemoryType::Semantic);
397
398        let engine = WriteInferenceEngine::new();
399        let actions = engine.infer_on_write(&new_mem, &[existing], &[]);
400        assert!(
401            actions.iter().any(|a| matches!(
402                a,
403                InferredAction::CreateEdge {
404                    edge_type: EdgeType::Related,
405                    ..
406                }
407            )),
408            "Expected CreateEdge Related, got: {:?}",
409            actions
410        );
411    }
412
413    #[tokio::test]
414    async fn test_llm_invalidation_emits_temporal_actions() {
415        let agent = AgentId::new();
416        let mut existing = make_memory(
417            "Alice works at Acme",
418            vec![0.8, 0.6, 0.0],
419            MemoryType::Semantic,
420        );
421        existing.agent_id = agent;
422
423        let mut new_mem = make_memory(
424            "Alice joined Google last week",
425            vec![0.75, 0.65, 0.1],
426            MemoryType::Semantic,
427        );
428        new_mem.agent_id = agent;
429        new_mem.created_at = 2000;
430
431        let judge =
432            MockLlmJudge::new(r#"{"verdict": "invalidate", "reason": "Alice changed jobs"}"#);
433        let llm = CognitiveLlmService::new(judge);
434        let engine = WriteInferenceEngine::new();
435        let actions = engine
436            .infer_on_write_with_llm(&new_mem, &[existing.clone()], &[], &llm)
437            .await;
438
439        assert!(
440            actions
441                .iter()
442                .any(|a| matches!(a, InferredAction::InvalidateMemory { .. })),
443            "Expected InvalidateMemory from LLM verdict, got: {:?}",
444            actions
445        );
446        // Exactly one invalidation action: InvalidateMemory both sets
447        // valid_until and creates the Supersedes edge, so a MarkObsolete
448        // alongside it would duplicate the edge.
449        let invalidation_count = actions
450            .iter()
451            .filter(|a| {
452                matches!(
453                    a,
454                    InferredAction::InvalidateMemory { .. } | InferredAction::MarkObsolete { .. }
455                )
456            })
457            .count();
458        assert_eq!(
459            invalidation_count, 1,
460            "Expected a single invalidation action, got: {:?}",
461            actions
462        );
463    }
464
465    #[tokio::test]
466    async fn test_llm_update_emits_update_content() {
467        let agent = AgentId::new();
468        let mut existing = make_memory(
469            "Project uses React",
470            vec![0.8, 0.6, 0.0],
471            MemoryType::Semantic,
472        );
473        existing.agent_id = agent;
474
475        let mut new_mem = make_memory(
476            "Project migrated from React to Vue",
477            vec![0.75, 0.65, 0.1],
478            MemoryType::Semantic,
479        );
480        new_mem.agent_id = agent;
481        new_mem.created_at = 2000;
482
483        let judge = MockLlmJudge::new(
484            r#"{"verdict": "update", "merged_content": "Project migrated from React to Vue in Q2", "reason": "adds temporal context"}"#,
485        );
486        let llm = CognitiveLlmService::new(judge);
487        let engine = WriteInferenceEngine::new();
488        let actions = engine
489            .infer_on_write_with_llm(&new_mem, &[existing.clone()], &[], &llm)
490            .await;
491
492        assert!(
493            actions
494                .iter()
495                .any(|a| matches!(a, InferredAction::UpdateContent { .. })),
496            "Expected UpdateContent from LLM update verdict, got: {:?}",
497            actions
498        );
499    }
500
501    #[tokio::test]
502    async fn test_llm_keep_falls_through_to_contradiction_check() {
503        let agent = AgentId::new();
504        // High similarity pair where LLM says keep but content differs
505        let mut existing = make_memory("Prefers tabs", vec![0.9, 0.44, 0.0], MemoryType::Semantic);
506        existing.agent_id = agent;
507
508        let mut new_mem = make_memory(
509            "Prefers spaces",
510            vec![0.88, 0.47, 0.0],
511            MemoryType::Semantic,
512        );
513        new_mem.agent_id = agent;
514        new_mem.created_at = 2000;
515
516        let judge = MockLlmJudge::new(
517            r#"{"verdict": "compatible", "reason": "different formatting preferences"}"#,
518        );
519        let llm = CognitiveLlmService::new(judge);
520        let engine = WriteInferenceEngine::new();
521        let actions = engine
522            .infer_on_write_with_llm(&new_mem, &[existing.clone()], &[], &llm)
523            .await;
524
525        // MockLlmJudge returns the same response for all calls, so the keep
526        // from invalidation falls through, then contradiction returns compatible
527        assert!(
528            !actions
529                .iter()
530                .any(|a| matches!(a, InferredAction::FlagContradiction { .. })),
531            "Should not flag contradiction when LLM says compatible",
532        );
533    }
534}