1use chrono::{DateTime, Duration, Utc};
5
6use crate::graph::EdgeType;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum MemoryRoute {
11 Keyword,
13 Semantic,
15 Hybrid,
17 Graph,
20 Episodic,
27}
28
29#[derive(Debug, Clone)]
31pub struct RoutingDecision {
32 pub route: MemoryRoute,
33 pub confidence: f32,
35 pub reasoning: Option<String>,
37}
38
39pub trait MemoryRouter: Send + Sync {
41 fn route(&self, query: &str) -> MemoryRoute;
43
44 fn route_with_confidence(&self, query: &str) -> RoutingDecision {
49 RoutingDecision {
50 route: self.route(query),
51 confidence: 1.0,
52 reasoning: None,
53 }
54 }
55}
56
57#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct TemporalRange {
69 pub after: Option<String>,
71 pub before: Option<String>,
73}
74
75const TEMPORAL_PATTERNS: &[&str] = &[
85 "yesterday",
87 "today",
88 "this morning",
89 "tonight",
90 "last night",
91 "last week",
93 "this week",
94 "past week",
95 "last month",
97 "this month",
98 "past month",
99 "when did",
101 "remember when",
102 "last time",
103 "how long ago",
104 "few days ago",
107 "few hours ago",
108 "earlier today",
109];
110
111const WORD_BOUNDARY_TEMPORAL: &[&str] = &["ago"];
114
115pub(crate) const CAUSAL_MARKERS: &[&str] = &[
120 "why",
121 "because",
122 "caused",
123 "cause",
124 "reason",
125 "result",
126 "led to",
127 "consequence",
128 "trigger",
129 "effect",
130 "blame",
131 "fault",
132];
133
134pub(crate) const TEMPORAL_MARKERS: &[&str] = &[
141 "before", "after", "first", "then", "timeline", "sequence", "preceded", "followed", "started",
142 "ended", "during", "prior",
143];
144
145pub(crate) const ENTITY_MARKERS: &[&str] = &[
147 "is a",
148 "type of",
149 "kind of",
150 "part of",
151 "instance",
152 "same as",
153 "alias",
154 "subtype",
155 "subclass",
156 "belongs to",
157];
158
159#[must_use]
181pub fn classify_graph_subgraph(query: &str) -> Vec<EdgeType> {
182 let lower = query.to_ascii_lowercase();
183 let mut types: Vec<EdgeType> = Vec::new();
184
185 if CAUSAL_MARKERS.iter().any(|m| lower.contains(m)) {
186 types.push(EdgeType::Causal);
187 }
188 if TEMPORAL_MARKERS.iter().any(|m| lower.contains(m)) {
189 types.push(EdgeType::Temporal);
190 }
191 if ENTITY_MARKERS.iter().any(|m| lower.contains(m)) {
192 types.push(EdgeType::Entity);
193 }
194
195 if !types.contains(&EdgeType::Semantic) {
197 types.push(EdgeType::Semantic);
198 }
199
200 types
201}
202
203pub struct HeuristicRouter;
213
214const QUESTION_WORDS: &[&str] = &[
215 "what", "how", "why", "when", "where", "who", "which", "explain", "describe",
216];
217
218const RELATIONSHIP_PATTERNS: &[&str] = &[
221 "related to",
222 "relates to",
223 "connection between",
224 "relationship",
225 "opinion on",
226 "thinks about",
227 "preference for",
228 "history of",
229 "know about",
230];
231
232fn contains_word(text: &str, word: &str) -> bool {
237 let bytes = text.as_bytes();
238 let wbytes = word.as_bytes();
239 let wlen = wbytes.len();
240 if wlen > bytes.len() {
241 return false;
242 }
243 for start in 0..=(bytes.len() - wlen) {
244 if bytes[start..start + wlen].eq_ignore_ascii_case(wbytes) {
245 let before_ok =
246 start == 0 || !bytes[start - 1].is_ascii_alphanumeric() && bytes[start - 1] != b'_';
247 let after_ok = start + wlen == bytes.len()
248 || !bytes[start + wlen].is_ascii_alphanumeric() && bytes[start + wlen] != b'_';
249 if before_ok && after_ok {
250 return true;
251 }
252 }
253 }
254 false
255}
256
257fn has_temporal_cue(lower: &str) -> bool {
260 if TEMPORAL_PATTERNS.iter().any(|p| lower.contains(p)) {
261 return true;
262 }
263 WORD_BOUNDARY_TEMPORAL
264 .iter()
265 .any(|w| contains_word(lower, w))
266}
267
268static SORTED_TEMPORAL_PATTERNS: std::sync::LazyLock<Vec<&'static str>> =
271 std::sync::LazyLock::new(|| {
272 let mut v: Vec<&str> = TEMPORAL_PATTERNS.to_vec();
273 v.sort_by_key(|p| std::cmp::Reverse(p.len()));
274 v
275 });
276
277#[must_use]
294pub fn strip_temporal_keywords(query: &str) -> String {
295 let lower = query.to_ascii_lowercase();
300 let mut remove: Vec<(usize, usize)> = Vec::new();
302
303 for pattern in SORTED_TEMPORAL_PATTERNS.iter() {
304 let plen = pattern.len();
305 let mut search_from = 0;
306 while let Some(pos) = lower[search_from..].find(pattern) {
307 let abs = search_from + pos;
308 remove.push((abs, abs + plen));
309 search_from = abs + plen;
310 }
311 }
312
313 for word in WORD_BOUNDARY_TEMPORAL {
315 let wlen = word.len();
316 let lbytes = lower.as_bytes();
317 let mut i = 0;
318 while i + wlen <= lower.len() {
319 if lower[i..].starts_with(*word) {
320 let before_ok =
321 i == 0 || !lbytes[i - 1].is_ascii_alphanumeric() && lbytes[i - 1] != b'_';
322 let after_ok = i + wlen == lower.len()
323 || !lbytes[i + wlen].is_ascii_alphanumeric() && lbytes[i + wlen] != b'_';
324 if before_ok && after_ok {
325 remove.push((i, i + wlen));
326 i += wlen;
327 continue;
328 }
329 }
330 i += 1;
331 }
332 }
333
334 if remove.is_empty() {
335 return query.split_whitespace().collect::<Vec<_>>().join(" ");
337 }
338
339 remove.sort_unstable_by_key(|r| r.0);
341 let bytes = query.as_bytes();
342 let mut result = Vec::with_capacity(query.len());
343 let mut cursor = 0;
344 for (start, end) in remove {
345 if start > cursor {
346 result.extend_from_slice(&bytes[cursor..start]);
347 }
348 cursor = cursor.max(end);
349 }
350 if cursor < bytes.len() {
351 result.extend_from_slice(&bytes[cursor..]);
352 }
353
354 let s = String::from_utf8(result).unwrap_or_default();
357 s.split_whitespace()
358 .filter(|t| !t.is_empty())
359 .collect::<Vec<_>>()
360 .join(" ")
361}
362
363#[must_use]
373pub fn resolve_temporal_range(query: &str, now: DateTime<Utc>) -> Option<TemporalRange> {
374 let lower = query.to_ascii_lowercase();
375
376 if lower.contains("yesterday") {
378 let yesterday = now.date_naive() - Duration::days(1);
379 return Some(TemporalRange {
380 after: Some(format!("{yesterday} 00:00:00")),
381 before: Some(format!("{yesterday} 23:59:59")),
382 });
383 }
384
385 if lower.contains("last night") {
387 let yesterday = now.date_naive() - Duration::days(1);
388 let today = now.date_naive();
389 return Some(TemporalRange {
390 after: Some(format!("{yesterday} 18:00:00")),
391 before: Some(format!("{today} 06:00:00")),
392 });
393 }
394
395 if lower.contains("tonight") {
397 let today = now.date_naive();
398 return Some(TemporalRange {
399 after: Some(format!("{today} 18:00:00")),
400 before: None,
401 });
402 }
403
404 if lower.contains("this morning") {
406 let today = now.date_naive();
407 return Some(TemporalRange {
408 after: Some(format!("{today} 00:00:00")),
409 before: Some(format!("{today} 12:00:00")),
410 });
411 }
412
413 if lower.contains("today") {
417 let today = now.date_naive();
418 return Some(TemporalRange {
419 after: Some(format!("{today} 00:00:00")),
420 before: None,
421 });
422 }
423
424 if lower.contains("last week") || lower.contains("past week") || lower.contains("this week") {
426 let start = now - Duration::days(7);
427 return Some(TemporalRange {
428 after: Some(start.format("%Y-%m-%d %H:%M:%S").to_string()),
429 before: None,
430 });
431 }
432
433 if lower.contains("last month") || lower.contains("past month") || lower.contains("this month")
435 {
436 let start = now - Duration::days(30);
437 return Some(TemporalRange {
438 after: Some(start.format("%Y-%m-%d %H:%M:%S").to_string()),
439 before: None,
440 });
441 }
442
443 if lower.contains("few days ago") {
445 let start = now - Duration::days(3);
446 return Some(TemporalRange {
447 after: Some(start.format("%Y-%m-%d %H:%M:%S").to_string()),
448 before: None,
449 });
450 }
451 if lower.contains("few hours ago") {
452 let start = now - Duration::hours(6);
453 return Some(TemporalRange {
454 after: Some(start.format("%Y-%m-%d %H:%M:%S").to_string()),
455 before: None,
456 });
457 }
458
459 if contains_word(&lower, "ago") {
461 let start = now - Duration::hours(24);
462 return Some(TemporalRange {
463 after: Some(start.format("%Y-%m-%d %H:%M:%S").to_string()),
464 before: None,
465 });
466 }
467
468 None
471}
472
473fn starts_with_question(words: &[&str]) -> bool {
474 words
475 .first()
476 .is_some_and(|w| QUESTION_WORDS.iter().any(|qw| w.eq_ignore_ascii_case(qw)))
477}
478
479fn is_pure_snake_case(word: &str) -> bool {
482 if word.is_empty() {
483 return false;
484 }
485 let has_underscore = word.contains('_');
486 if !has_underscore {
487 return false;
488 }
489 word.chars()
490 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
491 && !word.chars().all(|c| c.is_ascii_digit() || c == '_')
492}
493
494impl MemoryRouter for HeuristicRouter {
495 fn route_with_confidence(&self, query: &str) -> RoutingDecision {
501 let lower = query.to_ascii_lowercase();
502 let mut matched: u32 = 0;
503 if has_temporal_cue(&lower) {
504 matched += 1;
505 }
506 if RELATIONSHIP_PATTERNS.iter().any(|p| lower.contains(p)) {
507 matched += 1;
508 }
509 let words: Vec<&str> = query.split_whitespace().collect();
510 let word_count = words.len();
511 let has_structural = query.contains('/') || query.contains("::");
512 let question = starts_with_question(&words);
513 let has_snake = words.iter().any(|w| is_pure_snake_case(w));
514 if has_structural && !question {
515 matched += 1;
516 }
517 if question || word_count >= 6 {
518 matched += 1;
519 }
520 if word_count <= 3 && !question {
521 matched += 1;
522 }
523 if has_snake {
524 matched += 1;
525 }
526
527 #[allow(clippy::cast_precision_loss)]
528 let confidence = match matched {
529 0 => 0.0,
530 1 => 1.0,
531 n => 1.0 / n as f32,
532 };
533
534 RoutingDecision {
535 route: self.route(query),
536 confidence,
537 reasoning: None,
538 }
539 }
540
541 fn route(&self, query: &str) -> MemoryRoute {
542 let lower = query.to_ascii_lowercase();
543
544 if has_temporal_cue(&lower) {
547 return MemoryRoute::Episodic;
548 }
549
550 let has_relationship = RELATIONSHIP_PATTERNS.iter().any(|p| lower.contains(p));
552 if has_relationship {
553 return MemoryRoute::Graph;
554 }
555
556 let words: Vec<&str> = query.split_whitespace().collect();
557 let word_count = words.len();
558
559 let has_structural_code_pattern = query.contains('/') || query.contains("::");
562
563 let has_snake_case = words.iter().any(|w| is_pure_snake_case(w));
566 let question = starts_with_question(&words);
567
568 if has_structural_code_pattern && !question {
569 return MemoryRoute::Keyword;
570 }
571
572 if question || word_count >= 6 {
574 return MemoryRoute::Semantic;
575 }
576
577 if word_count <= 3 && !question {
579 return MemoryRoute::Keyword;
580 }
581
582 if has_snake_case {
584 return MemoryRoute::Keyword;
585 }
586
587 MemoryRoute::Hybrid
589 }
590}
591
592pub struct LlmRouter {
599 provider: std::sync::Arc<zeph_llm::any::AnyProvider>,
600 fallback_route: MemoryRoute,
601}
602
603impl LlmRouter {
604 #[must_use]
605 pub fn new(
606 provider: std::sync::Arc<zeph_llm::any::AnyProvider>,
607 fallback_route: MemoryRoute,
608 ) -> Self {
609 Self {
610 provider,
611 fallback_route,
612 }
613 }
614
615 async fn classify_async(&self, query: &str) -> RoutingDecision {
616 use zeph_llm::provider::{LlmProvider as _, Message, MessageMetadata, Role};
617
618 let system = "You are a memory store routing classifier. \
619 Given a user query, decide which memory backend is most appropriate. \
620 Respond with ONLY a JSON object: \
621 {\"route\": \"<route>\", \"confidence\": <0.0-1.0>, \"reasoning\": \"<brief>\"} \
622 where <route> is one of: keyword, semantic, hybrid, graph, episodic. \
623 Use 'keyword' for exact/code lookups, 'semantic' for conceptual questions, \
624 'hybrid' for mixed, 'graph' for relationship queries, 'episodic' for time-scoped queries.";
625
626 let user = format!(
628 "<query>{}</query>",
629 query.chars().take(500).collect::<String>()
630 );
631
632 let messages = vec![
633 Message {
634 role: Role::System,
635 content: system.to_owned(),
636 parts: vec![],
637 metadata: MessageMetadata::default(),
638 },
639 Message {
640 role: Role::User,
641 content: user,
642 parts: vec![],
643 metadata: MessageMetadata::default(),
644 },
645 ];
646
647 let result = match tokio::time::timeout(
648 std::time::Duration::from_secs(5),
649 self.provider.chat(&messages),
650 )
651 .await
652 {
653 Ok(Ok(r)) => r,
654 Ok(Err(e)) => {
655 tracing::debug!(error = %e, "LlmRouter: LLM call failed, falling back to heuristic");
656 return Self::heuristic_fallback(query);
657 }
658 Err(_) => {
659 tracing::debug!("LlmRouter: LLM timed out, falling back to heuristic");
660 return Self::heuristic_fallback(query);
661 }
662 };
663
664 self.parse_llm_response(&result, query)
665 }
666
667 fn parse_llm_response(&self, raw: &str, query: &str) -> RoutingDecision {
668 let json_str = raw
670 .find('{')
671 .and_then(|start| raw[start..].rfind('}').map(|end| &raw[start..=start + end]))
672 .unwrap_or("");
673
674 if let Ok(v) = serde_json::from_str::<serde_json::Value>(json_str) {
675 let route_str = v.get("route").and_then(|r| r.as_str()).unwrap_or("hybrid");
676 #[allow(clippy::cast_possible_truncation)]
677 let confidence = v
678 .get("confidence")
679 .and_then(serde_json::Value::as_f64)
680 .map_or(0.5, |c| c.clamp(0.0, 1.0) as f32);
681 let reasoning = v
682 .get("reasoning")
683 .and_then(|r| r.as_str())
684 .map(str::to_owned);
685
686 let route = parse_route_str(route_str, self.fallback_route);
687
688 tracing::debug!(
689 query = &query[..query.len().min(60)],
690 ?route,
691 confidence,
692 "LlmRouter: classified"
693 );
694
695 return RoutingDecision {
696 route,
697 confidence,
698 reasoning,
699 };
700 }
701
702 tracing::debug!("LlmRouter: failed to parse JSON response, falling back to heuristic");
703 Self::heuristic_fallback(query)
704 }
705
706 fn heuristic_fallback(query: &str) -> RoutingDecision {
707 HeuristicRouter.route_with_confidence(query)
708 }
709}
710
711fn parse_route_str(s: &str, fallback: MemoryRoute) -> MemoryRoute {
712 match s {
713 "keyword" => MemoryRoute::Keyword,
714 "semantic" => MemoryRoute::Semantic,
715 "hybrid" => MemoryRoute::Hybrid,
716 "graph" => MemoryRoute::Graph,
717 "episodic" => MemoryRoute::Episodic,
718 _ => fallback,
719 }
720}
721
722impl MemoryRouter for LlmRouter {
723 fn route(&self, query: &str) -> MemoryRoute {
724 HeuristicRouter.route(query)
727 }
728
729 fn route_with_confidence(&self, query: &str) -> RoutingDecision {
730 HeuristicRouter.route_with_confidence(query)
733 }
734}
735
736pub trait AsyncMemoryRouter: MemoryRouter {
738 fn route_async<'a>(
739 &'a self,
740 query: &'a str,
741 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = RoutingDecision> + Send + 'a>>;
742}
743
744impl AsyncMemoryRouter for LlmRouter {
745 fn route_async<'a>(
746 &'a self,
747 query: &'a str,
748 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = RoutingDecision> + Send + 'a>> {
749 Box::pin(self.classify_async(query))
750 }
751}
752
753pub struct HybridRouter {
759 llm: LlmRouter,
760 confidence_threshold: f32,
761}
762
763impl HybridRouter {
764 #[must_use]
765 pub fn new(
766 provider: std::sync::Arc<zeph_llm::any::AnyProvider>,
767 fallback_route: MemoryRoute,
768 confidence_threshold: f32,
769 ) -> Self {
770 Self {
771 llm: LlmRouter::new(provider, fallback_route),
772 confidence_threshold,
773 }
774 }
775
776 pub async fn route_async(&self, query: &str) -> RoutingDecision {
777 let heuristic = HeuristicRouter.route_with_confidence(query);
778 if heuristic.confidence >= self.confidence_threshold {
779 tracing::debug!(
780 query = &query[..query.len().min(60)],
781 confidence = heuristic.confidence,
782 route = ?heuristic.route,
783 "HybridRouter: heuristic sufficient, skipping LLM"
784 );
785 return heuristic;
786 }
787
788 tracing::debug!(
789 query = &query[..query.len().min(60)],
790 confidence = heuristic.confidence,
791 threshold = self.confidence_threshold,
792 "HybridRouter: low confidence, escalating to LLM"
793 );
794
795 let llm_result = self.llm.classify_async(query).await;
796
797 tracing::debug!(
800 route = ?llm_result.route,
801 confidence = llm_result.confidence,
802 "HybridRouter: final route after LLM escalation"
803 );
804 llm_result
805 }
806}
807
808impl MemoryRouter for HybridRouter {
809 fn route(&self, query: &str) -> MemoryRoute {
810 HeuristicRouter.route(query)
811 }
812
813 fn route_with_confidence(&self, query: &str) -> RoutingDecision {
814 HeuristicRouter.route_with_confidence(query)
816 }
817}
818
819#[cfg(test)]
820mod tests {
821 use chrono::TimeZone as _;
822
823 use super::*;
824
825 fn route(q: &str) -> MemoryRoute {
826 HeuristicRouter.route(q)
827 }
828
829 fn fixed_now() -> DateTime<Utc> {
830 Utc.with_ymd_and_hms(2026, 3, 14, 12, 0, 0).unwrap()
832 }
833
834 #[test]
835 fn rust_path_routes_keyword() {
836 assert_eq!(route("zeph_memory::recall"), MemoryRoute::Keyword);
837 }
838
839 #[test]
840 fn file_path_routes_keyword() {
841 assert_eq!(
842 route("crates/zeph-core/src/agent/mod.rs"),
843 MemoryRoute::Keyword
844 );
845 }
846
847 #[test]
848 fn pure_snake_case_routes_keyword() {
849 assert_eq!(route("memory_limit"), MemoryRoute::Keyword);
850 assert_eq!(route("error_handling"), MemoryRoute::Keyword);
851 }
852
853 #[test]
854 fn question_with_snake_case_routes_semantic() {
855 assert_eq!(
857 route("what is the memory_limit setting"),
858 MemoryRoute::Semantic
859 );
860 assert_eq!(route("how does error_handling work"), MemoryRoute::Semantic);
861 }
862
863 #[test]
864 fn short_query_routes_keyword() {
865 assert_eq!(route("context compaction"), MemoryRoute::Keyword);
866 assert_eq!(route("qdrant"), MemoryRoute::Keyword);
867 }
868
869 #[test]
870 fn question_routes_semantic() {
871 assert_eq!(
872 route("what is the purpose of semantic memory"),
873 MemoryRoute::Semantic
874 );
875 assert_eq!(route("how does the agent loop work"), MemoryRoute::Semantic);
876 assert_eq!(route("why does compaction fail"), MemoryRoute::Semantic);
877 assert_eq!(route("explain context compression"), MemoryRoute::Semantic);
878 }
879
880 #[test]
881 fn long_natural_query_routes_semantic() {
882 assert_eq!(
883 route("the agent keeps running out of context during long conversations"),
884 MemoryRoute::Semantic
885 );
886 }
887
888 #[test]
889 fn medium_non_question_routes_hybrid() {
890 assert_eq!(route("context window token budget"), MemoryRoute::Hybrid);
892 }
893
894 #[test]
895 fn empty_query_routes_keyword() {
896 assert_eq!(route(""), MemoryRoute::Keyword);
898 }
899
900 #[test]
901 fn question_word_only_routes_semantic() {
902 assert_eq!(route("what"), MemoryRoute::Semantic);
907 }
908
909 #[test]
910 fn camel_case_does_not_route_keyword_without_pattern() {
911 assert_eq!(
914 route("SemanticMemory configuration and options"),
915 MemoryRoute::Hybrid
916 );
917 }
918
919 #[test]
920 fn relationship_query_routes_graph() {
921 assert_eq!(
922 route("what is user's opinion on neovim"),
923 MemoryRoute::Graph
924 );
925 assert_eq!(
926 route("show the relationship between Alice and Bob"),
927 MemoryRoute::Graph
928 );
929 }
930
931 #[test]
932 fn relationship_query_related_to_routes_graph() {
933 assert_eq!(
934 route("how is Rust related to this project"),
935 MemoryRoute::Graph
936 );
937 assert_eq!(
938 route("how does this relates to the config"),
939 MemoryRoute::Graph
940 );
941 }
942
943 #[test]
944 fn relationship_know_about_routes_graph() {
945 assert_eq!(route("what do I know about neovim"), MemoryRoute::Graph);
946 }
947
948 #[test]
949 fn translate_does_not_route_graph() {
950 assert_ne!(route("translate this code to Python"), MemoryRoute::Graph);
953 }
954
955 #[test]
956 fn non_relationship_stays_semantic() {
957 assert_eq!(
958 route("find similar code patterns in the codebase"),
959 MemoryRoute::Semantic
960 );
961 }
962
963 #[test]
964 fn short_keyword_unchanged() {
965 assert_eq!(route("qdrant"), MemoryRoute::Keyword);
966 }
967
968 #[test]
970 fn long_nl_with_snake_case_routes_semantic() {
971 assert_eq!(
972 route("Use memory_search to find information about Rust ownership"),
973 MemoryRoute::Semantic
974 );
975 }
976
977 #[test]
978 fn short_snake_case_only_routes_keyword() {
979 assert_eq!(route("memory_search"), MemoryRoute::Keyword);
980 }
981
982 #[test]
983 fn question_with_snake_case_short_routes_semantic() {
984 assert_eq!(
985 route("What does memory_search return?"),
986 MemoryRoute::Semantic
987 );
988 }
989
990 #[test]
993 fn temporal_yesterday_routes_episodic() {
994 assert_eq!(
995 route("what did we discuss yesterday"),
996 MemoryRoute::Episodic
997 );
998 }
999
1000 #[test]
1001 fn temporal_last_week_routes_episodic() {
1002 assert_eq!(
1003 route("remember what happened last week"),
1004 MemoryRoute::Episodic
1005 );
1006 }
1007
1008 #[test]
1009 fn temporal_when_did_routes_episodic() {
1010 assert_eq!(
1011 route("when did we last talk about Qdrant"),
1012 MemoryRoute::Episodic
1013 );
1014 }
1015
1016 #[test]
1017 fn temporal_last_time_routes_episodic() {
1018 assert_eq!(
1019 route("last time we discussed the scheduler"),
1020 MemoryRoute::Episodic
1021 );
1022 }
1023
1024 #[test]
1025 fn temporal_today_routes_episodic() {
1026 assert_eq!(
1027 route("what did I mention today about testing"),
1028 MemoryRoute::Episodic
1029 );
1030 }
1031
1032 #[test]
1033 fn temporal_this_morning_routes_episodic() {
1034 assert_eq!(route("what did we say this morning"), MemoryRoute::Episodic);
1035 }
1036
1037 #[test]
1038 fn temporal_last_month_routes_episodic() {
1039 assert_eq!(
1040 route("find the config change from last month"),
1041 MemoryRoute::Episodic
1042 );
1043 }
1044
1045 #[test]
1046 fn temporal_history_collision_routes_episodic() {
1047 assert_eq!(route("history of changes last week"), MemoryRoute::Episodic);
1050 }
1051
1052 #[test]
1053 fn temporal_ago_word_boundary_routes_episodic() {
1054 assert_eq!(route("we fixed this a day ago"), MemoryRoute::Episodic);
1055 }
1056
1057 #[test]
1058 fn ago_in_chicago_no_false_positive() {
1059 assert_ne!(
1062 route("meeting in Chicago about the project"),
1063 MemoryRoute::Episodic
1064 );
1065 }
1066
1067 #[test]
1068 fn non_temporal_unchanged() {
1069 assert_eq!(route("how does the agent loop work"), MemoryRoute::Semantic);
1070 }
1071
1072 #[test]
1073 fn code_query_unchanged() {
1074 assert_eq!(route("zeph_memory::recall"), MemoryRoute::Keyword);
1075 }
1076
1077 #[test]
1080 fn resolve_yesterday_range() {
1081 let now = fixed_now(); let range = resolve_temporal_range("what did we discuss yesterday", now).unwrap();
1083 assert_eq!(range.after.as_deref(), Some("2026-03-13 00:00:00"));
1084 assert_eq!(range.before.as_deref(), Some("2026-03-13 23:59:59"));
1085 }
1086
1087 #[test]
1088 fn resolve_last_week_range() {
1089 let now = fixed_now(); let range = resolve_temporal_range("remember last week's discussion", now).unwrap();
1091 assert!(range.after.as_deref().unwrap().starts_with("2026-03-07"));
1093 assert!(range.before.is_none());
1094 }
1095
1096 #[test]
1097 fn resolve_last_month_range() {
1098 let now = fixed_now();
1099 let range = resolve_temporal_range("find the bug from last month", now).unwrap();
1100 assert!(range.after.as_deref().unwrap().starts_with("2026-02-12"));
1102 assert!(range.before.is_none());
1103 }
1104
1105 #[test]
1106 fn resolve_today_range() {
1107 let now = fixed_now();
1108 let range = resolve_temporal_range("what did we do today", now).unwrap();
1109 assert_eq!(range.after.as_deref(), Some("2026-03-14 00:00:00"));
1110 assert!(range.before.is_none());
1111 }
1112
1113 #[test]
1114 fn resolve_this_morning_range() {
1115 let now = fixed_now();
1116 let range = resolve_temporal_range("what did we say this morning", now).unwrap();
1117 assert_eq!(range.after.as_deref(), Some("2026-03-14 00:00:00"));
1118 assert_eq!(range.before.as_deref(), Some("2026-03-14 12:00:00"));
1119 }
1120
1121 #[test]
1122 fn resolve_last_night_range() {
1123 let now = fixed_now();
1124 let range = resolve_temporal_range("last night's conversation", now).unwrap();
1125 assert_eq!(range.after.as_deref(), Some("2026-03-13 18:00:00"));
1126 assert_eq!(range.before.as_deref(), Some("2026-03-14 06:00:00"));
1127 }
1128
1129 #[test]
1130 fn resolve_tonight_range() {
1131 let now = fixed_now();
1132 let range = resolve_temporal_range("remind me tonight what we agreed on", now).unwrap();
1133 assert_eq!(range.after.as_deref(), Some("2026-03-14 18:00:00"));
1134 assert!(range.before.is_none());
1135 }
1136
1137 #[test]
1138 fn resolve_no_temporal_returns_none() {
1139 let now = fixed_now();
1140 assert!(resolve_temporal_range("what is the purpose of semantic memory", now).is_none());
1141 }
1142
1143 #[test]
1144 fn resolve_generic_temporal_returns_none() {
1145 let now = fixed_now();
1147 assert!(resolve_temporal_range("when did we discuss this feature", now).is_none());
1148 assert!(resolve_temporal_range("remember when we fixed that bug", now).is_none());
1149 }
1150
1151 #[test]
1154 fn strip_yesterday_from_query() {
1155 let cleaned = strip_temporal_keywords("what did we discuss yesterday about Rust");
1156 assert_eq!(cleaned, "what did we discuss about Rust");
1157 }
1158
1159 #[test]
1160 fn strip_last_week_from_query() {
1161 let cleaned = strip_temporal_keywords("find the config change from last week");
1162 assert_eq!(cleaned, "find the config change from");
1163 }
1164
1165 #[test]
1166 fn strip_does_not_alter_non_temporal() {
1167 let q = "what is the purpose of semantic memory";
1168 assert_eq!(strip_temporal_keywords(q), q);
1169 }
1170
1171 #[test]
1172 fn strip_ago_word_boundary() {
1173 let cleaned = strip_temporal_keywords("we fixed this a day ago in the scheduler");
1174 assert!(!cleaned.contains("ago"));
1176 assert!(cleaned.contains("scheduler"));
1177 }
1178
1179 #[test]
1180 fn strip_does_not_touch_chicago() {
1181 let q = "meeting in Chicago about the project";
1182 assert_eq!(strip_temporal_keywords(q), q);
1183 }
1184
1185 #[test]
1186 fn strip_empty_string_returns_empty() {
1187 assert_eq!(strip_temporal_keywords(""), "");
1188 }
1189
1190 #[test]
1191 fn strip_only_temporal_keyword_returns_empty() {
1192 assert_eq!(strip_temporal_keywords("yesterday"), "");
1195 }
1196
1197 #[test]
1198 fn strip_repeated_temporal_keyword_removes_all_occurrences() {
1199 let cleaned = strip_temporal_keywords("yesterday I mentioned yesterday's bug");
1201 assert!(
1202 !cleaned.contains("yesterday"),
1203 "both occurrences must be removed: got '{cleaned}'"
1204 );
1205 assert!(cleaned.contains("mentioned"));
1206 }
1207
1208 #[test]
1211 fn confidence_multiple_matches_is_less_than_one() {
1212 let d = HeuristicRouter.route_with_confidence("zeph_memory::recall");
1215 assert!(
1216 d.confidence < 1.0,
1217 "ambiguous query should have confidence < 1.0, got {}",
1218 d.confidence
1219 );
1220 assert_eq!(d.route, MemoryRoute::Keyword);
1221 }
1222
1223 #[test]
1224 fn confidence_long_question_with_snake_fires_multiple_signals() {
1225 let d = HeuristicRouter
1227 .route_with_confidence("what is the purpose of memory_limit in the config system");
1228 assert!(
1229 d.confidence < 1.0,
1230 "ambiguous query must have confidence < 1.0, got {}",
1231 d.confidence
1232 );
1233 }
1234
1235 #[test]
1236 fn confidence_empty_query_is_nonzero() {
1237 let d = HeuristicRouter.route_with_confidence("");
1239 assert!(
1240 d.confidence > 0.0,
1241 "empty query must match short-path signal"
1242 );
1243 }
1244
1245 #[test]
1246 fn routing_decision_route_matches_route_fn() {
1247 let queries = [
1249 "qdrant",
1250 "what is the agent loop",
1251 "context window token budget",
1252 "what did we discuss yesterday",
1253 ];
1254 for q in queries {
1255 let decision = HeuristicRouter.route_with_confidence(q);
1256 assert_eq!(
1257 decision.route,
1258 HeuristicRouter.route(q),
1259 "mismatch for query: {q}"
1260 );
1261 }
1262 }
1263}