Skip to main content

mentedb_cognitive/
llm.rs

1//! # CognitiveLlmService: LLM powered judgment for memory operations
2//!
3//! Provides intelligent decision making for temporal invalidation, contradiction
4//! detection, entity resolution, memory consolidation, and topic canonicalization.
5//! When no LLM is configured the caller falls back to heuristic paths.
6
7use mentedb_core::memory::MemoryType;
8use mentedb_core::types::MemoryId;
9use serde::{Deserialize, Serialize};
10
11// ---------------------------------------------------------------------------
12// Error
13// ---------------------------------------------------------------------------
14
15/// Errors that can occur during LLM judgment calls.
16#[derive(Debug, thiserror::Error)]
17pub enum LlmJudgeError {
18    #[error("LLM provider returned an error: {0}")]
19    ProviderError(String),
20    #[error("failed to parse LLM response: {0}")]
21    ParseError(String),
22    #[error("LLM returned an unexpected verdict: {0}")]
23    UnexpectedVerdict(String),
24}
25
26// ---------------------------------------------------------------------------
27// LlmJudge trait
28// ---------------------------------------------------------------------------
29
30/// Minimal trait for sending a prompt to an LLM and receiving a text response.
31///
32/// Implementations exist for HTTP providers (OpenAI, Anthropic, Ollama, Custom)
33/// via the adapter in `mentedb-extraction`, and for testing via [`MockLlmJudge`].
34pub trait LlmJudge: Send + Sync {
35    /// Send a system prompt and user prompt to the LLM, return the raw response text.
36    fn complete(
37        &self,
38        system_prompt: &str,
39        user_prompt: &str,
40    ) -> impl std::future::Future<Output = Result<String, LlmJudgeError>> + Send;
41}
42
43impl<T: LlmJudge> LlmJudge for std::sync::Arc<T> {
44    fn complete(
45        &self,
46        system_prompt: &str,
47        user_prompt: &str,
48    ) -> impl std::future::Future<Output = Result<String, LlmJudgeError>> + Send {
49        (**self).complete(system_prompt, user_prompt)
50    }
51}
52
53// ---------------------------------------------------------------------------
54// Verdict types
55// ---------------------------------------------------------------------------
56
57/// Result of asking the LLM whether a new memory invalidates an existing one.
58#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59#[serde(tag = "verdict", rename_all = "snake_case")]
60pub enum InvalidationVerdict {
61    /// Both memories are valid and describe different facts.
62    Keep { reason: String },
63    /// The new memory makes the old one no longer true.
64    Invalidate { reason: String },
65    /// The old memory should be updated with new information.
66    Update {
67        merged_content: String,
68        reason: String,
69    },
70}
71
72/// Result of asking the LLM whether two memories contradict each other.
73#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
74#[serde(tag = "verdict", rename_all = "snake_case")]
75pub enum ContradictionVerdict {
76    /// The memories are compatible and can coexist.
77    Compatible { reason: String },
78    /// The memories directly contradict each other.
79    Contradicts { reason: String },
80    /// One memory supersedes the other (includes direction).
81    Supersedes {
82        #[serde(rename = "superseding_id")]
83        winner: String,
84        reason: String,
85    },
86}
87
88/// A group of entity references that the LLM determined refer to the same thing.
89#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
90pub struct EntityMergeGroup {
91    /// The canonical name for this entity.
92    pub canonical: String,
93    /// All alternative names that map to this entity.
94    pub aliases: Vec<String>,
95    /// How confident the LLM is in this grouping (0.0 to 1.0).
96    pub confidence: f32,
97}
98
99/// An entity candidate passed to the resolution method.
100#[derive(Debug, Clone, Serialize, Deserialize)]
101pub struct EntityCandidate {
102    /// The raw entity reference string.
103    pub name: String,
104    /// Optional surrounding context to help disambiguation.
105    pub context: Option<String>,
106    /// The memory ID this entity was extracted from.
107    pub memory_id: Option<MemoryId>,
108}
109
110/// What to do with a cluster of similar memories after consolidation.
111#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
112#[serde(tag = "action", rename_all = "snake_case")]
113pub enum ConsolidationDecision {
114    /// All memories in the cluster are distinct, keep them all.
115    KeepAll { reason: String },
116    /// Merge multiple memories into one with richer content.
117    Merge {
118        merged_content: String,
119        merged_type: String,
120        keep_ids: Vec<String>,
121        remove_ids: Vec<String>,
122        reason: String,
123    },
124    /// One memory is clearly the best, remove the rest.
125    Deduplicate {
126        keep_id: String,
127        remove_ids: Vec<String>,
128        reason: String,
129    },
130}
131
132/// A canonical topic label returned by the LLM.
133#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
134pub struct TopicLabel {
135    /// The canonical 1-3 word topic label.
136    pub topic: String,
137    /// Whether this is a new topic or matched an existing one.
138    pub is_new: bool,
139}
140
141/// LLM-generated community summary for a cluster of entities.
142#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
143pub struct CommunitySummary {
144    pub summary: String,
145}
146
147/// LLM-generated user profile from accumulated knowledge.
148#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
149pub struct UserProfile {
150    pub profile: String,
151}
152
153// ---------------------------------------------------------------------------
154// Compact memory representation for prompts
155// ---------------------------------------------------------------------------
156
157/// Lightweight memory summary used as input to judgment methods.
158#[derive(Debug, Clone, Serialize, Deserialize)]
159pub struct MemorySummary {
160    pub id: MemoryId,
161    pub content: String,
162    pub memory_type: MemoryType,
163    pub confidence: f32,
164    pub created_at: u64,
165}
166
167// ---------------------------------------------------------------------------
168// Prompts
169// ---------------------------------------------------------------------------
170
171mod prompts {
172    pub const INVALIDATION_SYSTEM: &str = r#"You are a memory validity judge for an AI agent's long term memory system.
173
174Given an EXISTING memory and a NEW memory, determine the relationship:
175
176- "keep": Both memories are valid. They describe different facts or the old one is still true.
177- "invalidate": The new memory makes the old one no longer true. Example: old says "Alice works at Acme", new says "Alice joined Google" — the old employment fact is now outdated.
178- "update": The old memory should be updated to incorporate new information. Example: old says "project uses React", new says "project migrated from React to Vue" — merge into one memory.
179
180Respond with ONLY a JSON object. Examples:
181{"verdict": "keep", "reason": "These describe different aspects of the same topic"}
182{"verdict": "invalidate", "reason": "The user changed jobs, old employment is outdated"}
183{"verdict": "update", "merged_content": "Project migrated from React to Vue in Q2", "reason": "The new memory adds temporal context to the old one"}"#;
184
185    pub const CONTRADICTION_SYSTEM: &str = r#"You are a contradiction detector for an AI agent's long term memory system.
186
187Given two memories A and B, determine their relationship:
188
189- "compatible": They can both be true at the same time.
190- "contradicts": They directly contradict each other and cannot both be true.
191- "supersedes": One replaces the other due to a change over time (not a logical contradiction but a temporal update).
192
193For "supersedes", include which memory wins using its ID.
194
195Respond with ONLY a JSON object. Examples:
196{"verdict": "compatible", "reason": "These describe different topics"}
197{"verdict": "contradicts", "reason": "Cannot prefer both PostgreSQL and MySQL as primary database"}
198{"verdict": "supersedes", "superseding_id": "<id of winner>", "reason": "Memory B reflects a more recent decision"}"#;
199
200    pub const ENTITY_RESOLUTION_SYSTEM: &str = r#"You are an entity resolution system for an AI agent's long term memory.
201
202Given a list of entity references (names, pronouns, descriptions), group the ones that refer to the same real world entity. Use the provided context to disambiguate.
203
204Rules:
205- Only group entities you are confident refer to the same thing
206- "Python" (language) and "python" (the snake) are DIFFERENT entities if context makes that clear
207- Pronouns like "my manager" can be grouped with a name if context confirms it
208- Include a confidence score (0.0 to 1.0) for each group
209
210Respond with ONLY a JSON object:
211{"groups": [{"canonical": "Alice Smith", "aliases": ["Alice", "my manager"], "confidence": 0.9}]}"#;
212
213    pub const CONSOLIDATION_SYSTEM: &str = r#"You are a memory consolidation system for an AI agent's long term memory.
214
215Given a cluster of similar memories, decide how to combine them:
216
217- "keep_all": The memories describe genuinely different facts despite being similar.
218- "merge": Multiple memories should be combined into one richer memory. Provide the merged content.
219- "deduplicate": One memory captures everything, the others are redundant. Keep the best one.
220
221When merging, preserve all important information from each memory. The merged content should be strictly better than any individual memory.
222
223Respond with ONLY a JSON object. Examples:
224{"action": "keep_all", "reason": "Each memory describes a different API endpoint"}
225{"action": "merge", "merged_content": "User prefers Rust for systems programming due to memory safety and zero cost abstractions", "merged_type": "Semantic", "keep_ids": [], "remove_ids": ["id1", "id2"], "reason": "Both memories express the same preference with complementary detail"}
226{"action": "deduplicate", "keep_id": "id1", "remove_ids": ["id2", "id3"], "reason": "id1 is the most complete version"}"#;
227
228    pub const TOPIC_SYSTEM: &str = r#"You are a topic labeler for a conversation tracking system.
229
230Given a user message and a list of existing topic labels, either:
2311. Assign the MOST appropriate existing label if the message fits
2322. Create a new 1-3 word label if no existing label fits
233
234Rules:
235- Labels should be lowercase, concise (1-3 words max)
236- Prefer existing labels when the topic clearly matches
237- "deploy to staging" and "deploy to production" are the SAME topic: "deployment"
238- "auth error" and "set up authentication" are the SAME topic: "authentication"
239- Be general enough to group related messages but specific enough to be useful
240
241Respond with ONLY a JSON object:
242{"topic": "authentication", "is_new": false}"#;
243
244    pub const COMMUNITY_SUMMARY_SYSTEM: &str = r#"You are a knowledge summarizer for an AI agent's long-term memory system.
245
246Given a category name and a list of entities the user has interacted with, create a concise summary that captures the user's relationship to this cluster of entities.
247
248Rules:
249- Mention the key entities by name — prioritize the most significant ones
250- Focus on the user's relationship to each (uses, likes, works with, etc.)
251- Keep it to 2-3 sentences maximum
252- The summary should be findable when someone searches for this topic
253- Write in third person ("The user...")
254
255Respond with ONLY a JSON object:
256{"summary": "The user works with Python and Rust for systems programming, uses PostgreSQL for databases, and deploys on AWS."}"#;
257
258    pub const USER_PROFILE_SYSTEM: &str = r#"You are a profile generator for an AI agent's long-term memory system.
259
260Given a collection of facts, preferences, and patterns about a user, generate a concise profile that captures the most important information an AI assistant should know.
261
262Rules:
263- Prioritize: preferences > active projects > skills > relationships > habits
264- Be factual — only include what the evidence supports
265- Keep it under 200 words
266- Write in third person ("The user...")
267- Group related facts together
268- Include specific names, tools, and preferences — not vague generalities
269- This profile will be included in EVERY conversation, so only include persistently relevant facts
270
271Respond with ONLY a JSON object:
272{"profile": "The user is a software engineer who prefers Rust and Python. They are building MenteDB, a cognitive memory database..."}"#;
273}
274
275// ---------------------------------------------------------------------------
276// CognitiveLlmService
277// ---------------------------------------------------------------------------
278
279/// The unified LLM intelligence layer for memory operations.
280///
281/// Wraps any [`LlmJudge`] implementation and provides typed judgment methods
282/// with specialized prompts for each cognitive task.
283pub struct CognitiveLlmService<J: LlmJudge> {
284    judge: J,
285}
286
287impl<J: LlmJudge> CognitiveLlmService<J> {
288    /// Create a new service backed by the given LLM judge.
289    pub fn new(judge: J) -> Self {
290        Self { judge }
291    }
292
293    /// Determine whether a new memory invalidates an existing one.
294    ///
295    /// This is the key method that replaces the cosine similarity heuristic
296    /// for temporal invalidation. The LLM can understand that "Alice works at
297    /// Acme" is invalidated by "Alice joined Google" even though the embeddings
298    /// are quite different.
299    pub async fn judge_invalidation(
300        &self,
301        old: &MemorySummary,
302        new: &MemorySummary,
303    ) -> Result<InvalidationVerdict, LlmJudgeError> {
304        let user_prompt = format!(
305            "EXISTING memory:\n{}\n\nNEW memory:\n{}",
306            serde_json::to_string_pretty(old).unwrap_or_default(),
307            serde_json::to_string_pretty(new).unwrap_or_default(),
308        );
309
310        let response = self
311            .judge
312            .complete(prompts::INVALIDATION_SYSTEM, &user_prompt)
313            .await?;
314        parse_json_response::<InvalidationVerdict>(&response)
315    }
316
317    /// Determine whether two memories contradict each other.
318    pub async fn detect_contradiction(
319        &self,
320        a: &MemorySummary,
321        b: &MemorySummary,
322    ) -> Result<ContradictionVerdict, LlmJudgeError> {
323        let user_prompt = format!(
324            "Memory A:\n{}\n\nMemory B:\n{}",
325            serde_json::to_string_pretty(a).unwrap_or_default(),
326            serde_json::to_string_pretty(b).unwrap_or_default(),
327        );
328
329        let response = self
330            .judge
331            .complete(prompts::CONTRADICTION_SYSTEM, &user_prompt)
332            .await?;
333        parse_json_response::<ContradictionVerdict>(&response)
334    }
335
336    /// Resolve a batch of entity references into merge groups.
337    pub async fn resolve_entities(
338        &self,
339        candidates: &[EntityCandidate],
340    ) -> Result<Vec<EntityMergeGroup>, LlmJudgeError> {
341        if candidates.is_empty() {
342            return Ok(Vec::new());
343        }
344
345        let user_prompt = format!(
346            "Entity references to resolve:\n{}",
347            serde_json::to_string_pretty(candidates).unwrap_or_default(),
348        );
349
350        let response = self
351            .judge
352            .complete(prompts::ENTITY_RESOLUTION_SYSTEM, &user_prompt)
353            .await?;
354
355        #[derive(Deserialize)]
356        struct EntityResponse {
357            groups: Vec<EntityMergeGroup>,
358        }
359
360        let parsed = parse_json_response::<EntityResponse>(&response)?;
361        Ok(parsed.groups)
362    }
363
364    /// Decide how to consolidate a cluster of similar memories.
365    pub async fn consolidate(
366        &self,
367        cluster: &[ClusterMember],
368    ) -> Result<ConsolidationDecision, LlmJudgeError> {
369        if cluster.is_empty() {
370            return Ok(ConsolidationDecision::KeepAll {
371                reason: "Empty cluster".to_string(),
372            });
373        }
374
375        let user_prompt = format!(
376            "Memory cluster to consolidate:\n{}",
377            serde_json::to_string_pretty(cluster).unwrap_or_default(),
378        );
379
380        let response = self
381            .judge
382            .complete(prompts::CONSOLIDATION_SYSTEM, &user_prompt)
383            .await?;
384        parse_json_response::<ConsolidationDecision>(&response)
385    }
386
387    /// Map a raw user message to a canonical topic label.
388    pub async fn canonicalize_topic(
389        &self,
390        message: &str,
391        existing_topics: &[String],
392    ) -> Result<TopicLabel, LlmJudgeError> {
393        let user_prompt = format!(
394            "User message: \"{}\"\n\nExisting topic labels: {:?}",
395            message, existing_topics,
396        );
397
398        let response = self
399            .judge
400            .complete(prompts::TOPIC_SYSTEM, &user_prompt)
401            .await?;
402        parse_json_response::<TopicLabel>(&response)
403    }
404
405    /// Generate a summary for a community (cluster of related entities).
406    pub async fn generate_community_summary(
407        &self,
408        category: &str,
409        entities: &[(String, String)], // (name, context/relationship)
410    ) -> Result<CommunitySummary, LlmJudgeError> {
411        let entity_list = entities
412            .iter()
413            .map(|(name, ctx)| format!("- {} — {}", name, ctx))
414            .collect::<Vec<_>>()
415            .join("\n");
416
417        let user_prompt = format!(
418            "Category: {}\n\nEntities in this cluster:\n{}",
419            category, entity_list
420        );
421
422        let response = self
423            .judge
424            .complete(prompts::COMMUNITY_SUMMARY_SYSTEM, &user_prompt)
425            .await?;
426        parse_json_response::<CommunitySummary>(&response)
427    }
428
429    /// Generate a user profile from accumulated knowledge.
430    pub async fn generate_user_profile(
431        &self,
432        facts: &[String],
433        community_summaries: &[String],
434    ) -> Result<UserProfile, LlmJudgeError> {
435        let mut sections = Vec::new();
436
437        if !facts.is_empty() {
438            sections.push(format!(
439                "Known facts about the user:\n{}",
440                facts
441                    .iter()
442                    .map(|f| format!("- {}", f))
443                    .collect::<Vec<_>>()
444                    .join("\n")
445            ));
446        }
447
448        if !community_summaries.is_empty() {
449            sections.push(format!(
450                "Community summaries:\n{}",
451                community_summaries
452                    .iter()
453                    .map(|s| format!("- {}", s))
454                    .collect::<Vec<_>>()
455                    .join("\n")
456            ));
457        }
458
459        let user_prompt = sections.join("\n\n");
460
461        if user_prompt.is_empty() {
462            return Err(LlmJudgeError::ParseError(
463                "no facts or summaries provided for profile generation".into(),
464            ));
465        }
466
467        let response = self
468            .judge
469            .complete(prompts::USER_PROFILE_SYSTEM, &user_prompt)
470            .await?;
471        parse_json_response::<UserProfile>(&response)
472    }
473}
474
475/// A memory in a consolidation cluster.
476#[derive(Debug, Clone, Serialize, Deserialize)]
477pub struct ClusterMember {
478    pub id: String,
479    pub content: String,
480    pub memory_type: String,
481    pub confidence: f32,
482    pub created_at: u64,
483}
484
485// ---------------------------------------------------------------------------
486// JSON parsing helper
487// ---------------------------------------------------------------------------
488
489fn parse_json_response<T: serde::de::DeserializeOwned>(raw: &str) -> Result<T, LlmJudgeError> {
490    // Try direct parse first
491    if let Ok(v) = serde_json::from_str::<T>(raw) {
492        return Ok(v);
493    }
494
495    let trimmed = raw.trim();
496
497    // LLMs sometimes wrap JSON in markdown code blocks
498    let stripped = if trimmed.starts_with("```json") {
499        trimmed
500            .strip_prefix("```json")
501            .and_then(|s| s.strip_suffix("```"))
502            .unwrap_or(trimmed)
503            .trim()
504    } else if trimmed.starts_with("```") {
505        trimmed
506            .strip_prefix("```")
507            .and_then(|s| s.strip_suffix("```"))
508            .unwrap_or(trimmed)
509            .trim()
510    } else {
511        trimmed
512    };
513
514    if let Ok(v) = serde_json::from_str::<T>(stripped) {
515        return Ok(v);
516    }
517
518    // LLMs sometimes add explanatory text around the JSON.
519    // Try to extract the first JSON object from the response.
520    if let Some(start) = stripped.find('{')
521        && let Some(end) = rfind_matching_brace(stripped, start)
522    {
523        let candidate = &stripped[start..=end];
524        if let Ok(v) = serde_json::from_str::<T>(candidate) {
525            return Ok(v);
526        }
527    }
528
529    Err(LlmJudgeError::ParseError(format!(
530        "could not parse LLM response as expected type. Raw response: {raw}"
531    )))
532}
533
534/// Find the closing brace that matches the opening brace at `start`.
535fn rfind_matching_brace(s: &str, start: usize) -> Option<usize> {
536    let mut depth = 0i32;
537    let mut in_string = false;
538    let mut escape_next = false;
539
540    for (i, ch) in s[start..].char_indices() {
541        if escape_next {
542            escape_next = false;
543            continue;
544        }
545        match ch {
546            '\\' if in_string => escape_next = true,
547            '"' => in_string = !in_string,
548            '{' if !in_string => depth += 1,
549            '}' if !in_string => {
550                depth -= 1;
551                if depth == 0 {
552                    return Some(start + i);
553                }
554            }
555            _ => {}
556        }
557    }
558    None
559}
560
561// ---------------------------------------------------------------------------
562// MockLlmJudge for testing
563// ---------------------------------------------------------------------------
564
565/// A mock judge that returns a predetermined response. For testing.
566pub struct MockLlmJudge {
567    response: String,
568}
569
570impl MockLlmJudge {
571    pub fn new(response: impl Into<String>) -> Self {
572        Self {
573            response: response.into(),
574        }
575    }
576}
577
578impl LlmJudge for MockLlmJudge {
579    async fn complete(
580        &self,
581        _system_prompt: &str,
582        _user_prompt: &str,
583    ) -> Result<String, LlmJudgeError> {
584        Ok(self.response.clone())
585    }
586}
587
588/// A mock judge that captures prompts for inspection.
589#[cfg(test)]
590pub struct CapturingJudge {
591    response: String,
592    captured: std::sync::Mutex<Vec<(String, String)>>,
593}
594
595#[cfg(test)]
596impl CapturingJudge {
597    pub fn new(response: impl Into<String>) -> Self {
598        Self {
599            response: response.into(),
600            captured: std::sync::Mutex::new(Vec::new()),
601        }
602    }
603
604    pub fn calls(&self) -> Vec<(String, String)> {
605        self.captured.lock().unwrap().clone()
606    }
607}
608
609#[cfg(test)]
610impl LlmJudge for CapturingJudge {
611    async fn complete(
612        &self,
613        system_prompt: &str,
614        user_prompt: &str,
615    ) -> Result<String, LlmJudgeError> {
616        self.captured
617            .lock()
618            .unwrap()
619            .push((system_prompt.to_string(), user_prompt.to_string()));
620        Ok(self.response.clone())
621    }
622}
623
624// ---------------------------------------------------------------------------
625// Tests
626// ---------------------------------------------------------------------------
627
628#[cfg(test)]
629mod tests {
630    use super::*;
631    use mentedb_core::types::MemoryId;
632
633    fn test_id() -> MemoryId {
634        MemoryId::new()
635    }
636
637    fn mem(content: &str) -> MemorySummary {
638        MemorySummary {
639            id: test_id(),
640            content: content.to_string(),
641            memory_type: MemoryType::Semantic,
642            confidence: 0.9,
643            created_at: 1000,
644        }
645    }
646
647    #[allow(dead_code)]
648    fn mem_at(content: &str, created_at: u64) -> MemorySummary {
649        MemorySummary {
650            id: test_id(),
651            content: content.to_string(),
652            memory_type: MemoryType::Semantic,
653            confidence: 0.9,
654            created_at,
655        }
656    }
657
658    #[tokio::test]
659    async fn test_judge_invalidation_keep() {
660        let judge = MockLlmJudge::new(
661            r#"{"verdict": "keep", "reason": "These describe different people"}"#,
662        );
663        let svc = CognitiveLlmService::new(judge);
664
665        let result = svc
666            .judge_invalidation(&mem("Alice works at Acme"), &mem("Bob works at Google"))
667            .await
668            .unwrap();
669
670        assert!(matches!(result, InvalidationVerdict::Keep { .. }));
671    }
672
673    #[tokio::test]
674    async fn test_judge_invalidation_invalidate() {
675        let judge =
676            MockLlmJudge::new(r#"{"verdict": "invalidate", "reason": "Alice changed jobs"}"#);
677        let svc = CognitiveLlmService::new(judge);
678
679        let result = svc
680            .judge_invalidation(&mem("Alice works at Acme"), &mem("Alice joined Google"))
681            .await
682            .unwrap();
683
684        assert!(matches!(result, InvalidationVerdict::Invalidate { .. }));
685    }
686
687    #[tokio::test]
688    async fn test_judge_invalidation_update() {
689        let judge = MockLlmJudge::new(
690            r#"{"verdict": "update", "merged_content": "Project migrated from React to Vue in Q2", "reason": "Temporal update"}"#,
691        );
692        let svc = CognitiveLlmService::new(judge);
693
694        let result = svc
695            .judge_invalidation(&mem("Project uses React"), &mem("Project migrated to Vue"))
696            .await
697            .unwrap();
698
699        match result {
700            InvalidationVerdict::Update { merged_content, .. } => {
701                assert!(merged_content.contains("React"));
702                assert!(merged_content.contains("Vue"));
703            }
704            other => panic!("Expected Update, got {:?}", other),
705        }
706    }
707
708    #[tokio::test]
709    async fn test_detect_contradiction_compatible() {
710        let judge = MockLlmJudge::new(
711            r#"{"verdict": "compatible", "reason": "Different topics entirely"}"#,
712        );
713        let svc = CognitiveLlmService::new(judge);
714
715        let result = svc
716            .detect_contradiction(&mem("Likes Python"), &mem("Uses PostgreSQL"))
717            .await
718            .unwrap();
719
720        assert!(matches!(result, ContradictionVerdict::Compatible { .. }));
721    }
722
723    #[tokio::test]
724    async fn test_detect_contradiction_contradicts() {
725        let judge =
726            MockLlmJudge::new(r#"{"verdict": "contradicts", "reason": "Cannot prefer both"}"#);
727        let svc = CognitiveLlmService::new(judge);
728
729        let result = svc
730            .detect_contradiction(&mem("Prefers tabs"), &mem("Prefers spaces"))
731            .await
732            .unwrap();
733
734        assert!(matches!(result, ContradictionVerdict::Contradicts { .. }));
735    }
736
737    #[tokio::test]
738    async fn test_detect_contradiction_supersedes() {
739        let id_b = test_id();
740        let response = format!(
741            r#"{{"verdict": "supersedes", "superseding_id": "{}", "reason": "B is newer"}}"#,
742            id_b
743        );
744        let judge = MockLlmJudge::new(response);
745        let svc = CognitiveLlmService::new(judge);
746
747        let mem_b = MemorySummary {
748            id: id_b,
749            content: "Migrated to Vue".into(),
750            memory_type: MemoryType::Semantic,
751            confidence: 0.9,
752            created_at: 2000,
753        };
754
755        let result = svc
756            .detect_contradiction(&mem("Uses React"), &mem_b)
757            .await
758            .unwrap();
759
760        assert!(matches!(result, ContradictionVerdict::Supersedes { .. }));
761    }
762
763    #[tokio::test]
764    async fn test_resolve_entities() {
765        let judge = MockLlmJudge::new(
766            r#"{"groups": [{"canonical": "Alice Smith", "aliases": ["Alice", "my manager"], "confidence": 0.92}]}"#,
767        );
768        let svc = CognitiveLlmService::new(judge);
769
770        let candidates = vec![
771            EntityCandidate {
772                name: "Alice".into(),
773                context: None,
774                memory_id: None,
775            },
776            EntityCandidate {
777                name: "my manager".into(),
778                context: Some("my manager Alice told me".into()),
779                memory_id: None,
780            },
781            EntityCandidate {
782                name: "Alice Smith".into(),
783                context: None,
784                memory_id: None,
785            },
786        ];
787
788        let groups = svc.resolve_entities(&candidates).await.unwrap();
789        assert_eq!(groups.len(), 1);
790        assert_eq!(groups[0].canonical, "Alice Smith");
791        assert_eq!(groups[0].aliases.len(), 2);
792    }
793
794    #[tokio::test]
795    async fn test_resolve_entities_empty() {
796        let judge = MockLlmJudge::new("should not be called");
797        let svc = CognitiveLlmService::new(judge);
798
799        let groups = svc.resolve_entities(&[]).await.unwrap();
800        assert!(groups.is_empty());
801    }
802
803    #[tokio::test]
804    async fn test_consolidate_merge() {
805        let judge = MockLlmJudge::new(
806            r#"{"action": "merge", "merged_content": "User prefers Rust for systems programming due to safety and performance", "merged_type": "Semantic", "keep_ids": [], "remove_ids": ["a", "b"], "reason": "Complementary details"}"#,
807        );
808        let svc = CognitiveLlmService::new(judge);
809
810        let cluster = vec![
811            ClusterMember {
812                id: "a".into(),
813                content: "Uses Rust".into(),
814                memory_type: "Semantic".into(),
815                confidence: 0.9,
816                created_at: 1000,
817            },
818            ClusterMember {
819                id: "b".into(),
820                content: "Prefers Rust for safety".into(),
821                memory_type: "Semantic".into(),
822                confidence: 0.85,
823                created_at: 2000,
824            },
825        ];
826
827        let decision = svc.consolidate(&cluster).await.unwrap();
828        match decision {
829            ConsolidationDecision::Merge {
830                merged_content,
831                remove_ids,
832                ..
833            } => {
834                assert!(merged_content.contains("Rust"));
835                assert_eq!(remove_ids.len(), 2);
836            }
837            other => panic!("Expected Merge, got {:?}", other),
838        }
839    }
840
841    #[tokio::test]
842    async fn test_consolidate_empty_cluster() {
843        let judge = MockLlmJudge::new("should not be called");
844        let svc = CognitiveLlmService::new(judge);
845
846        let decision = svc.consolidate(&[]).await.unwrap();
847        assert!(matches!(decision, ConsolidationDecision::KeepAll { .. }));
848    }
849
850    #[tokio::test]
851    async fn test_canonicalize_topic_existing() {
852        let judge = MockLlmJudge::new(r#"{"topic": "authentication", "is_new": false}"#);
853        let svc = CognitiveLlmService::new(judge);
854
855        let label = svc
856            .canonicalize_topic(
857                "how do I configure the auth middleware",
858                &[
859                    "database".into(),
860                    "authentication".into(),
861                    "deployment".into(),
862                ],
863            )
864            .await
865            .unwrap();
866
867        assert_eq!(label.topic, "authentication");
868        assert!(!label.is_new);
869    }
870
871    #[tokio::test]
872    async fn test_canonicalize_topic_new() {
873        let judge = MockLlmJudge::new(r#"{"topic": "caching", "is_new": true}"#);
874        let svc = CognitiveLlmService::new(judge);
875
876        let label = svc
877            .canonicalize_topic(
878                "should we add Redis for caching",
879                &["database".into(), "authentication".into()],
880            )
881            .await
882            .unwrap();
883
884        assert_eq!(label.topic, "caching");
885        assert!(label.is_new);
886    }
887
888    #[tokio::test]
889    async fn test_json_markdown_code_block_stripping() {
890        let judge = MockLlmJudge::new(
891            "```json\n{\"verdict\": \"keep\", \"reason\": \"wrapped in markdown\"}\n```",
892        );
893        let svc = CognitiveLlmService::new(judge);
894
895        let result = svc.judge_invalidation(&mem("A"), &mem("B")).await.unwrap();
896
897        assert!(matches!(result, InvalidationVerdict::Keep { .. }));
898    }
899
900    #[tokio::test]
901    async fn test_json_surrounded_by_text() {
902        let judge = MockLlmJudge::new(
903            "Here is my analysis:\n{\"verdict\": \"invalidate\", \"reason\": \"job changed\"}\nLet me know if you need more detail.",
904        );
905        let svc = CognitiveLlmService::new(judge);
906
907        let result = svc.judge_invalidation(&mem("A"), &mem("B")).await.unwrap();
908
909        assert!(matches!(result, InvalidationVerdict::Invalidate { .. }));
910    }
911
912    #[tokio::test]
913    async fn test_json_with_nested_braces_in_strings() {
914        let judge = MockLlmJudge::new(
915            "Sure! {\"verdict\": \"update\", \"merged_content\": \"uses {curly} braces\", \"reason\": \"test\"}",
916        );
917        let svc = CognitiveLlmService::new(judge);
918
919        let result = svc.judge_invalidation(&mem("A"), &mem("B")).await.unwrap();
920
921        match result {
922            InvalidationVerdict::Update { merged_content, .. } => {
923                assert!(merged_content.contains("{curly}"))
924            }
925            other => panic!("Expected Update, got {:?}", other),
926        }
927    }
928
929    #[tokio::test]
930    async fn test_provider_error_propagates() {
931        struct FailingJudge;
932        impl LlmJudge for FailingJudge {
933            async fn complete(&self, _: &str, _: &str) -> Result<String, LlmJudgeError> {
934                Err(LlmJudgeError::ProviderError("connection refused".into()))
935            }
936        }
937
938        let svc = CognitiveLlmService::new(FailingJudge);
939        let result = svc.judge_invalidation(&mem("A"), &mem("B")).await;
940
941        assert!(result.is_err());
942        assert!(
943            result
944                .unwrap_err()
945                .to_string()
946                .contains("connection refused")
947        );
948    }
949
950    #[tokio::test]
951    async fn test_prompt_includes_memory_content() {
952        let judge = std::sync::Arc::new(CapturingJudge::new(
953            r#"{"verdict": "keep", "reason": "test"}"#,
954        ));
955        let svc = CognitiveLlmService::new(judge.clone());
956
957        let _ = svc
958            .judge_invalidation(&mem("Alice works at Acme"), &mem("Alice joined Google"))
959            .await;
960
961        let calls = judge.calls();
962        assert_eq!(calls.len(), 1);
963        let (system, user) = &calls[0];
964        assert!(system.contains("memory validity judge"));
965        assert!(user.contains("Alice works at Acme"));
966        assert!(user.contains("Alice joined Google"));
967    }
968}