Skip to main content

mentedb_cognitive/
trajectory.rs

1use crate::llm::{CognitiveLlmService, LlmJudge};
2use mentedb_core::types::Timestamp;
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5use std::io;
6use std::path::Path;
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub enum DecisionState {
10    Investigating,
11    NarrowedTo(String),
12    Decided(String),
13    Interrupted,
14    Completed,
15}
16
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct TrajectoryNode {
19    pub turn_id: u64,
20    pub topic_embedding: Vec<f32>,
21    pub topic_summary: String,
22    pub decision_state: DecisionState,
23    pub open_questions: Vec<String>,
24    pub timestamp: Timestamp,
25}
26
27const MAX_TURNS_DEFAULT: usize = 100;
28const REINFORCEMENT_BONUS: u32 = 2;
29
30/// Basic topic normalization: lowercase, collapse whitespace, trim.
31/// This handles the easy cases (casing, extra spaces) without attempting
32/// semantic canonicalization (tracked in #22).
33fn normalize_topic(raw: &str) -> String {
34    raw.split_whitespace()
35        .map(|w| w.to_lowercase())
36        .collect::<Vec<_>>()
37        .join(" ")
38}
39
40/// Tracks topic transitions as a Markov chain. Maps
41/// from_topic -> (to_topic -> frequency_count).
42///
43/// Also maintains a learned topic cache that maps raw user messages
44/// to canonical topic labels, so repeated patterns skip the LLM.
45#[derive(Debug, Clone, Default, Serialize, Deserialize)]
46pub struct TransitionMap {
47    transitions: HashMap<String, HashMap<String, u32>>,
48    /// Learned mapping from raw topic strings to canonical labels.
49    /// Built up over time via LLM canonicalization calls.
50    #[serde(default)]
51    topic_cache: HashMap<String, String>,
52}
53
54#[derive(Serialize, Deserialize)]
55struct TransitionSnapshot {
56    version: u32,
57    transitions: HashMap<String, HashMap<String, u32>>,
58    #[serde(default)]
59    topic_cache: HashMap<String, String>,
60}
61
62const TRANSITION_SNAPSHOT_VERSION: u32 = 2;
63
64impl TransitionMap {
65    /// Record a transition, applying the topic cache if available.
66    /// If a canonical label exists for a raw topic, uses that instead.
67    pub fn record(&mut self, from: &str, to: &str) {
68        let from = self.resolve_topic(from);
69        let to = self.resolve_topic(to);
70        *self
71            .transitions
72            .entry(from)
73            .or_default()
74            .entry(to)
75            .or_insert(0) += 1;
76    }
77
78    pub fn reinforce(&mut self, from: &str, to: &str) {
79        let from = self.resolve_topic(from);
80        let to = self.resolve_topic(to);
81        *self
82            .transitions
83            .entry(from)
84            .or_default()
85            .entry(to)
86            .or_insert(0) += REINFORCEMENT_BONUS;
87    }
88
89    pub fn decay(&mut self, from: &str, to: &str) {
90        let from = self.resolve_topic(from);
91        let to = self.resolve_topic(to);
92        if let Some(targets) = self.transitions.get_mut(&from) {
93            if let Some(count) = targets.get_mut(&to) {
94                *count = count.saturating_sub(1);
95                if *count == 0 {
96                    targets.remove(&to);
97                }
98            }
99            if targets.is_empty() {
100                self.transitions.remove(&from);
101            }
102        }
103    }
104
105    /// Returns the top N predicted topics from a given topic,
106    /// sorted by frequency descending.
107    pub fn predict_from(&self, topic: &str, limit: usize) -> Vec<(String, u32)> {
108        let topic = self.resolve_topic(topic);
109        let Some(targets) = self.transitions.get(&topic) else {
110            return Vec::new();
111        };
112        let mut ranked: Vec<(String, u32)> = targets.iter().map(|(t, &c)| (t.clone(), c)).collect();
113        ranked.sort_by_key(|x| std::cmp::Reverse(x.1));
114        ranked.truncate(limit);
115        ranked
116    }
117
118    /// Resolve a raw topic to its canonical label.
119    /// Checks the learned cache first, falls back to normalize_topic().
120    fn resolve_topic(&self, raw: &str) -> String {
121        let normalized = normalize_topic(raw);
122        self.topic_cache
123            .get(&normalized)
124            .cloned()
125            .unwrap_or(normalized)
126    }
127
128    /// Store a learned mapping from a raw topic to its canonical label.
129    pub fn learn_canonical(&mut self, raw: &str, canonical: &str) {
130        let normalized = normalize_topic(raw);
131        let canonical = normalize_topic(canonical);
132        if normalized != canonical {
133            self.topic_cache.insert(normalized, canonical);
134        }
135    }
136
137    /// Look up a cached canonical label for a raw topic.
138    pub fn get_canonical(&self, raw: &str) -> Option<&String> {
139        let normalized = normalize_topic(raw);
140        self.topic_cache.get(&normalized)
141    }
142
143    /// Returns the list of known canonical topic labels (deduped).
144    pub fn known_topics(&self) -> Vec<String> {
145        let mut topics: Vec<String> = self.topic_cache.values().cloned().collect();
146        // Also include transition keys and targets that aren't in the cache
147        for (key, targets) in &self.transitions {
148            if !topics.contains(key) {
149                topics.push(key.clone());
150            }
151            for target in targets.keys() {
152                if !topics.contains(target) {
153                    topics.push(target.clone());
154                }
155            }
156        }
157        topics.sort();
158        topics.dedup();
159        topics
160    }
161
162    pub fn topic_cache_size(&self) -> usize {
163        self.topic_cache.len()
164    }
165
166    pub fn is_empty(&self) -> bool {
167        self.transitions.is_empty()
168    }
169
170    pub fn total_transitions(&self) -> usize {
171        self.transitions.values().map(|t| t.len()).sum()
172    }
173
174    /// Save the transition map to a JSON file. Prunes transitions with
175    /// count below `min_count` to keep the file from growing unbounded.
176    /// Uses atomic write (temp file + rename) to avoid corruption.
177    pub fn save(&self, path: &Path, min_count: u32) -> io::Result<()> {
178        let pruned: HashMap<String, HashMap<String, u32>> = self
179            .transitions
180            .iter()
181            .filter_map(|(from, targets)| {
182                let kept: HashMap<String, u32> = targets
183                    .iter()
184                    .filter(|(_, c)| **c >= min_count)
185                    .map(|(t, &c)| (t.clone(), c))
186                    .collect();
187                if kept.is_empty() {
188                    None
189                } else {
190                    Some((from.clone(), kept))
191                }
192            })
193            .collect();
194        let snapshot = TransitionSnapshot {
195            version: TRANSITION_SNAPSHOT_VERSION,
196            transitions: pruned,
197            topic_cache: self.topic_cache.clone(),
198        };
199        let json = serde_json::to_string(&snapshot)
200            .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
201        let tmp = path.with_extension("tmp");
202        std::fs::write(&tmp, json)?;
203        std::fs::rename(&tmp, path)
204    }
205
206    /// Load a transition map from a JSON file, merging counts into the
207    /// current map so that patterns accumulate across sessions.
208    /// Supports both v1 (no topic_cache) and v2 (with topic_cache) formats.
209    pub fn load(&mut self, path: &Path) -> io::Result<()> {
210        let json = std::fs::read_to_string(path)?;
211        let snapshot: TransitionSnapshot = serde_json::from_str(&json)
212            .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
213
214        if snapshot.version > TRANSITION_SNAPSHOT_VERSION {
215            return Err(io::Error::new(
216                io::ErrorKind::InvalidData,
217                format!(
218                    "unsupported transition snapshot version: {} (expected <= {})",
219                    snapshot.version, TRANSITION_SNAPSHOT_VERSION
220                ),
221            ));
222        }
223
224        for (from, targets) in snapshot.transitions {
225            let entry = self.transitions.entry(from).or_default();
226            for (to, count) in targets {
227                *entry.entry(to).or_insert(0) += count;
228            }
229        }
230
231        // Merge topic cache (loaded values don't overwrite existing)
232        for (raw, canonical) in snapshot.topic_cache {
233            self.topic_cache.entry(raw).or_insert(canonical);
234        }
235
236        Ok(())
237    }
238}
239
240pub struct TrajectoryTracker {
241    trajectory: Vec<TrajectoryNode>,
242    max_turns: usize,
243    pub transitions: TransitionMap,
244}
245
246impl TrajectoryTracker {
247    pub fn new(max_turns: usize) -> Self {
248        Self {
249            trajectory: Vec::new(),
250            max_turns,
251            transitions: TransitionMap::default(),
252        }
253    }
254
255    pub fn record_turn(&mut self, turn: TrajectoryNode) {
256        if let Some(prev) = self.trajectory.last() {
257            self.transitions
258                .record(&prev.topic_summary, &turn.topic_summary);
259        }
260
261        if self.trajectory.len() >= self.max_turns {
262            self.trajectory.remove(0);
263        }
264        self.trajectory.push(turn);
265    }
266
267    /// Record a turn with LLM-powered topic canonicalization.
268    ///
269    /// Checks the learned topic cache first (no LLM call needed for known patterns).
270    /// On cache miss, asks the LLM for a canonical label and stores it for future use.
271    /// Falls back to the heuristic path on any LLM error.
272    pub async fn record_turn_with_llm<J: LlmJudge>(
273        &mut self,
274        mut turn: TrajectoryNode,
275        llm: &CognitiveLlmService<J>,
276    ) {
277        // Canonicalize the incoming topic
278        let canonical = self.canonicalize_topic(&turn.topic_summary, llm).await;
279        turn.topic_summary = canonical;
280
281        if let Some(prev) = self.trajectory.last() {
282            self.transitions
283                .record(&prev.topic_summary, &turn.topic_summary);
284        }
285
286        if self.trajectory.len() >= self.max_turns {
287            self.trajectory.remove(0);
288        }
289        self.trajectory.push(turn);
290    }
291
292    /// Resolve a topic to its canonical label, using cache or LLM.
293    async fn canonicalize_topic<J: LlmJudge>(
294        &mut self,
295        raw: &str,
296        llm: &CognitiveLlmService<J>,
297    ) -> String {
298        // Tier 3: Check learned cache first (free, no LLM call)
299        if let Some(cached) = self.transitions.get_canonical(raw) {
300            return cached.clone();
301        }
302
303        // Tier 2: Ask the LLM
304        let existing = self.transitions.known_topics();
305        match llm.canonicalize_topic(raw, &existing).await {
306            Ok(label) => {
307                self.transitions.learn_canonical(raw, &label.topic);
308                normalize_topic(&label.topic)
309            }
310            Err(_) => {
311                // Tier 1: Fallback to normalize + exact match
312                normalize_topic(raw)
313            }
314        }
315    }
316
317    pub fn get_trajectory(&self) -> &[TrajectoryNode] {
318        &self.trajectory
319    }
320
321    pub fn get_resume_context(&self) -> Option<String> {
322        if self.trajectory.is_empty() {
323            return None;
324        }
325
326        let mut parts = Vec::new();
327
328        // Find the last non-completed topic
329        if let Some(last) = self.trajectory.last() {
330            parts.push(format!("You were working on: {}", last.topic_summary));
331
332            match &last.decision_state {
333                DecisionState::Investigating => {
334                    parts.push("Status: Still investigating.".to_string());
335                }
336                DecisionState::NarrowedTo(choice) => {
337                    parts.push(format!("You narrowed down to: {}", choice));
338                }
339                DecisionState::Decided(decision) => {
340                    parts.push(format!("You decided on: {}", decision));
341                }
342                DecisionState::Interrupted => {
343                    parts.push("Status: Was interrupted before completion.".to_string());
344                }
345                DecisionState::Completed => {
346                    parts.push("Status: Completed.".to_string());
347                }
348            }
349
350            if !last.open_questions.is_empty() {
351                let qs: Vec<String> = last
352                    .open_questions
353                    .iter()
354                    .map(|q| format!("- {}", q))
355                    .collect();
356                parts.push(format!("Open questions:\n{}", qs.join("\n")));
357            }
358        }
359
360        // Add recent trajectory summary
361        if self.trajectory.len() > 1 {
362            let recent: Vec<String> = self
363                .trajectory
364                .iter()
365                .rev()
366                .skip(1)
367                .take(3)
368                .rev()
369                .map(|t| t.topic_summary.clone())
370                .collect();
371            parts.push(format!("Recent trajectory: {}", recent.join(" → ")));
372        }
373
374        Some(parts.join(" "))
375    }
376
377    pub fn predict_next_topics(&self) -> Vec<String> {
378        let mut predictions = Vec::new();
379        let mut seen = ahash::AHashSet::new();
380
381        let Some(last) = self.trajectory.last() else {
382            return predictions;
383        };
384
385        // Learned transitions are the strongest signal
386        let learned = self.transitions.predict_from(&last.topic_summary, 3);
387        for (topic, _count) in &learned {
388            if seen.insert(topic.clone()) {
389                predictions.push(topic.clone());
390            }
391        }
392
393        // Open questions fill remaining slots
394        for q in &last.open_questions {
395            if predictions.len() >= 3 {
396                break;
397            }
398            if seen.insert(q.clone()) {
399                predictions.push(q.clone());
400            }
401        }
402
403        // Continuation of current topic
404        if predictions.len() < 3 {
405            let cont = format!("{} (continued)", last.topic_summary);
406            if seen.insert(cont.clone()) {
407                predictions.push(cont);
408            }
409        }
410
411        // Revisit previous topic
412        if predictions.len() < 3 && self.trajectory.len() >= 2 {
413            let prev = &self.trajectory[self.trajectory.len() - 2];
414            let rev = format!("{} (revisit)", prev.topic_summary);
415            if seen.insert(rev.clone()) {
416                predictions.push(rev);
417            }
418        }
419
420        predictions.truncate(3);
421        predictions
422    }
423
424    /// Called when the speculative cache gets a hit. Reinforces the
425    /// transition from the previous topic to the hit topic.
426    pub fn reinforce_transition(&mut self, hit_topic: &str) {
427        if let Some(last) = self.trajectory.last() {
428            self.transitions.reinforce(&last.topic_summary, hit_topic);
429        }
430    }
431
432    /// Called when the speculative cache misses. Slightly decays the
433    /// transition from the previous topic to the predicted topic.
434    pub fn decay_transition(&mut self, predicted_topic: &str) {
435        if let Some(last) = self.trajectory.last() {
436            self.transitions.decay(&last.topic_summary, predicted_topic);
437        }
438    }
439}
440
441impl Default for TrajectoryTracker {
442    fn default() -> Self {
443        Self::new(MAX_TURNS_DEFAULT)
444    }
445}
446
447#[cfg(test)]
448mod tests {
449    use super::*;
450
451    fn make_turn(
452        id: u64,
453        summary: &str,
454        state: DecisionState,
455        questions: Vec<&str>,
456    ) -> TrajectoryNode {
457        TrajectoryNode {
458            turn_id: id,
459            topic_embedding: vec![0.0; 4],
460            topic_summary: summary.to_string(),
461            decision_state: state,
462            open_questions: questions.into_iter().map(String::from).collect(),
463            timestamp: id * 1000,
464        }
465    }
466
467    #[test]
468    fn test_record_and_resume() {
469        let mut tracker = TrajectoryTracker::default();
470        tracker.record_turn(make_turn(
471            1,
472            "JWT auth design",
473            DecisionState::Investigating,
474            vec![],
475        ));
476        tracker.record_turn(make_turn(
477            2,
478            "Token refresh strategy",
479            DecisionState::Decided("short-lived access tokens (15min)".into()),
480            vec!["Where to store refresh tokens?"],
481        ));
482
483        let ctx = tracker.get_resume_context().unwrap();
484        assert!(ctx.contains("Token refresh strategy"));
485        assert!(ctx.contains("short-lived access tokens"));
486        assert!(ctx.contains("refresh tokens"));
487    }
488
489    #[test]
490    fn test_predict_topics() {
491        let mut tracker = TrajectoryTracker::default();
492        tracker.record_turn(make_turn(
493            1,
494            "Database schema",
495            DecisionState::Decided("normalized".into()),
496            vec!["How to handle migrations?", "Index strategy?"],
497        ));
498
499        let preds = tracker.predict_next_topics();
500        assert!(!preds.is_empty());
501        assert!(preds.iter().any(|p| p.contains("migrations")));
502    }
503
504    #[test]
505    fn test_fifo_eviction() {
506        let mut tracker = TrajectoryTracker::default();
507        for i in 0..105 {
508            tracker.record_turn(make_turn(
509                i,
510                &format!("turn {}", i),
511                DecisionState::Investigating,
512                vec![],
513            ));
514        }
515        assert_eq!(tracker.get_trajectory().len(), MAX_TURNS_DEFAULT);
516        assert_eq!(tracker.get_trajectory()[0].turn_id, 5);
517    }
518
519    #[test]
520    fn test_transition_recording() {
521        let mut tracker = TrajectoryTracker::default();
522        tracker.record_turn(make_turn(1, "auth", DecisionState::Investigating, vec![]));
523        tracker.record_turn(make_turn(
524            2,
525            "database",
526            DecisionState::Investigating,
527            vec![],
528        ));
529        tracker.record_turn(make_turn(3, "auth", DecisionState::Investigating, vec![]));
530        tracker.record_turn(make_turn(
531            4,
532            "database",
533            DecisionState::Investigating,
534            vec![],
535        ));
536        tracker.record_turn(make_turn(5, "auth", DecisionState::Investigating, vec![]));
537        tracker.record_turn(make_turn(
538            6,
539            "deployment",
540            DecisionState::Investigating,
541            vec![],
542        ));
543
544        // auth -> database happened twice, auth -> deployment once
545        let preds = tracker.transitions.predict_from("auth", 5);
546        assert_eq!(preds.len(), 2);
547        assert_eq!(preds[0].0, "database");
548        assert_eq!(preds[0].1, 2);
549        assert_eq!(preds[1].0, "deployment");
550        assert_eq!(preds[1].1, 1);
551    }
552
553    #[test]
554    fn test_learned_predictions_take_priority() {
555        let mut tracker = TrajectoryTracker::default();
556
557        // Build a pattern: auth -> database (3 times)
558        for _ in 0..3 {
559            tracker.record_turn(make_turn(0, "auth", DecisionState::Investigating, vec![]));
560            tracker.record_turn(make_turn(
561                0,
562                "database",
563                DecisionState::Investigating,
564                vec![],
565            ));
566        }
567
568        // Now land on auth with an open question
569        tracker.record_turn(make_turn(
570            0,
571            "auth",
572            DecisionState::Investigating,
573            vec!["how to handle JWT expiry?"],
574        ));
575
576        let preds = tracker.predict_next_topics();
577        // Learned transition "database" should come first
578        assert_eq!(preds[0], "database");
579    }
580
581    #[test]
582    fn test_reinforce_and_decay() {
583        let mut map = TransitionMap::default();
584        map.record("auth", "database");
585        map.record("auth", "database");
586        assert_eq!(map.predict_from("auth", 1)[0].1, 2);
587
588        // Reinforce adds bonus
589        map.reinforce("auth", "database");
590        assert_eq!(map.predict_from("auth", 1)[0].1, 4);
591
592        // Decay subtracts 1
593        map.decay("auth", "database");
594        assert_eq!(map.predict_from("auth", 1)[0].1, 3);
595    }
596
597    #[test]
598    fn test_decay_removes_zero_entries() {
599        let mut map = TransitionMap::default();
600        map.record("auth", "database");
601        assert_eq!(map.total_transitions(), 1);
602
603        map.decay("auth", "database");
604        assert!(map.is_empty());
605    }
606
607    #[test]
608    fn test_reinforce_via_tracker() {
609        let mut tracker = TrajectoryTracker::default();
610        tracker.record_turn(make_turn(1, "auth", DecisionState::Investigating, vec![]));
611        tracker.record_turn(make_turn(
612            2,
613            "database",
614            DecisionState::Investigating,
615            vec![],
616        ));
617
618        // One natural transition recorded
619        assert_eq!(tracker.transitions.predict_from("auth", 1)[0].1, 1);
620
621        // Simulate cache hit reinforcement
622        tracker.reinforce_transition("database");
623        assert_eq!(
624            tracker.transitions.predict_from("database", 1)[0].1,
625            REINFORCEMENT_BONUS
626        );
627    }
628
629    #[test]
630    fn test_no_duplicate_predictions() {
631        let mut tracker = TrajectoryTracker::default();
632
633        // Build pattern: auth -> database
634        tracker.record_turn(make_turn(1, "auth", DecisionState::Investigating, vec![]));
635        tracker.record_turn(make_turn(
636            2,
637            "database",
638            DecisionState::Investigating,
639            vec![],
640        ));
641
642        // Land on auth with "database" as an open question too
643        tracker.record_turn(make_turn(
644            3,
645            "auth",
646            DecisionState::Investigating,
647            vec!["database"],
648        ));
649
650        let preds = tracker.predict_next_topics();
651        let unique: ahash::AHashSet<&String> = preds.iter().collect();
652        assert_eq!(preds.len(), unique.len(), "predictions should be unique");
653    }
654
655    #[test]
656    fn test_normalization_collapses_variants() {
657        let mut map = TransitionMap::default();
658        map.record("Auth Setup", "database");
659        map.record("auth setup", "DATABASE");
660        map.record("  auth   setup  ", "  database  ");
661
662        // All three should collapse into one transition with count 3
663        let preds = map.predict_from("AUTH SETUP", 1);
664        assert_eq!(preds.len(), 1);
665        assert_eq!(preds[0].0, "database");
666        assert_eq!(preds[0].1, 3);
667    }
668
669    #[test]
670    fn test_transition_map_save_and_load() {
671        let dir = tempfile::tempdir().unwrap();
672        let path = dir.path().join("transitions.json");
673
674        let mut map = TransitionMap::default();
675        map.record("auth", "database");
676        map.record("auth", "database");
677        map.record("auth", "deploy");
678        map.save(&path, 1).unwrap();
679
680        // Load into a fresh map — counts should carry over
681        let mut loaded = TransitionMap::default();
682        loaded.load(&path).unwrap();
683        let preds = loaded.predict_from("auth", 5);
684        assert_eq!(preds[0].0, "database");
685        assert_eq!(preds[0].1, 2);
686        // deploy has count 1, should be saved with min_count=1
687        assert_eq!(preds[1].0, "deploy");
688        assert_eq!(preds[1].1, 1);
689    }
690
691    #[test]
692    fn test_transition_map_save_prunes_low_counts() {
693        let dir = tempfile::tempdir().unwrap();
694        let path = dir.path().join("transitions.json");
695
696        let mut map = TransitionMap::default();
697        map.record("auth", "database");
698        map.record("auth", "database");
699        map.record("auth", "deploy"); // count 1
700        map.save(&path, 2).unwrap(); // only keep count >= 2
701
702        let mut loaded = TransitionMap::default();
703        loaded.load(&path).unwrap();
704        let preds = loaded.predict_from("auth", 5);
705        assert_eq!(preds.len(), 1);
706        assert_eq!(preds[0].0, "database");
707        assert_eq!(preds[0].1, 2);
708    }
709
710    #[test]
711    fn test_transition_map_load_merges() {
712        let dir = tempfile::tempdir().unwrap();
713        let path = dir.path().join("transitions.json");
714
715        let mut map = TransitionMap::default();
716        map.record("auth", "database");
717        map.save(&path, 1).unwrap();
718
719        // Load into a map that already has data — counts should add
720        let mut existing = TransitionMap::default();
721        existing.record("auth", "database");
722        existing.record("auth", "testing");
723        existing.load(&path).unwrap();
724
725        let preds = existing.predict_from("auth", 5);
726        // database: 1 existing + 1 loaded = 2
727        assert_eq!(preds[0].0, "database");
728        assert_eq!(preds[0].1, 2);
729        // testing: 1 existing only
730        assert_eq!(preds[1].0, "testing");
731        assert_eq!(preds[1].1, 1);
732    }
733
734    #[test]
735    fn test_topic_cache_learn_and_resolve() {
736        let mut map = TransitionMap::default();
737
738        // Without cache, "auth setup" and "configure authentication" are different keys
739        map.record("auth setup", "database");
740        map.record("configure authentication", "database");
741        // Two separate entries
742        assert_eq!(map.predict_from("auth setup", 1)[0].1, 1);
743
744        // Teach the cache
745        map.learn_canonical("auth setup", "authentication");
746        map.learn_canonical("configure authentication", "authentication");
747
748        // Now both resolve to "authentication"
749        map.record("auth setup", "database");
750        map.record("configure authentication", "database");
751        let preds = map.predict_from("authentication", 1);
752        assert_eq!(preds[0].0, "database");
753        assert_eq!(preds[0].1, 2);
754    }
755
756    #[test]
757    fn test_topic_cache_persists_across_save_load() {
758        let dir = tempfile::tempdir().unwrap();
759        let path = dir.path().join("transitions.json");
760
761        let mut map = TransitionMap::default();
762        map.learn_canonical("auth setup", "authentication");
763        map.learn_canonical("db design", "database");
764        map.record("auth setup", "db design");
765        map.save(&path, 1).unwrap();
766
767        let mut loaded = TransitionMap::default();
768        loaded.load(&path).unwrap();
769
770        // Cache should survive the roundtrip
771        assert_eq!(
772            loaded.get_canonical("auth setup"),
773            Some(&"authentication".to_string())
774        );
775        assert_eq!(
776            loaded.get_canonical("db design"),
777            Some(&"database".to_string())
778        );
779        // Transition should use canonical keys
780        let preds = loaded.predict_from("authentication", 1);
781        assert_eq!(preds[0].0, "database");
782    }
783
784    #[test]
785    fn test_known_topics_includes_cache_and_transitions() {
786        let mut map = TransitionMap::default();
787        map.learn_canonical("auth setup", "authentication");
788        map.record("deployment", "testing");
789
790        let topics = map.known_topics();
791        assert!(topics.contains(&"authentication".to_string()));
792        assert!(topics.contains(&"deployment".to_string()));
793        assert!(topics.contains(&"testing".to_string()));
794    }
795
796    #[test]
797    fn test_v1_snapshot_loads_without_topic_cache() {
798        let dir = tempfile::tempdir().unwrap();
799        let path = dir.path().join("transitions.json");
800
801        // Write a v1 snapshot manually (no topic_cache field)
802        let v1_json = r#"{"version":1,"transitions":{"auth":{"database":3}}}"#;
803        std::fs::write(&path, v1_json).unwrap();
804
805        let mut map = TransitionMap::default();
806        map.load(&path).unwrap();
807
808        // Transitions loaded, cache empty
809        let preds = map.predict_from("auth", 1);
810        assert_eq!(preds[0].0, "database");
811        assert_eq!(preds[0].1, 3);
812        assert_eq!(map.topic_cache_size(), 0);
813    }
814
815    use crate::llm::{CognitiveLlmService, MockLlmJudge};
816
817    #[tokio::test]
818    async fn test_record_turn_with_llm_canonicalizes() {
819        let judge = MockLlmJudge::new(r#"{"topic": "authentication", "is_new": false}"#);
820        let llm = CognitiveLlmService::new(judge);
821        let mut tracker = TrajectoryTracker::default();
822
823        tracker
824            .record_turn_with_llm(
825                make_turn(
826                    1,
827                    "auth setup question",
828                    DecisionState::Investigating,
829                    vec![],
830                ),
831                &llm,
832            )
833            .await;
834
835        // Topic should be canonicalized to "authentication"
836        assert_eq!(tracker.get_trajectory()[0].topic_summary, "authentication");
837        // Cache should have the learned mapping
838        assert_eq!(
839            tracker.transitions.get_canonical("auth setup question"),
840            Some(&"authentication".to_string())
841        );
842    }
843
844    #[tokio::test]
845    async fn test_record_turn_with_llm_uses_cache_on_repeat() {
846        let judge = MockLlmJudge::new(r#"{"topic": "authentication", "is_new": false}"#);
847        let llm = CognitiveLlmService::new(judge);
848        let mut tracker = TrajectoryTracker::default();
849
850        // First call learns the mapping
851        tracker
852            .record_turn_with_llm(
853                make_turn(1, "auth setup", DecisionState::Investigating, vec![]),
854                &llm,
855            )
856            .await;
857
858        // Pre-teach a different canonical for the next topic
859        // to prove the cache is being used (not the LLM)
860        tracker
861            .transitions
862            .learn_canonical("configure auth", "authentication");
863
864        // This should use cache, NOT the MockLlmJudge
865        // (MockLlmJudge always returns "authentication" anyway, but the point is
866        // the cache path is exercised — no LLM call needed)
867        tracker
868            .record_turn_with_llm(
869                make_turn(2, "configure auth", DecisionState::Investigating, vec![]),
870                &llm,
871            )
872            .await;
873
874        assert_eq!(tracker.get_trajectory()[1].topic_summary, "authentication");
875
876        // Both turns should contribute to the same transition key
877        // Turn 1: "authentication" recorded (no prev)
878        // Turn 2: "authentication" -> "authentication" (same topic)
879        assert_eq!(tracker.get_trajectory().len(), 2);
880    }
881
882    #[tokio::test]
883    async fn test_record_turn_with_llm_transitions_accumulate() {
884        let judge = MockLlmJudge::new(r#"{"topic": "authentication", "is_new": false}"#);
885        let llm = CognitiveLlmService::new(judge);
886        let mut tracker = TrajectoryTracker::default();
887
888        // Record first turn (no transition yet)
889        tracker
890            .record_turn_with_llm(
891                make_turn(1, "auth setup", DecisionState::Investigating, vec![]),
892                &llm,
893            )
894            .await;
895
896        // Now record with a different mock response for "database"
897        let judge2 = MockLlmJudge::new(r#"{"topic": "database", "is_new": false}"#);
898        let llm2 = CognitiveLlmService::new(judge2);
899        tracker
900            .record_turn_with_llm(
901                make_turn(2, "db schema design", DecisionState::Investigating, vec![]),
902                &llm2,
903            )
904            .await;
905
906        // Should have transition: authentication -> database
907        let preds = tracker.transitions.predict_from("authentication", 3);
908        assert_eq!(preds.len(), 1);
909        assert_eq!(preds[0].0, "database");
910        assert_eq!(preds[0].1, 1);
911    }
912}