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, mut turn: TrajectoryNode) {
256        // Collapse to the learned canonical label when one exists (populated off
257        // the hot path by canonicalize_trajectory_topics), so recurring
258        // phrasings converge to one node instead of one-per-message. Unknown
259        // topics keep their original text (casing preserved for resume context).
260        if let Some(canonical) = self.transitions.get_canonical(&turn.topic_summary).cloned() {
261            turn.topic_summary = canonical;
262        }
263        if let Some(prev) = self.trajectory.last() {
264            self.transitions
265                .record(&prev.topic_summary, &turn.topic_summary);
266        }
267
268        if self.trajectory.len() >= self.max_turns {
269            self.trajectory.remove(0);
270        }
271        self.trajectory.push(turn);
272    }
273
274    /// Recent distinct topic labels that have no learned canonical mapping yet,
275    /// most recent first. The async canonicalization pass uses this to decide
276    /// which topics still need an LLM label, so the sync recording path stays
277    /// fast and no LLM call ever happens per turn.
278    pub fn pending_canonicalization(&self, limit: usize) -> Vec<String> {
279        let canonical: std::collections::HashSet<String> =
280            self.transitions.known_topics().into_iter().collect();
281        let mut seen = std::collections::HashSet::new();
282        let mut out = Vec::new();
283        for node in self.trajectory.iter().rev() {
284            let t = &node.topic_summary;
285            // Skip topics already resolved: a canonical value itself, or a raw
286            // key that already maps to one.
287            if canonical.contains(t) || self.transitions.get_canonical(t).is_some() {
288                continue;
289            }
290            if seen.insert(t.clone()) {
291                out.push(t.clone());
292                if out.len() >= limit {
293                    break;
294                }
295            }
296        }
297        out
298    }
299
300    /// The set of learned canonical topic labels, for seeding LLM prompts so it
301    /// reuses existing labels instead of inventing near-duplicates.
302    pub fn known_topics(&self) -> Vec<String> {
303        self.transitions.known_topics()
304    }
305
306    /// Store a learned raw -> canonical mapping. Called by the async
307    /// canonicalization pass after it gets labels from the LLM.
308    pub fn learn_canonical(&mut self, raw: &str, canonical: &str) {
309        self.transitions.learn_canonical(raw, canonical);
310    }
311
312    /// Record a turn with LLM-powered topic canonicalization.
313    ///
314    /// Checks the learned topic cache first (no LLM call needed for known patterns).
315    /// On cache miss, asks the LLM for a canonical label and stores it for future use.
316    /// Falls back to the heuristic path on any LLM error.
317    pub async fn record_turn_with_llm<J: LlmJudge>(
318        &mut self,
319        mut turn: TrajectoryNode,
320        llm: &CognitiveLlmService<J>,
321    ) {
322        // Canonicalize the incoming topic
323        let canonical = self.canonicalize_topic(&turn.topic_summary, llm).await;
324        turn.topic_summary = canonical;
325
326        if let Some(prev) = self.trajectory.last() {
327            self.transitions
328                .record(&prev.topic_summary, &turn.topic_summary);
329        }
330
331        if self.trajectory.len() >= self.max_turns {
332            self.trajectory.remove(0);
333        }
334        self.trajectory.push(turn);
335    }
336
337    /// Resolve a topic to its canonical label, using cache or LLM.
338    async fn canonicalize_topic<J: LlmJudge>(
339        &mut self,
340        raw: &str,
341        llm: &CognitiveLlmService<J>,
342    ) -> String {
343        // Tier 3: Check learned cache first (free, no LLM call)
344        if let Some(cached) = self.transitions.get_canonical(raw) {
345            return cached.clone();
346        }
347
348        // Tier 2: Ask the LLM
349        let existing = self.transitions.known_topics();
350        match llm.canonicalize_topic(raw, &existing).await {
351            Ok(label) => {
352                self.transitions.learn_canonical(raw, &label.topic);
353                normalize_topic(&label.topic)
354            }
355            Err(_) => {
356                // Tier 1: Fallback to normalize + exact match
357                normalize_topic(raw)
358            }
359        }
360    }
361
362    pub fn get_trajectory(&self) -> &[TrajectoryNode] {
363        &self.trajectory
364    }
365
366    pub fn get_resume_context(&self) -> Option<String> {
367        if self.trajectory.is_empty() {
368            return None;
369        }
370
371        let mut parts = Vec::new();
372
373        // Find the last non-completed topic
374        if let Some(last) = self.trajectory.last() {
375            parts.push(format!("You were working on: {}", last.topic_summary));
376
377            match &last.decision_state {
378                DecisionState::Investigating => {
379                    parts.push("Status: Still investigating.".to_string());
380                }
381                DecisionState::NarrowedTo(choice) => {
382                    parts.push(format!("You narrowed down to: {}", choice));
383                }
384                DecisionState::Decided(decision) => {
385                    parts.push(format!("You decided on: {}", decision));
386                }
387                DecisionState::Interrupted => {
388                    parts.push("Status: Was interrupted before completion.".to_string());
389                }
390                DecisionState::Completed => {
391                    parts.push("Status: Completed.".to_string());
392                }
393            }
394
395            if !last.open_questions.is_empty() {
396                let qs: Vec<String> = last
397                    .open_questions
398                    .iter()
399                    .map(|q| format!("- {}", q))
400                    .collect();
401                parts.push(format!("Open questions:\n{}", qs.join("\n")));
402            }
403        }
404
405        // Add recent trajectory summary
406        if self.trajectory.len() > 1 {
407            let recent: Vec<String> = self
408                .trajectory
409                .iter()
410                .rev()
411                .skip(1)
412                .take(3)
413                .rev()
414                .map(|t| t.topic_summary.clone())
415                .collect();
416            parts.push(format!("Recent trajectory: {}", recent.join(" → ")));
417        }
418
419        Some(parts.join(" "))
420    }
421
422    pub fn predict_next_topics(&self) -> Vec<String> {
423        let mut predictions = Vec::new();
424        let mut seen = ahash::AHashSet::new();
425
426        let Some(last) = self.trajectory.last() else {
427            return predictions;
428        };
429
430        // Learned transitions are the strongest signal
431        let learned = self.transitions.predict_from(&last.topic_summary, 3);
432        for (topic, _count) in &learned {
433            if seen.insert(topic.clone()) {
434                predictions.push(topic.clone());
435            }
436        }
437
438        // Open questions fill remaining slots
439        for q in &last.open_questions {
440            if predictions.len() >= 3 {
441                break;
442            }
443            if seen.insert(q.clone()) {
444                predictions.push(q.clone());
445            }
446        }
447
448        // Continuation of current topic
449        if predictions.len() < 3 {
450            let cont = format!("{} (continued)", last.topic_summary);
451            if seen.insert(cont.clone()) {
452                predictions.push(cont);
453            }
454        }
455
456        // Revisit previous topic
457        if predictions.len() < 3 && self.trajectory.len() >= 2 {
458            let prev = &self.trajectory[self.trajectory.len() - 2];
459            let rev = format!("{} (revisit)", prev.topic_summary);
460            if seen.insert(rev.clone()) {
461                predictions.push(rev);
462            }
463        }
464
465        predictions.truncate(3);
466        predictions
467    }
468
469    /// Called when the speculative cache gets a hit. Reinforces the
470    /// transition from the previous topic to the hit topic.
471    pub fn reinforce_transition(&mut self, hit_topic: &str) {
472        if let Some(last) = self.trajectory.last() {
473            self.transitions.reinforce(&last.topic_summary, hit_topic);
474        }
475    }
476
477    /// Called when the speculative cache misses. Slightly decays the
478    /// transition from the previous topic to the predicted topic.
479    pub fn decay_transition(&mut self, predicted_topic: &str) {
480        if let Some(last) = self.trajectory.last() {
481            self.transitions.decay(&last.topic_summary, predicted_topic);
482        }
483    }
484}
485
486impl Default for TrajectoryTracker {
487    fn default() -> Self {
488        Self::new(MAX_TURNS_DEFAULT)
489    }
490}
491
492#[cfg(test)]
493mod tests {
494    use super::*;
495
496    fn make_turn(
497        id: u64,
498        summary: &str,
499        state: DecisionState,
500        questions: Vec<&str>,
501    ) -> TrajectoryNode {
502        TrajectoryNode {
503            turn_id: id,
504            topic_embedding: vec![0.0; 4],
505            topic_summary: summary.to_string(),
506            decision_state: state,
507            open_questions: questions.into_iter().map(String::from).collect(),
508            timestamp: id * 1000,
509        }
510    }
511
512    #[test]
513    fn test_record_and_resume() {
514        let mut tracker = TrajectoryTracker::default();
515        tracker.record_turn(make_turn(
516            1,
517            "JWT auth design",
518            DecisionState::Investigating,
519            vec![],
520        ));
521        tracker.record_turn(make_turn(
522            2,
523            "Token refresh strategy",
524            DecisionState::Decided("short-lived access tokens (15min)".into()),
525            vec!["Where to store refresh tokens?"],
526        ));
527
528        let ctx = tracker.get_resume_context().unwrap();
529        assert!(ctx.contains("Token refresh strategy"));
530        assert!(ctx.contains("short-lived access tokens"));
531        assert!(ctx.contains("refresh tokens"));
532    }
533
534    #[test]
535    fn test_predict_topics() {
536        let mut tracker = TrajectoryTracker::default();
537        tracker.record_turn(make_turn(
538            1,
539            "Database schema",
540            DecisionState::Decided("normalized".into()),
541            vec!["How to handle migrations?", "Index strategy?"],
542        ));
543
544        let preds = tracker.predict_next_topics();
545        assert!(!preds.is_empty());
546        assert!(preds.iter().any(|p| p.contains("migrations")));
547    }
548
549    #[test]
550    fn test_fifo_eviction() {
551        let mut tracker = TrajectoryTracker::default();
552        for i in 0..105 {
553            tracker.record_turn(make_turn(
554                i,
555                &format!("turn {}", i),
556                DecisionState::Investigating,
557                vec![],
558            ));
559        }
560        assert_eq!(tracker.get_trajectory().len(), MAX_TURNS_DEFAULT);
561        assert_eq!(tracker.get_trajectory()[0].turn_id, 5);
562    }
563
564    #[test]
565    fn test_transition_recording() {
566        let mut tracker = TrajectoryTracker::default();
567        tracker.record_turn(make_turn(1, "auth", DecisionState::Investigating, vec![]));
568        tracker.record_turn(make_turn(
569            2,
570            "database",
571            DecisionState::Investigating,
572            vec![],
573        ));
574        tracker.record_turn(make_turn(3, "auth", DecisionState::Investigating, vec![]));
575        tracker.record_turn(make_turn(
576            4,
577            "database",
578            DecisionState::Investigating,
579            vec![],
580        ));
581        tracker.record_turn(make_turn(5, "auth", DecisionState::Investigating, vec![]));
582        tracker.record_turn(make_turn(
583            6,
584            "deployment",
585            DecisionState::Investigating,
586            vec![],
587        ));
588
589        // auth -> database happened twice, auth -> deployment once
590        let preds = tracker.transitions.predict_from("auth", 5);
591        assert_eq!(preds.len(), 2);
592        assert_eq!(preds[0].0, "database");
593        assert_eq!(preds[0].1, 2);
594        assert_eq!(preds[1].0, "deployment");
595        assert_eq!(preds[1].1, 1);
596    }
597
598    #[test]
599    fn test_learned_predictions_take_priority() {
600        let mut tracker = TrajectoryTracker::default();
601
602        // Build a pattern: auth -> database (3 times)
603        for _ in 0..3 {
604            tracker.record_turn(make_turn(0, "auth", DecisionState::Investigating, vec![]));
605            tracker.record_turn(make_turn(
606                0,
607                "database",
608                DecisionState::Investigating,
609                vec![],
610            ));
611        }
612
613        // Now land on auth with an open question
614        tracker.record_turn(make_turn(
615            0,
616            "auth",
617            DecisionState::Investigating,
618            vec!["how to handle JWT expiry?"],
619        ));
620
621        let preds = tracker.predict_next_topics();
622        // Learned transition "database" should come first
623        assert_eq!(preds[0], "database");
624    }
625
626    #[test]
627    fn test_reinforce_and_decay() {
628        let mut map = TransitionMap::default();
629        map.record("auth", "database");
630        map.record("auth", "database");
631        assert_eq!(map.predict_from("auth", 1)[0].1, 2);
632
633        // Reinforce adds bonus
634        map.reinforce("auth", "database");
635        assert_eq!(map.predict_from("auth", 1)[0].1, 4);
636
637        // Decay subtracts 1
638        map.decay("auth", "database");
639        assert_eq!(map.predict_from("auth", 1)[0].1, 3);
640    }
641
642    #[test]
643    fn test_decay_removes_zero_entries() {
644        let mut map = TransitionMap::default();
645        map.record("auth", "database");
646        assert_eq!(map.total_transitions(), 1);
647
648        map.decay("auth", "database");
649        assert!(map.is_empty());
650    }
651
652    #[test]
653    fn test_reinforce_via_tracker() {
654        let mut tracker = TrajectoryTracker::default();
655        tracker.record_turn(make_turn(1, "auth", DecisionState::Investigating, vec![]));
656        tracker.record_turn(make_turn(
657            2,
658            "database",
659            DecisionState::Investigating,
660            vec![],
661        ));
662
663        // One natural transition recorded
664        assert_eq!(tracker.transitions.predict_from("auth", 1)[0].1, 1);
665
666        // Simulate cache hit reinforcement
667        tracker.reinforce_transition("database");
668        assert_eq!(
669            tracker.transitions.predict_from("database", 1)[0].1,
670            REINFORCEMENT_BONUS
671        );
672    }
673
674    #[test]
675    fn test_no_duplicate_predictions() {
676        let mut tracker = TrajectoryTracker::default();
677
678        // Build pattern: auth -> database
679        tracker.record_turn(make_turn(1, "auth", DecisionState::Investigating, vec![]));
680        tracker.record_turn(make_turn(
681            2,
682            "database",
683            DecisionState::Investigating,
684            vec![],
685        ));
686
687        // Land on auth with "database" as an open question too
688        tracker.record_turn(make_turn(
689            3,
690            "auth",
691            DecisionState::Investigating,
692            vec!["database"],
693        ));
694
695        let preds = tracker.predict_next_topics();
696        let unique: ahash::AHashSet<&String> = preds.iter().collect();
697        assert_eq!(preds.len(), unique.len(), "predictions should be unique");
698    }
699
700    #[test]
701    fn test_normalization_collapses_variants() {
702        let mut map = TransitionMap::default();
703        map.record("Auth Setup", "database");
704        map.record("auth setup", "DATABASE");
705        map.record("  auth   setup  ", "  database  ");
706
707        // All three should collapse into one transition with count 3
708        let preds = map.predict_from("AUTH SETUP", 1);
709        assert_eq!(preds.len(), 1);
710        assert_eq!(preds[0].0, "database");
711        assert_eq!(preds[0].1, 3);
712    }
713
714    #[test]
715    fn test_transition_map_save_and_load() {
716        let dir = tempfile::tempdir().unwrap();
717        let path = dir.path().join("transitions.json");
718
719        let mut map = TransitionMap::default();
720        map.record("auth", "database");
721        map.record("auth", "database");
722        map.record("auth", "deploy");
723        map.save(&path, 1).unwrap();
724
725        // Load into a fresh map — counts should carry over
726        let mut loaded = TransitionMap::default();
727        loaded.load(&path).unwrap();
728        let preds = loaded.predict_from("auth", 5);
729        assert_eq!(preds[0].0, "database");
730        assert_eq!(preds[0].1, 2);
731        // deploy has count 1, should be saved with min_count=1
732        assert_eq!(preds[1].0, "deploy");
733        assert_eq!(preds[1].1, 1);
734    }
735
736    #[test]
737    fn test_transition_map_save_prunes_low_counts() {
738        let dir = tempfile::tempdir().unwrap();
739        let path = dir.path().join("transitions.json");
740
741        let mut map = TransitionMap::default();
742        map.record("auth", "database");
743        map.record("auth", "database");
744        map.record("auth", "deploy"); // count 1
745        map.save(&path, 2).unwrap(); // only keep count >= 2
746
747        let mut loaded = TransitionMap::default();
748        loaded.load(&path).unwrap();
749        let preds = loaded.predict_from("auth", 5);
750        assert_eq!(preds.len(), 1);
751        assert_eq!(preds[0].0, "database");
752        assert_eq!(preds[0].1, 2);
753    }
754
755    #[test]
756    fn test_transition_map_load_merges() {
757        let dir = tempfile::tempdir().unwrap();
758        let path = dir.path().join("transitions.json");
759
760        let mut map = TransitionMap::default();
761        map.record("auth", "database");
762        map.save(&path, 1).unwrap();
763
764        // Load into a map that already has data — counts should add
765        let mut existing = TransitionMap::default();
766        existing.record("auth", "database");
767        existing.record("auth", "testing");
768        existing.load(&path).unwrap();
769
770        let preds = existing.predict_from("auth", 5);
771        // database: 1 existing + 1 loaded = 2
772        assert_eq!(preds[0].0, "database");
773        assert_eq!(preds[0].1, 2);
774        // testing: 1 existing only
775        assert_eq!(preds[1].0, "testing");
776        assert_eq!(preds[1].1, 1);
777    }
778
779    #[test]
780    fn test_topic_cache_learn_and_resolve() {
781        let mut map = TransitionMap::default();
782
783        // Without cache, "auth setup" and "configure authentication" are different keys
784        map.record("auth setup", "database");
785        map.record("configure authentication", "database");
786        // Two separate entries
787        assert_eq!(map.predict_from("auth setup", 1)[0].1, 1);
788
789        // Teach the cache
790        map.learn_canonical("auth setup", "authentication");
791        map.learn_canonical("configure authentication", "authentication");
792
793        // Now both resolve to "authentication"
794        map.record("auth setup", "database");
795        map.record("configure authentication", "database");
796        let preds = map.predict_from("authentication", 1);
797        assert_eq!(preds[0].0, "database");
798        assert_eq!(preds[0].1, 2);
799    }
800
801    #[test]
802    fn test_topic_cache_persists_across_save_load() {
803        let dir = tempfile::tempdir().unwrap();
804        let path = dir.path().join("transitions.json");
805
806        let mut map = TransitionMap::default();
807        map.learn_canonical("auth setup", "authentication");
808        map.learn_canonical("db design", "database");
809        map.record("auth setup", "db design");
810        map.save(&path, 1).unwrap();
811
812        let mut loaded = TransitionMap::default();
813        loaded.load(&path).unwrap();
814
815        // Cache should survive the roundtrip
816        assert_eq!(
817            loaded.get_canonical("auth setup"),
818            Some(&"authentication".to_string())
819        );
820        assert_eq!(
821            loaded.get_canonical("db design"),
822            Some(&"database".to_string())
823        );
824        // Transition should use canonical keys
825        let preds = loaded.predict_from("authentication", 1);
826        assert_eq!(preds[0].0, "database");
827    }
828
829    #[test]
830    fn test_known_topics_includes_cache_and_transitions() {
831        let mut map = TransitionMap::default();
832        map.learn_canonical("auth setup", "authentication");
833        map.record("deployment", "testing");
834
835        let topics = map.known_topics();
836        assert!(topics.contains(&"authentication".to_string()));
837        assert!(topics.contains(&"deployment".to_string()));
838        assert!(topics.contains(&"testing".to_string()));
839    }
840
841    #[test]
842    fn test_v1_snapshot_loads_without_topic_cache() {
843        let dir = tempfile::tempdir().unwrap();
844        let path = dir.path().join("transitions.json");
845
846        // Write a v1 snapshot manually (no topic_cache field)
847        let v1_json = r#"{"version":1,"transitions":{"auth":{"database":3}}}"#;
848        std::fs::write(&path, v1_json).unwrap();
849
850        let mut map = TransitionMap::default();
851        map.load(&path).unwrap();
852
853        // Transitions loaded, cache empty
854        let preds = map.predict_from("auth", 1);
855        assert_eq!(preds[0].0, "database");
856        assert_eq!(preds[0].1, 3);
857        assert_eq!(map.topic_cache_size(), 0);
858    }
859
860    use crate::llm::{CognitiveLlmService, MockLlmJudge};
861
862    #[tokio::test]
863    async fn test_record_turn_with_llm_canonicalizes() {
864        let judge = MockLlmJudge::new(r#"{"topic": "authentication", "is_new": false}"#);
865        let llm = CognitiveLlmService::new(judge);
866        let mut tracker = TrajectoryTracker::default();
867
868        tracker
869            .record_turn_with_llm(
870                make_turn(
871                    1,
872                    "auth setup question",
873                    DecisionState::Investigating,
874                    vec![],
875                ),
876                &llm,
877            )
878            .await;
879
880        // Topic should be canonicalized to "authentication"
881        assert_eq!(tracker.get_trajectory()[0].topic_summary, "authentication");
882        // Cache should have the learned mapping
883        assert_eq!(
884            tracker.transitions.get_canonical("auth setup question"),
885            Some(&"authentication".to_string())
886        );
887    }
888
889    #[tokio::test]
890    async fn test_record_turn_with_llm_uses_cache_on_repeat() {
891        let judge = MockLlmJudge::new(r#"{"topic": "authentication", "is_new": false}"#);
892        let llm = CognitiveLlmService::new(judge);
893        let mut tracker = TrajectoryTracker::default();
894
895        // First call learns the mapping
896        tracker
897            .record_turn_with_llm(
898                make_turn(1, "auth setup", DecisionState::Investigating, vec![]),
899                &llm,
900            )
901            .await;
902
903        // Pre-teach a different canonical for the next topic
904        // to prove the cache is being used (not the LLM)
905        tracker
906            .transitions
907            .learn_canonical("configure auth", "authentication");
908
909        // This should use cache, NOT the MockLlmJudge
910        // (MockLlmJudge always returns "authentication" anyway, but the point is
911        // the cache path is exercised — no LLM call needed)
912        tracker
913            .record_turn_with_llm(
914                make_turn(2, "configure auth", DecisionState::Investigating, vec![]),
915                &llm,
916            )
917            .await;
918
919        assert_eq!(tracker.get_trajectory()[1].topic_summary, "authentication");
920
921        // Both turns should contribute to the same transition key
922        // Turn 1: "authentication" recorded (no prev)
923        // Turn 2: "authentication" -> "authentication" (same topic)
924        assert_eq!(tracker.get_trajectory().len(), 2);
925    }
926
927    #[tokio::test]
928    async fn test_record_turn_with_llm_transitions_accumulate() {
929        let judge = MockLlmJudge::new(r#"{"topic": "authentication", "is_new": false}"#);
930        let llm = CognitiveLlmService::new(judge);
931        let mut tracker = TrajectoryTracker::default();
932
933        // Record first turn (no transition yet)
934        tracker
935            .record_turn_with_llm(
936                make_turn(1, "auth setup", DecisionState::Investigating, vec![]),
937                &llm,
938            )
939            .await;
940
941        // Now record with a different mock response for "database"
942        let judge2 = MockLlmJudge::new(r#"{"topic": "database", "is_new": false}"#);
943        let llm2 = CognitiveLlmService::new(judge2);
944        tracker
945            .record_turn_with_llm(
946                make_turn(2, "db schema design", DecisionState::Investigating, vec![]),
947                &llm2,
948            )
949            .await;
950
951        // Should have transition: authentication -> database
952        let preds = tracker.transitions.predict_from("authentication", 3);
953        assert_eq!(preds.len(), 1);
954        assert_eq!(preds[0].0, "database");
955        assert_eq!(preds[0].1, 1);
956    }
957
958    #[test]
959    fn record_turn_resolves_learned_canonical_and_clears_pending() {
960        let mut tracker = TrajectoryTracker::default();
961
962        // A brand-new phrasing records as normalized text and is pending an
963        // LLM label.
964        tracker.record_turn(make_turn(
965            1,
966            "I switched from Postgres to SQLite",
967            DecisionState::Investigating,
968            vec![],
969        ));
970        assert!(
971            tracker
972                .pending_canonicalization(10)
973                .iter()
974                .any(|t| t.contains("SQLite")),
975            "raw topic should be pending canonicalization"
976        );
977
978        // Once the async pass learns a canonical label, the same phrasing
979        // collapses to it on the next turn, and it is no longer pending.
980        tracker.learn_canonical("I switched from Postgres to SQLite", "database choice");
981        tracker.record_turn(make_turn(
982            2,
983            "I switched from Postgres to SQLite",
984            DecisionState::Investigating,
985            vec![],
986        ));
987        assert_eq!(
988            tracker.get_trajectory().last().unwrap().topic_summary,
989            "database choice",
990            "recurring phrasing should resolve to the learned canonical label"
991        );
992        assert!(
993            !tracker
994                .pending_canonicalization(10)
995                .iter()
996                .any(|t| t.contains("SQLite")),
997            "canonicalized topic should no longer be pending"
998        );
999    }
1000}