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// ---------------------------------------------------------------------------
142// Compact memory representation for prompts
143// ---------------------------------------------------------------------------
144
145/// Lightweight memory summary used as input to judgment methods.
146#[derive(Debug, Clone, Serialize, Deserialize)]
147pub struct MemorySummary {
148    pub id: MemoryId,
149    pub content: String,
150    pub memory_type: MemoryType,
151    pub confidence: f32,
152    pub created_at: u64,
153}
154
155// ---------------------------------------------------------------------------
156// Prompts
157// ---------------------------------------------------------------------------
158
159mod prompts {
160    pub const INVALIDATION_SYSTEM: &str = r#"You are a memory validity judge for an AI agent's long term memory system.
161
162Given an EXISTING memory and a NEW memory, determine the relationship:
163
164- "keep": Both memories are valid. They describe different facts or the old one is still true.
165- "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.
166- "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.
167
168Respond with ONLY a JSON object. Examples:
169{"verdict": "keep", "reason": "These describe different aspects of the same topic"}
170{"verdict": "invalidate", "reason": "The user changed jobs, old employment is outdated"}
171{"verdict": "update", "merged_content": "Project migrated from React to Vue in Q2", "reason": "The new memory adds temporal context to the old one"}"#;
172
173    pub const CONTRADICTION_SYSTEM: &str = r#"You are a contradiction detector for an AI agent's long term memory system.
174
175Given two memories A and B, determine their relationship:
176
177- "compatible": They can both be true at the same time.
178- "contradicts": They directly contradict each other and cannot both be true.
179- "supersedes": One replaces the other due to a change over time (not a logical contradiction but a temporal update).
180
181For "supersedes", include which memory wins using its ID.
182
183Respond with ONLY a JSON object. Examples:
184{"verdict": "compatible", "reason": "These describe different topics"}
185{"verdict": "contradicts", "reason": "Cannot prefer both PostgreSQL and MySQL as primary database"}
186{"verdict": "supersedes", "superseding_id": "<id of winner>", "reason": "Memory B reflects a more recent decision"}"#;
187
188    pub const ENTITY_RESOLUTION_SYSTEM: &str = r#"You are an entity resolution system for an AI agent's long term memory.
189
190Given 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.
191
192Rules:
193- Only group entities you are confident refer to the same thing
194- "Python" (language) and "python" (the snake) are DIFFERENT entities if context makes that clear
195- Pronouns like "my manager" can be grouped with a name if context confirms it
196- Include a confidence score (0.0 to 1.0) for each group
197
198Respond with ONLY a JSON object:
199{"groups": [{"canonical": "Alice Smith", "aliases": ["Alice", "my manager"], "confidence": 0.9}]}"#;
200
201    pub const CONSOLIDATION_SYSTEM: &str = r#"You are a memory consolidation system for an AI agent's long term memory.
202
203Given a cluster of similar memories, decide how to combine them:
204
205- "keep_all": The memories describe genuinely different facts despite being similar.
206- "merge": Multiple memories should be combined into one richer memory. Provide the merged content.
207- "deduplicate": One memory captures everything, the others are redundant. Keep the best one.
208
209When merging, preserve all important information from each memory. The merged content should be strictly better than any individual memory.
210
211Respond with ONLY a JSON object. Examples:
212{"action": "keep_all", "reason": "Each memory describes a different API endpoint"}
213{"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"}
214{"action": "deduplicate", "keep_id": "id1", "remove_ids": ["id2", "id3"], "reason": "id1 is the most complete version"}"#;
215
216    pub const TOPIC_SYSTEM: &str = r#"You are a topic labeler for a conversation tracking system.
217
218Given a user message and a list of existing topic labels, either:
2191. Assign the MOST appropriate existing label if the message fits
2202. Create a new 1-3 word label if no existing label fits
221
222Rules:
223- Labels should be lowercase, concise (1-3 words max)
224- Prefer existing labels when the topic clearly matches
225- "deploy to staging" and "deploy to production" are the SAME topic: "deployment"
226- "auth error" and "set up authentication" are the SAME topic: "authentication"
227- Be general enough to group related messages but specific enough to be useful
228
229Respond with ONLY a JSON object:
230{"topic": "authentication", "is_new": false}"#;
231}
232
233// ---------------------------------------------------------------------------
234// CognitiveLlmService
235// ---------------------------------------------------------------------------
236
237/// The unified LLM intelligence layer for memory operations.
238///
239/// Wraps any [`LlmJudge`] implementation and provides typed judgment methods
240/// with specialized prompts for each cognitive task.
241pub struct CognitiveLlmService<J: LlmJudge> {
242    judge: J,
243}
244
245impl<J: LlmJudge> CognitiveLlmService<J> {
246    /// Create a new service backed by the given LLM judge.
247    pub fn new(judge: J) -> Self {
248        Self { judge }
249    }
250
251    /// Determine whether a new memory invalidates an existing one.
252    ///
253    /// This is the key method that replaces the cosine similarity heuristic
254    /// for temporal invalidation. The LLM can understand that "Alice works at
255    /// Acme" is invalidated by "Alice joined Google" even though the embeddings
256    /// are quite different.
257    pub async fn judge_invalidation(
258        &self,
259        old: &MemorySummary,
260        new: &MemorySummary,
261    ) -> Result<InvalidationVerdict, LlmJudgeError> {
262        let user_prompt = format!(
263            "EXISTING memory:\n{}\n\nNEW memory:\n{}",
264            serde_json::to_string_pretty(old).unwrap_or_default(),
265            serde_json::to_string_pretty(new).unwrap_or_default(),
266        );
267
268        let response = self
269            .judge
270            .complete(prompts::INVALIDATION_SYSTEM, &user_prompt)
271            .await?;
272        parse_json_response::<InvalidationVerdict>(&response)
273    }
274
275    /// Determine whether two memories contradict each other.
276    pub async fn detect_contradiction(
277        &self,
278        a: &MemorySummary,
279        b: &MemorySummary,
280    ) -> Result<ContradictionVerdict, LlmJudgeError> {
281        let user_prompt = format!(
282            "Memory A:\n{}\n\nMemory B:\n{}",
283            serde_json::to_string_pretty(a).unwrap_or_default(),
284            serde_json::to_string_pretty(b).unwrap_or_default(),
285        );
286
287        let response = self
288            .judge
289            .complete(prompts::CONTRADICTION_SYSTEM, &user_prompt)
290            .await?;
291        parse_json_response::<ContradictionVerdict>(&response)
292    }
293
294    /// Resolve a batch of entity references into merge groups.
295    pub async fn resolve_entities(
296        &self,
297        candidates: &[EntityCandidate],
298    ) -> Result<Vec<EntityMergeGroup>, LlmJudgeError> {
299        if candidates.is_empty() {
300            return Ok(Vec::new());
301        }
302
303        let user_prompt = format!(
304            "Entity references to resolve:\n{}",
305            serde_json::to_string_pretty(candidates).unwrap_or_default(),
306        );
307
308        let response = self
309            .judge
310            .complete(prompts::ENTITY_RESOLUTION_SYSTEM, &user_prompt)
311            .await?;
312
313        #[derive(Deserialize)]
314        struct EntityResponse {
315            groups: Vec<EntityMergeGroup>,
316        }
317
318        let parsed = parse_json_response::<EntityResponse>(&response)?;
319        Ok(parsed.groups)
320    }
321
322    /// Decide how to consolidate a cluster of similar memories.
323    pub async fn consolidate(
324        &self,
325        cluster: &[ClusterMember],
326    ) -> Result<ConsolidationDecision, LlmJudgeError> {
327        if cluster.is_empty() {
328            return Ok(ConsolidationDecision::KeepAll {
329                reason: "Empty cluster".to_string(),
330            });
331        }
332
333        let user_prompt = format!(
334            "Memory cluster to consolidate:\n{}",
335            serde_json::to_string_pretty(cluster).unwrap_or_default(),
336        );
337
338        let response = self
339            .judge
340            .complete(prompts::CONSOLIDATION_SYSTEM, &user_prompt)
341            .await?;
342        parse_json_response::<ConsolidationDecision>(&response)
343    }
344
345    /// Map a raw user message to a canonical topic label.
346    pub async fn canonicalize_topic(
347        &self,
348        message: &str,
349        existing_topics: &[String],
350    ) -> Result<TopicLabel, LlmJudgeError> {
351        let user_prompt = format!(
352            "User message: \"{}\"\n\nExisting topic labels: {:?}",
353            message, existing_topics,
354        );
355
356        let response = self
357            .judge
358            .complete(prompts::TOPIC_SYSTEM, &user_prompt)
359            .await?;
360        parse_json_response::<TopicLabel>(&response)
361    }
362}
363
364/// A memory in a consolidation cluster.
365#[derive(Debug, Clone, Serialize, Deserialize)]
366pub struct ClusterMember {
367    pub id: String,
368    pub content: String,
369    pub memory_type: String,
370    pub confidence: f32,
371    pub created_at: u64,
372}
373
374// ---------------------------------------------------------------------------
375// JSON parsing helper
376// ---------------------------------------------------------------------------
377
378fn parse_json_response<T: serde::de::DeserializeOwned>(raw: &str) -> Result<T, LlmJudgeError> {
379    // Try direct parse first
380    if let Ok(v) = serde_json::from_str::<T>(raw) {
381        return Ok(v);
382    }
383
384    let trimmed = raw.trim();
385
386    // LLMs sometimes wrap JSON in markdown code blocks
387    let stripped = if trimmed.starts_with("```json") {
388        trimmed
389            .strip_prefix("```json")
390            .and_then(|s| s.strip_suffix("```"))
391            .unwrap_or(trimmed)
392            .trim()
393    } else if trimmed.starts_with("```") {
394        trimmed
395            .strip_prefix("```")
396            .and_then(|s| s.strip_suffix("```"))
397            .unwrap_or(trimmed)
398            .trim()
399    } else {
400        trimmed
401    };
402
403    if let Ok(v) = serde_json::from_str::<T>(stripped) {
404        return Ok(v);
405    }
406
407    // LLMs sometimes add explanatory text around the JSON.
408    // Try to extract the first JSON object from the response.
409    if let Some(start) = stripped.find('{')
410        && let Some(end) = rfind_matching_brace(stripped, start)
411    {
412        let candidate = &stripped[start..=end];
413        if let Ok(v) = serde_json::from_str::<T>(candidate) {
414            return Ok(v);
415        }
416    }
417
418    Err(LlmJudgeError::ParseError(format!(
419        "could not parse LLM response as expected type. Raw response: {raw}"
420    )))
421}
422
423/// Find the closing brace that matches the opening brace at `start`.
424fn rfind_matching_brace(s: &str, start: usize) -> Option<usize> {
425    let mut depth = 0i32;
426    let mut in_string = false;
427    let mut escape_next = false;
428
429    for (i, ch) in s[start..].char_indices() {
430        if escape_next {
431            escape_next = false;
432            continue;
433        }
434        match ch {
435            '\\' if in_string => escape_next = true,
436            '"' => in_string = !in_string,
437            '{' if !in_string => depth += 1,
438            '}' if !in_string => {
439                depth -= 1;
440                if depth == 0 {
441                    return Some(start + i);
442                }
443            }
444            _ => {}
445        }
446    }
447    None
448}
449
450// ---------------------------------------------------------------------------
451// MockLlmJudge for testing
452// ---------------------------------------------------------------------------
453
454/// A mock judge that returns a predetermined response. For testing.
455pub struct MockLlmJudge {
456    response: String,
457}
458
459impl MockLlmJudge {
460    pub fn new(response: impl Into<String>) -> Self {
461        Self {
462            response: response.into(),
463        }
464    }
465}
466
467impl LlmJudge for MockLlmJudge {
468    async fn complete(
469        &self,
470        _system_prompt: &str,
471        _user_prompt: &str,
472    ) -> Result<String, LlmJudgeError> {
473        Ok(self.response.clone())
474    }
475}
476
477/// A mock judge that captures prompts for inspection.
478#[cfg(test)]
479pub struct CapturingJudge {
480    response: String,
481    captured: std::sync::Mutex<Vec<(String, String)>>,
482}
483
484#[cfg(test)]
485impl CapturingJudge {
486    pub fn new(response: impl Into<String>) -> Self {
487        Self {
488            response: response.into(),
489            captured: std::sync::Mutex::new(Vec::new()),
490        }
491    }
492
493    pub fn calls(&self) -> Vec<(String, String)> {
494        self.captured.lock().unwrap().clone()
495    }
496}
497
498#[cfg(test)]
499impl LlmJudge for CapturingJudge {
500    async fn complete(
501        &self,
502        system_prompt: &str,
503        user_prompt: &str,
504    ) -> Result<String, LlmJudgeError> {
505        self.captured
506            .lock()
507            .unwrap()
508            .push((system_prompt.to_string(), user_prompt.to_string()));
509        Ok(self.response.clone())
510    }
511}
512
513// ---------------------------------------------------------------------------
514// Tests
515// ---------------------------------------------------------------------------
516
517#[cfg(test)]
518mod tests {
519    use super::*;
520    use mentedb_core::types::MemoryId;
521
522    fn test_id() -> MemoryId {
523        MemoryId::new()
524    }
525
526    fn mem(content: &str) -> MemorySummary {
527        MemorySummary {
528            id: test_id(),
529            content: content.to_string(),
530            memory_type: MemoryType::Semantic,
531            confidence: 0.9,
532            created_at: 1000,
533        }
534    }
535
536    #[allow(dead_code)]
537    fn mem_at(content: &str, created_at: u64) -> MemorySummary {
538        MemorySummary {
539            id: test_id(),
540            content: content.to_string(),
541            memory_type: MemoryType::Semantic,
542            confidence: 0.9,
543            created_at,
544        }
545    }
546
547    #[tokio::test]
548    async fn test_judge_invalidation_keep() {
549        let judge = MockLlmJudge::new(
550            r#"{"verdict": "keep", "reason": "These describe different people"}"#,
551        );
552        let svc = CognitiveLlmService::new(judge);
553
554        let result = svc
555            .judge_invalidation(&mem("Alice works at Acme"), &mem("Bob works at Google"))
556            .await
557            .unwrap();
558
559        assert!(matches!(result, InvalidationVerdict::Keep { .. }));
560    }
561
562    #[tokio::test]
563    async fn test_judge_invalidation_invalidate() {
564        let judge =
565            MockLlmJudge::new(r#"{"verdict": "invalidate", "reason": "Alice changed jobs"}"#);
566        let svc = CognitiveLlmService::new(judge);
567
568        let result = svc
569            .judge_invalidation(&mem("Alice works at Acme"), &mem("Alice joined Google"))
570            .await
571            .unwrap();
572
573        assert!(matches!(result, InvalidationVerdict::Invalidate { .. }));
574    }
575
576    #[tokio::test]
577    async fn test_judge_invalidation_update() {
578        let judge = MockLlmJudge::new(
579            r#"{"verdict": "update", "merged_content": "Project migrated from React to Vue in Q2", "reason": "Temporal update"}"#,
580        );
581        let svc = CognitiveLlmService::new(judge);
582
583        let result = svc
584            .judge_invalidation(&mem("Project uses React"), &mem("Project migrated to Vue"))
585            .await
586            .unwrap();
587
588        match result {
589            InvalidationVerdict::Update { merged_content, .. } => {
590                assert!(merged_content.contains("React"));
591                assert!(merged_content.contains("Vue"));
592            }
593            other => panic!("Expected Update, got {:?}", other),
594        }
595    }
596
597    #[tokio::test]
598    async fn test_detect_contradiction_compatible() {
599        let judge = MockLlmJudge::new(
600            r#"{"verdict": "compatible", "reason": "Different topics entirely"}"#,
601        );
602        let svc = CognitiveLlmService::new(judge);
603
604        let result = svc
605            .detect_contradiction(&mem("Likes Python"), &mem("Uses PostgreSQL"))
606            .await
607            .unwrap();
608
609        assert!(matches!(result, ContradictionVerdict::Compatible { .. }));
610    }
611
612    #[tokio::test]
613    async fn test_detect_contradiction_contradicts() {
614        let judge =
615            MockLlmJudge::new(r#"{"verdict": "contradicts", "reason": "Cannot prefer both"}"#);
616        let svc = CognitiveLlmService::new(judge);
617
618        let result = svc
619            .detect_contradiction(&mem("Prefers tabs"), &mem("Prefers spaces"))
620            .await
621            .unwrap();
622
623        assert!(matches!(result, ContradictionVerdict::Contradicts { .. }));
624    }
625
626    #[tokio::test]
627    async fn test_detect_contradiction_supersedes() {
628        let id_b = test_id();
629        let response = format!(
630            r#"{{"verdict": "supersedes", "superseding_id": "{}", "reason": "B is newer"}}"#,
631            id_b
632        );
633        let judge = MockLlmJudge::new(response);
634        let svc = CognitiveLlmService::new(judge);
635
636        let mem_b = MemorySummary {
637            id: id_b,
638            content: "Migrated to Vue".into(),
639            memory_type: MemoryType::Semantic,
640            confidence: 0.9,
641            created_at: 2000,
642        };
643
644        let result = svc
645            .detect_contradiction(&mem("Uses React"), &mem_b)
646            .await
647            .unwrap();
648
649        assert!(matches!(result, ContradictionVerdict::Supersedes { .. }));
650    }
651
652    #[tokio::test]
653    async fn test_resolve_entities() {
654        let judge = MockLlmJudge::new(
655            r#"{"groups": [{"canonical": "Alice Smith", "aliases": ["Alice", "my manager"], "confidence": 0.92}]}"#,
656        );
657        let svc = CognitiveLlmService::new(judge);
658
659        let candidates = vec![
660            EntityCandidate {
661                name: "Alice".into(),
662                context: None,
663                memory_id: None,
664            },
665            EntityCandidate {
666                name: "my manager".into(),
667                context: Some("my manager Alice told me".into()),
668                memory_id: None,
669            },
670            EntityCandidate {
671                name: "Alice Smith".into(),
672                context: None,
673                memory_id: None,
674            },
675        ];
676
677        let groups = svc.resolve_entities(&candidates).await.unwrap();
678        assert_eq!(groups.len(), 1);
679        assert_eq!(groups[0].canonical, "Alice Smith");
680        assert_eq!(groups[0].aliases.len(), 2);
681    }
682
683    #[tokio::test]
684    async fn test_resolve_entities_empty() {
685        let judge = MockLlmJudge::new("should not be called");
686        let svc = CognitiveLlmService::new(judge);
687
688        let groups = svc.resolve_entities(&[]).await.unwrap();
689        assert!(groups.is_empty());
690    }
691
692    #[tokio::test]
693    async fn test_consolidate_merge() {
694        let judge = MockLlmJudge::new(
695            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"}"#,
696        );
697        let svc = CognitiveLlmService::new(judge);
698
699        let cluster = vec![
700            ClusterMember {
701                id: "a".into(),
702                content: "Uses Rust".into(),
703                memory_type: "Semantic".into(),
704                confidence: 0.9,
705                created_at: 1000,
706            },
707            ClusterMember {
708                id: "b".into(),
709                content: "Prefers Rust for safety".into(),
710                memory_type: "Semantic".into(),
711                confidence: 0.85,
712                created_at: 2000,
713            },
714        ];
715
716        let decision = svc.consolidate(&cluster).await.unwrap();
717        match decision {
718            ConsolidationDecision::Merge {
719                merged_content,
720                remove_ids,
721                ..
722            } => {
723                assert!(merged_content.contains("Rust"));
724                assert_eq!(remove_ids.len(), 2);
725            }
726            other => panic!("Expected Merge, got {:?}", other),
727        }
728    }
729
730    #[tokio::test]
731    async fn test_consolidate_empty_cluster() {
732        let judge = MockLlmJudge::new("should not be called");
733        let svc = CognitiveLlmService::new(judge);
734
735        let decision = svc.consolidate(&[]).await.unwrap();
736        assert!(matches!(decision, ConsolidationDecision::KeepAll { .. }));
737    }
738
739    #[tokio::test]
740    async fn test_canonicalize_topic_existing() {
741        let judge = MockLlmJudge::new(r#"{"topic": "authentication", "is_new": false}"#);
742        let svc = CognitiveLlmService::new(judge);
743
744        let label = svc
745            .canonicalize_topic(
746                "how do I configure the auth middleware",
747                &[
748                    "database".into(),
749                    "authentication".into(),
750                    "deployment".into(),
751                ],
752            )
753            .await
754            .unwrap();
755
756        assert_eq!(label.topic, "authentication");
757        assert!(!label.is_new);
758    }
759
760    #[tokio::test]
761    async fn test_canonicalize_topic_new() {
762        let judge = MockLlmJudge::new(r#"{"topic": "caching", "is_new": true}"#);
763        let svc = CognitiveLlmService::new(judge);
764
765        let label = svc
766            .canonicalize_topic(
767                "should we add Redis for caching",
768                &["database".into(), "authentication".into()],
769            )
770            .await
771            .unwrap();
772
773        assert_eq!(label.topic, "caching");
774        assert!(label.is_new);
775    }
776
777    #[tokio::test]
778    async fn test_json_markdown_code_block_stripping() {
779        let judge = MockLlmJudge::new(
780            "```json\n{\"verdict\": \"keep\", \"reason\": \"wrapped in markdown\"}\n```",
781        );
782        let svc = CognitiveLlmService::new(judge);
783
784        let result = svc.judge_invalidation(&mem("A"), &mem("B")).await.unwrap();
785
786        assert!(matches!(result, InvalidationVerdict::Keep { .. }));
787    }
788
789    #[tokio::test]
790    async fn test_json_surrounded_by_text() {
791        let judge = MockLlmJudge::new(
792            "Here is my analysis:\n{\"verdict\": \"invalidate\", \"reason\": \"job changed\"}\nLet me know if you need more detail.",
793        );
794        let svc = CognitiveLlmService::new(judge);
795
796        let result = svc.judge_invalidation(&mem("A"), &mem("B")).await.unwrap();
797
798        assert!(matches!(result, InvalidationVerdict::Invalidate { .. }));
799    }
800
801    #[tokio::test]
802    async fn test_json_with_nested_braces_in_strings() {
803        let judge = MockLlmJudge::new(
804            "Sure! {\"verdict\": \"update\", \"merged_content\": \"uses {curly} braces\", \"reason\": \"test\"}",
805        );
806        let svc = CognitiveLlmService::new(judge);
807
808        let result = svc.judge_invalidation(&mem("A"), &mem("B")).await.unwrap();
809
810        match result {
811            InvalidationVerdict::Update { merged_content, .. } => {
812                assert!(merged_content.contains("{curly}"))
813            }
814            other => panic!("Expected Update, got {:?}", other),
815        }
816    }
817
818    #[tokio::test]
819    async fn test_provider_error_propagates() {
820        struct FailingJudge;
821        impl LlmJudge for FailingJudge {
822            async fn complete(&self, _: &str, _: &str) -> Result<String, LlmJudgeError> {
823                Err(LlmJudgeError::ProviderError("connection refused".into()))
824            }
825        }
826
827        let svc = CognitiveLlmService::new(FailingJudge);
828        let result = svc.judge_invalidation(&mem("A"), &mem("B")).await;
829
830        assert!(result.is_err());
831        assert!(
832            result
833                .unwrap_err()
834                .to_string()
835                .contains("connection refused")
836        );
837    }
838
839    #[tokio::test]
840    async fn test_prompt_includes_memory_content() {
841        let judge = std::sync::Arc::new(CapturingJudge::new(
842            r#"{"verdict": "keep", "reason": "test"}"#,
843        ));
844        let svc = CognitiveLlmService::new(judge.clone());
845
846        let _ = svc
847            .judge_invalidation(&mem("Alice works at Acme"), &mem("Alice joined Google"))
848            .await;
849
850        let calls = judge.calls();
851        assert_eq!(calls.len(), 1);
852        let (system, user) = &calls[0];
853        assert!(system.contains("memory validity judge"));
854        assert!(user.contains("Alice works at Acme"));
855        assert!(user.contains("Alice joined Google"));
856    }
857}