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(|a, b| b.1.cmp(&a.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, 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 pub async fn record_turn_with_llm<J: LlmJudge>(
273 &mut self,
274 mut turn: TrajectoryNode,
275 llm: &CognitiveLlmService<J>,
276 ) {
277 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 async fn canonicalize_topic<J: LlmJudge>(
294 &mut self,
295 raw: &str,
296 llm: &CognitiveLlmService<J>,
297 ) -> String {
298 if let Some(cached) = self.transitions.get_canonical(raw) {
300 return cached.clone();
301 }
302
303 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 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 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 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 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 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 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 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 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 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 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 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 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 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 map.reinforce("auth", "database");
590 assert_eq!(map.predict_from("auth", 1)[0].1, 4);
591
592 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 assert_eq!(tracker.transitions.predict_from("auth", 1)[0].1, 1);
620
621 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 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 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 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 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 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"); map.save(&path, 2).unwrap(); 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 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 assert_eq!(preds[0].0, "database");
728 assert_eq!(preds[0].1, 2);
729 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 map.record("auth setup", "database");
740 map.record("configure authentication", "database");
741 assert_eq!(map.predict_from("auth setup", 1)[0].1, 1);
743
744 map.learn_canonical("auth setup", "authentication");
746 map.learn_canonical("configure authentication", "authentication");
747
748 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 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 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 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 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 assert_eq!(tracker.get_trajectory()[0].topic_summary, "authentication");
837 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 tracker
852 .record_turn_with_llm(
853 make_turn(1, "auth setup", DecisionState::Investigating, vec![]),
854 &llm,
855 )
856 .await;
857
858 tracker
861 .transitions
862 .learn_canonical("configure auth", "authentication");
863
864 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 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 tracker
890 .record_turn_with_llm(
891 make_turn(1, "auth setup", DecisionState::Investigating, vec![]),
892 &llm,
893 )
894 .await;
895
896 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 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}