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    /// A byte-identical re-save: invalidate the duplicate and link it to the
28    /// surviving copy with a `Derived` edge (deduplication lineage), NOT a
29    /// `Supersedes` edge. A Supersedes edge here reads as a memory superseding an
30    /// exact copy of itself, which only shows up as noise in the conflict view.
31    DeduplicateExact {
32        duplicate: MemoryId,
33        keeper: MemoryId,
34    },
35    /// Update the content of an existing memory with merged information.
36    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/// Configuration for write-time inference thresholds.
74#[derive(Debug, Clone)]
75pub struct WriteInferenceConfig {
76    /// Similarity above which two memories may contradict (default: 0.95).
77    pub contradiction_threshold: f32,
78    /// Similarity above which an older memory is marked obsolete (default: 0.85).
79    pub obsolete_threshold: f32,
80    /// Minimum similarity for creating a Related edge (default: 0.6).
81    pub related_min: f32,
82    /// Maximum similarity for creating a Related edge (default: 0.85).
83    pub related_max: f32,
84    /// Minimum similarity for a Correction to supersede (default: 0.5).
85    pub correction_threshold: f32,
86    /// Multiplier applied to original confidence on correction (default: 0.5).
87    pub confidence_decay_factor: f32,
88    /// Minimum confidence after decay (default: 0.1).
89    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; // reserved for future graph-aware inference
128        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            // Cosine similarity alone cannot tell a reworded duplicate from a
138            // genuine contradiction: paraphrases ("uses Postgres" / "uses
139            // PostgreSQL") and opposites ("prefers tabs" / "prefers spaces")
140            // both land in the high-similarity band. So the cheap write-time
141            // heuristic makes only the two judgments similarity CAN support:
142            //   1. byte-identical content -> a true duplicate, supersede it
143            //   2. moderate similarity    -> a Related edge
144            // Every semantic decision (merge, paraphrase dedup, real
145            // contradiction, which-fact-wins supersession) is deferred to the
146            // LLM paths (`consolidate_memories`, `detect_conflicts_with_llm`),
147            // which read the actual text. Flagging contradictions from bare
148            // similarity here produced ~0% precision (every reworded re-save
149            // looked like a contradiction), so it was removed.
150            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        // Correction type: find the most similar existing memory and supersede it
169        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    /// LLM enhanced write inference. Uses the CognitiveLlmService when available
199    /// to make smarter invalidation and contradiction decisions. Falls back to
200    /// cosine similarity heuristics for moderate similarity (Related edges) and
201    /// correction handling.
202    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            // Only consult LLM for memories with moderate+ similarity (> 0.5)
222            // to avoid burning tokens on completely unrelated pairs
223            if sim > 0.5 && existing.agent_id == new_memory.agent_id {
224                let old_summary = memory_to_summary(existing);
225
226                // LLM invalidation check
227                if let Ok(verdict) = llm.judge_invalidation(&old_summary, &new_summary).await {
228                    match verdict {
229                        InvalidationVerdict::Invalidate { reason: _ } => {
230                            // InvalidateMemory sets valid_until and creates the
231                            // Supersedes edge; no separate MarkObsolete/CreateEdge.
232                            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                            // Skip contradiction check since we already know the relationship
244                            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                            // Fall through to contradiction check
259                        }
260                    }
261                }
262
263                // LLM contradiction check for high similarity pairs that weren't invalidated
264                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            // Moderate similarity: create Related edge (heuristic, no LLM needed)
295            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        // Correction type still uses heuristic (find most similar and supersede)
306        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        // Two facts with high embedding similarity but different content look
368        // identical to cosine similarity whether they are paraphrases or
369        // opposites. The cheap heuristic must NOT guess "contradiction" here
370        // (that produced ~0% precision in production); real contradiction
371        // detection is the LLM path's job. High-sim different-content yields no
372        // action from the heuristic.
373        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        // Identical text is deduplicated (invalidate + Derived lineage), not a
396        // supersession, so it never surfaces as a conflict.
397        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        // ~0.7 similarity
428        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        // Exactly one invalidation action: InvalidateMemory both sets
479        // valid_until and creates the Supersedes edge, so a MarkObsolete
480        // alongside it would duplicate the edge.
481        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        // High similarity pair where LLM says keep but content differs
537        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        // MockLlmJudge returns the same response for all calls, so the keep
558        // from invalidation falls through, then contradiction returns compatible
559        assert!(
560            !actions
561                .iter()
562                .any(|a| matches!(a, InferredAction::FlagContradiction { .. })),
563            "Should not flag contradiction when LLM says compatible",
564        );
565    }
566}