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            // Cosine similarity alone cannot tell a reworded duplicate from a
130            // genuine contradiction: paraphrases ("uses Postgres" / "uses
131            // PostgreSQL") and opposites ("prefers tabs" / "prefers spaces")
132            // both land in the high-similarity band. So the cheap write-time
133            // heuristic makes only the two judgments similarity CAN support:
134            //   1. byte-identical content -> a true duplicate, supersede it
135            //   2. moderate similarity    -> a Related edge
136            // Every semantic decision (merge, paraphrase dedup, real
137            // contradiction, which-fact-wins supersession) is deferred to the
138            // LLM paths (`consolidate_memories`, `detect_conflicts_with_llm`),
139            // which read the actual text. Flagging contradictions from bare
140            // similarity here produced ~0% precision (every reworded re-save
141            // looked like a contradiction), so it was removed.
142            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        // Correction type: find the most similar existing memory and supersede it
162        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    /// LLM enhanced write inference. Uses the CognitiveLlmService when available
192    /// to make smarter invalidation and contradiction decisions. Falls back to
193    /// cosine similarity heuristics for moderate similarity (Related edges) and
194    /// correction handling.
195    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            // Only consult LLM for memories with moderate+ similarity (> 0.5)
215            // to avoid burning tokens on completely unrelated pairs
216            if sim > 0.5 && existing.agent_id == new_memory.agent_id {
217                let old_summary = memory_to_summary(existing);
218
219                // LLM invalidation check
220                if let Ok(verdict) = llm.judge_invalidation(&old_summary, &new_summary).await {
221                    match verdict {
222                        InvalidationVerdict::Invalidate { reason: _ } => {
223                            // InvalidateMemory sets valid_until and creates the
224                            // Supersedes edge; no separate MarkObsolete/CreateEdge.
225                            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                            // Skip contradiction check since we already know the relationship
237                            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                            // Fall through to contradiction check
252                        }
253                    }
254                }
255
256                // LLM contradiction check for high similarity pairs that weren't invalidated
257                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            // Moderate similarity: create Related edge (heuristic, no LLM needed)
288            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        // Correction type still uses heuristic (find most similar and supersede)
299        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        // Two facts with high embedding similarity but different content look
361        // identical to cosine similarity whether they are paraphrases or
362        // opposites. The cheap heuristic must NOT guess "contradiction" here
363        // (that produced ~0% precision in production); real contradiction
364        // detection is the LLM path's job. High-sim different-content yields no
365        // action from the heuristic.
366        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        // The one supersession the heuristic can make safely: identical text.
389        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        // ~0.7 similarity
420        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        // Exactly one invalidation action: InvalidateMemory both sets
471        // valid_until and creates the Supersedes edge, so a MarkObsolete
472        // alongside it would duplicate the edge.
473        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        // High similarity pair where LLM says keep but content differs
529        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        // MockLlmJudge returns the same response for all calls, so the keep
550        // from invalidation falls through, then contradiction returns compatible
551        assert!(
552            !actions
553                .iter()
554                .any(|a| matches!(a, InferredAction::FlagContradiction { .. })),
555            "Should not flag contradiction when LLM says compatible",
556        );
557    }
558}