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
30fn normalize_topic(raw: &str) -> String {
34 raw.split_whitespace()
35 .map(|w| w.to_lowercase())
36 .collect::<Vec<_>>()
37 .join(" ")
38}
39
40#[derive(Debug, Clone, Default, Serialize, Deserialize)]
46pub struct TransitionMap {
47 transitions: HashMap<String, HashMap<String, u32>>,
48 #[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 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 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 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 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 pub fn get_canonical(&self, raw: &str) -> Option<&String> {
139 let normalized = normalize_topic(raw);
140 self.topic_cache.get(&normalized)
141 }
142
143 pub fn known_topics(&self) -> Vec<String> {
145 let mut topics: Vec<String> = self.topic_cache.values().cloned().collect();
146 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 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 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 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 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 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 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 pub fn known_topics(&self) -> Vec<String> {
303 self.transitions.known_topics()
304 }
305
306 pub fn learn_canonical(&mut self, raw: &str, canonical: &str) {
309 self.transitions.learn_canonical(raw, canonical);
310 }
311
312 pub async fn record_turn_with_llm<J: LlmJudge>(
318 &mut self,
319 mut turn: TrajectoryNode,
320 llm: &CognitiveLlmService<J>,
321 ) {
322 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 async fn canonicalize_topic<J: LlmJudge>(
339 &mut self,
340 raw: &str,
341 llm: &CognitiveLlmService<J>,
342 ) -> String {
343 if let Some(cached) = self.transitions.get_canonical(raw) {
345 return cached.clone();
346 }
347
348 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 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 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 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 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 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 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 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 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 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 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 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 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 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 map.reinforce("auth", "database");
635 assert_eq!(map.predict_from("auth", 1)[0].1, 4);
636
637 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 assert_eq!(tracker.transitions.predict_from("auth", 1)[0].1, 1);
665
666 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 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 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 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 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 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"); map.save(&path, 2).unwrap(); 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 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 assert_eq!(preds[0].0, "database");
773 assert_eq!(preds[0].1, 2);
774 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 map.record("auth setup", "database");
785 map.record("configure authentication", "database");
786 assert_eq!(map.predict_from("auth setup", 1)[0].1, 1);
788
789 map.learn_canonical("auth setup", "authentication");
791 map.learn_canonical("configure authentication", "authentication");
792
793 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 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 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 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 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 assert_eq!(tracker.get_trajectory()[0].topic_summary, "authentication");
882 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 tracker
897 .record_turn_with_llm(
898 make_turn(1, "auth setup", DecisionState::Investigating, vec![]),
899 &llm,
900 )
901 .await;
902
903 tracker
906 .transitions
907 .learn_canonical("configure auth", "authentication");
908
909 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 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 tracker
935 .record_turn_with_llm(
936 make_turn(1, "auth setup", DecisionState::Investigating, vec![]),
937 &llm,
938 )
939 .await;
940
941 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 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 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 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}