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            // Very high similarity: potential duplicate or contradiction
130            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            // High similarity: mark older as obsolete if newer timestamp
145            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                // Also emit temporal invalidation so the old memory gets valid_until set
151                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            // Moderate similarity: create Related edge
159            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        // Correction type: find the most similar existing memory and supersede it
170        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    /// LLM enhanced write inference. Uses the CognitiveLlmService when available
200    /// to make smarter invalidation and contradiction decisions. Falls back to
201    /// cosine similarity heuristics for moderate similarity (Related edges) and
202    /// correction handling.
203    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            // Only consult LLM for memories with moderate+ similarity (> 0.5)
223            // to avoid burning tokens on completely unrelated pairs
224            if sim > 0.5 && existing.agent_id == new_memory.agent_id {
225                let old_summary = memory_to_summary(existing);
226
227                // LLM invalidation check
228                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                            // Skip contradiction check since we already know the relationship
253                            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                            // Fall through to contradiction check
268                        }
269                    }
270                }
271
272                // LLM contradiction check for high similarity pairs that weren't invalidated
273                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            // Moderate similarity: create Related edge (heuristic, no LLM needed)
314            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        // Correction type still uses heuristic (find most similar and supersede)
325        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        // ~0.7 similarity
416        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        // High similarity pair where LLM says keep but content differs
515        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        // MockLlmJudge returns the same response for all calls, so the keep
536        // from invalidation falls through, then contradiction returns compatible
537        assert!(
538            !actions
539                .iter()
540                .any(|a| matches!(a, InferredAction::FlagContradiction { .. })),
541            "Should not flag contradiction when LLM says compatible",
542        );
543    }
544}