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
711#[must_use]
712pub fn parse_route_str(s: &str, fallback: MemoryRoute) -> MemoryRoute {
713 match s {
714 "keyword" => MemoryRoute::Keyword,
715 "semantic" => MemoryRoute::Semantic,
716 "hybrid" => MemoryRoute::Hybrid,
717 "graph" => MemoryRoute::Graph,
718 "episodic" => MemoryRoute::Episodic,
719 _ => fallback,
720 }
721}
722
723impl MemoryRouter for LlmRouter {
724 fn route(&self, query: &str) -> MemoryRoute {
725 HeuristicRouter.route(query)
728 }
729
730 fn route_with_confidence(&self, query: &str) -> RoutingDecision {
731 HeuristicRouter.route_with_confidence(query)
734 }
735}
736
737pub trait AsyncMemoryRouter: MemoryRouter {
739 fn route_async<'a>(
740 &'a self,
741 query: &'a str,
742 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = RoutingDecision> + Send + 'a>>;
743}
744
745impl AsyncMemoryRouter for LlmRouter {
746 fn route_async<'a>(
747 &'a self,
748 query: &'a str,
749 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = RoutingDecision> + Send + 'a>> {
750 Box::pin(self.classify_async(query))
751 }
752}
753
754pub struct HybridRouter {
760 llm: LlmRouter,
761 confidence_threshold: f32,
762}
763
764impl HybridRouter {
765 #[must_use]
766 pub fn new(
767 provider: std::sync::Arc<zeph_llm::any::AnyProvider>,
768 fallback_route: MemoryRoute,
769 confidence_threshold: f32,
770 ) -> Self {
771 Self {
772 llm: LlmRouter::new(provider, fallback_route),
773 confidence_threshold,
774 }
775 }
776
777 pub async fn classify_async(&self, query: &str) -> RoutingDecision {
778 let heuristic = HeuristicRouter.route_with_confidence(query);
779 if heuristic.confidence >= self.confidence_threshold {
780 tracing::debug!(
781 query = &query[..query.len().min(60)],
782 confidence = heuristic.confidence,
783 route = ?heuristic.route,
784 "HybridRouter: heuristic sufficient, skipping LLM"
785 );
786 return heuristic;
787 }
788
789 tracing::debug!(
790 query = &query[..query.len().min(60)],
791 confidence = heuristic.confidence,
792 threshold = self.confidence_threshold,
793 "HybridRouter: low confidence, escalating to LLM"
794 );
795
796 let llm_result = self.llm.classify_async(query).await;
797
798 tracing::debug!(
801 route = ?llm_result.route,
802 confidence = llm_result.confidence,
803 "HybridRouter: final route after LLM escalation"
804 );
805 llm_result
806 }
807}
808
809impl MemoryRouter for HybridRouter {
810 fn route(&self, query: &str) -> MemoryRoute {
811 HeuristicRouter.route(query)
812 }
813
814 fn route_with_confidence(&self, query: &str) -> RoutingDecision {
815 HeuristicRouter.route_with_confidence(query)
817 }
818}
819
820impl AsyncMemoryRouter for HeuristicRouter {
821 fn route_async<'a>(
822 &'a self,
823 query: &'a str,
824 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = RoutingDecision> + Send + 'a>> {
825 Box::pin(std::future::ready(self.route_with_confidence(query)))
826 }
827}
828
829impl AsyncMemoryRouter for HybridRouter {
830 fn route_async<'a>(
831 &'a self,
832 query: &'a str,
833 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = RoutingDecision> + Send + 'a>> {
834 Box::pin(self.classify_async(query))
835 }
836}
837
838#[cfg(test)]
839mod tests {
840 use chrono::TimeZone as _;
841
842 use super::*;
843
844 fn route(q: &str) -> MemoryRoute {
845 HeuristicRouter.route(q)
846 }
847
848 fn fixed_now() -> DateTime<Utc> {
849 Utc.with_ymd_and_hms(2026, 3, 14, 12, 0, 0).unwrap()
851 }
852
853 #[test]
854 fn rust_path_routes_keyword() {
855 assert_eq!(route("zeph_memory::recall"), MemoryRoute::Keyword);
856 }
857
858 #[test]
859 fn file_path_routes_keyword() {
860 assert_eq!(
861 route("crates/zeph-core/src/agent/mod.rs"),
862 MemoryRoute::Keyword
863 );
864 }
865
866 #[test]
867 fn pure_snake_case_routes_keyword() {
868 assert_eq!(route("memory_limit"), MemoryRoute::Keyword);
869 assert_eq!(route("error_handling"), MemoryRoute::Keyword);
870 }
871
872 #[test]
873 fn question_with_snake_case_routes_semantic() {
874 assert_eq!(
876 route("what is the memory_limit setting"),
877 MemoryRoute::Semantic
878 );
879 assert_eq!(route("how does error_handling work"), MemoryRoute::Semantic);
880 }
881
882 #[test]
883 fn short_query_routes_keyword() {
884 assert_eq!(route("context compaction"), MemoryRoute::Keyword);
885 assert_eq!(route("qdrant"), MemoryRoute::Keyword);
886 }
887
888 #[test]
889 fn question_routes_semantic() {
890 assert_eq!(
891 route("what is the purpose of semantic memory"),
892 MemoryRoute::Semantic
893 );
894 assert_eq!(route("how does the agent loop work"), MemoryRoute::Semantic);
895 assert_eq!(route("why does compaction fail"), MemoryRoute::Semantic);
896 assert_eq!(route("explain context compression"), MemoryRoute::Semantic);
897 }
898
899 #[test]
900 fn long_natural_query_routes_semantic() {
901 assert_eq!(
902 route("the agent keeps running out of context during long conversations"),
903 MemoryRoute::Semantic
904 );
905 }
906
907 #[test]
908 fn medium_non_question_routes_hybrid() {
909 assert_eq!(route("context window token budget"), MemoryRoute::Hybrid);
911 }
912
913 #[test]
914 fn empty_query_routes_keyword() {
915 assert_eq!(route(""), MemoryRoute::Keyword);
917 }
918
919 #[test]
920 fn question_word_only_routes_semantic() {
921 assert_eq!(route("what"), MemoryRoute::Semantic);
926 }
927
928 #[test]
929 fn camel_case_does_not_route_keyword_without_pattern() {
930 assert_eq!(
933 route("SemanticMemory configuration and options"),
934 MemoryRoute::Hybrid
935 );
936 }
937
938 #[test]
939 fn relationship_query_routes_graph() {
940 assert_eq!(
941 route("what is user's opinion on neovim"),
942 MemoryRoute::Graph
943 );
944 assert_eq!(
945 route("show the relationship between Alice and Bob"),
946 MemoryRoute::Graph
947 );
948 }
949
950 #[test]
951 fn relationship_query_related_to_routes_graph() {
952 assert_eq!(
953 route("how is Rust related to this project"),
954 MemoryRoute::Graph
955 );
956 assert_eq!(
957 route("how does this relates to the config"),
958 MemoryRoute::Graph
959 );
960 }
961
962 #[test]
963 fn relationship_know_about_routes_graph() {
964 assert_eq!(route("what do I know about neovim"), MemoryRoute::Graph);
965 }
966
967 #[test]
968 fn translate_does_not_route_graph() {
969 assert_ne!(route("translate this code to Python"), MemoryRoute::Graph);
972 }
973
974 #[test]
975 fn non_relationship_stays_semantic() {
976 assert_eq!(
977 route("find similar code patterns in the codebase"),
978 MemoryRoute::Semantic
979 );
980 }
981
982 #[test]
983 fn short_keyword_unchanged() {
984 assert_eq!(route("qdrant"), MemoryRoute::Keyword);
985 }
986
987 #[test]
989 fn long_nl_with_snake_case_routes_semantic() {
990 assert_eq!(
991 route("Use memory_search to find information about Rust ownership"),
992 MemoryRoute::Semantic
993 );
994 }
995
996 #[test]
997 fn short_snake_case_only_routes_keyword() {
998 assert_eq!(route("memory_search"), MemoryRoute::Keyword);
999 }
1000
1001 #[test]
1002 fn question_with_snake_case_short_routes_semantic() {
1003 assert_eq!(
1004 route("What does memory_search return?"),
1005 MemoryRoute::Semantic
1006 );
1007 }
1008
1009 #[test]
1012 fn temporal_yesterday_routes_episodic() {
1013 assert_eq!(
1014 route("what did we discuss yesterday"),
1015 MemoryRoute::Episodic
1016 );
1017 }
1018
1019 #[test]
1020 fn temporal_last_week_routes_episodic() {
1021 assert_eq!(
1022 route("remember what happened last week"),
1023 MemoryRoute::Episodic
1024 );
1025 }
1026
1027 #[test]
1028 fn temporal_when_did_routes_episodic() {
1029 assert_eq!(
1030 route("when did we last talk about Qdrant"),
1031 MemoryRoute::Episodic
1032 );
1033 }
1034
1035 #[test]
1036 fn temporal_last_time_routes_episodic() {
1037 assert_eq!(
1038 route("last time we discussed the scheduler"),
1039 MemoryRoute::Episodic
1040 );
1041 }
1042
1043 #[test]
1044 fn temporal_today_routes_episodic() {
1045 assert_eq!(
1046 route("what did I mention today about testing"),
1047 MemoryRoute::Episodic
1048 );
1049 }
1050
1051 #[test]
1052 fn temporal_this_morning_routes_episodic() {
1053 assert_eq!(route("what did we say this morning"), MemoryRoute::Episodic);
1054 }
1055
1056 #[test]
1057 fn temporal_last_month_routes_episodic() {
1058 assert_eq!(
1059 route("find the config change from last month"),
1060 MemoryRoute::Episodic
1061 );
1062 }
1063
1064 #[test]
1065 fn temporal_history_collision_routes_episodic() {
1066 assert_eq!(route("history of changes last week"), MemoryRoute::Episodic);
1069 }
1070
1071 #[test]
1072 fn temporal_ago_word_boundary_routes_episodic() {
1073 assert_eq!(route("we fixed this a day ago"), MemoryRoute::Episodic);
1074 }
1075
1076 #[test]
1077 fn ago_in_chicago_no_false_positive() {
1078 assert_ne!(
1081 route("meeting in Chicago about the project"),
1082 MemoryRoute::Episodic
1083 );
1084 }
1085
1086 #[test]
1087 fn non_temporal_unchanged() {
1088 assert_eq!(route("how does the agent loop work"), MemoryRoute::Semantic);
1089 }
1090
1091 #[test]
1092 fn code_query_unchanged() {
1093 assert_eq!(route("zeph_memory::recall"), MemoryRoute::Keyword);
1094 }
1095
1096 #[test]
1099 fn resolve_yesterday_range() {
1100 let now = fixed_now(); let range = resolve_temporal_range("what did we discuss yesterday", now).unwrap();
1102 assert_eq!(range.after.as_deref(), Some("2026-03-13 00:00:00"));
1103 assert_eq!(range.before.as_deref(), Some("2026-03-13 23:59:59"));
1104 }
1105
1106 #[test]
1107 fn resolve_last_week_range() {
1108 let now = fixed_now(); let range = resolve_temporal_range("remember last week's discussion", now).unwrap();
1110 assert!(range.after.as_deref().unwrap().starts_with("2026-03-07"));
1112 assert!(range.before.is_none());
1113 }
1114
1115 #[test]
1116 fn resolve_last_month_range() {
1117 let now = fixed_now();
1118 let range = resolve_temporal_range("find the bug from last month", now).unwrap();
1119 assert!(range.after.as_deref().unwrap().starts_with("2026-02-12"));
1121 assert!(range.before.is_none());
1122 }
1123
1124 #[test]
1125 fn resolve_today_range() {
1126 let now = fixed_now();
1127 let range = resolve_temporal_range("what did we do today", now).unwrap();
1128 assert_eq!(range.after.as_deref(), Some("2026-03-14 00:00:00"));
1129 assert!(range.before.is_none());
1130 }
1131
1132 #[test]
1133 fn resolve_this_morning_range() {
1134 let now = fixed_now();
1135 let range = resolve_temporal_range("what did we say this morning", now).unwrap();
1136 assert_eq!(range.after.as_deref(), Some("2026-03-14 00:00:00"));
1137 assert_eq!(range.before.as_deref(), Some("2026-03-14 12:00:00"));
1138 }
1139
1140 #[test]
1141 fn resolve_last_night_range() {
1142 let now = fixed_now();
1143 let range = resolve_temporal_range("last night's conversation", now).unwrap();
1144 assert_eq!(range.after.as_deref(), Some("2026-03-13 18:00:00"));
1145 assert_eq!(range.before.as_deref(), Some("2026-03-14 06:00:00"));
1146 }
1147
1148 #[test]
1149 fn resolve_tonight_range() {
1150 let now = fixed_now();
1151 let range = resolve_temporal_range("remind me tonight what we agreed on", now).unwrap();
1152 assert_eq!(range.after.as_deref(), Some("2026-03-14 18:00:00"));
1153 assert!(range.before.is_none());
1154 }
1155
1156 #[test]
1157 fn resolve_no_temporal_returns_none() {
1158 let now = fixed_now();
1159 assert!(resolve_temporal_range("what is the purpose of semantic memory", now).is_none());
1160 }
1161
1162 #[test]
1163 fn resolve_generic_temporal_returns_none() {
1164 let now = fixed_now();
1166 assert!(resolve_temporal_range("when did we discuss this feature", now).is_none());
1167 assert!(resolve_temporal_range("remember when we fixed that bug", now).is_none());
1168 }
1169
1170 #[test]
1173 fn strip_yesterday_from_query() {
1174 let cleaned = strip_temporal_keywords("what did we discuss yesterday about Rust");
1175 assert_eq!(cleaned, "what did we discuss about Rust");
1176 }
1177
1178 #[test]
1179 fn strip_last_week_from_query() {
1180 let cleaned = strip_temporal_keywords("find the config change from last week");
1181 assert_eq!(cleaned, "find the config change from");
1182 }
1183
1184 #[test]
1185 fn strip_does_not_alter_non_temporal() {
1186 let q = "what is the purpose of semantic memory";
1187 assert_eq!(strip_temporal_keywords(q), q);
1188 }
1189
1190 #[test]
1191 fn strip_ago_word_boundary() {
1192 let cleaned = strip_temporal_keywords("we fixed this a day ago in the scheduler");
1193 assert!(!cleaned.contains("ago"));
1195 assert!(cleaned.contains("scheduler"));
1196 }
1197
1198 #[test]
1199 fn strip_does_not_touch_chicago() {
1200 let q = "meeting in Chicago about the project";
1201 assert_eq!(strip_temporal_keywords(q), q);
1202 }
1203
1204 #[test]
1205 fn strip_empty_string_returns_empty() {
1206 assert_eq!(strip_temporal_keywords(""), "");
1207 }
1208
1209 #[test]
1210 fn strip_only_temporal_keyword_returns_empty() {
1211 assert_eq!(strip_temporal_keywords("yesterday"), "");
1214 }
1215
1216 #[test]
1217 fn strip_repeated_temporal_keyword_removes_all_occurrences() {
1218 let cleaned = strip_temporal_keywords("yesterday I mentioned yesterday's bug");
1220 assert!(
1221 !cleaned.contains("yesterday"),
1222 "both occurrences must be removed: got '{cleaned}'"
1223 );
1224 assert!(cleaned.contains("mentioned"));
1225 }
1226
1227 #[test]
1230 fn confidence_multiple_matches_is_less_than_one() {
1231 let d = HeuristicRouter.route_with_confidence("zeph_memory::recall");
1234 assert!(
1235 d.confidence < 1.0,
1236 "ambiguous query should have confidence < 1.0, got {}",
1237 d.confidence
1238 );
1239 assert_eq!(d.route, MemoryRoute::Keyword);
1240 }
1241
1242 #[test]
1243 fn confidence_long_question_with_snake_fires_multiple_signals() {
1244 let d = HeuristicRouter
1246 .route_with_confidence("what is the purpose of memory_limit in the config system");
1247 assert!(
1248 d.confidence < 1.0,
1249 "ambiguous query must have confidence < 1.0, got {}",
1250 d.confidence
1251 );
1252 }
1253
1254 #[test]
1255 fn confidence_empty_query_is_nonzero() {
1256 let d = HeuristicRouter.route_with_confidence("");
1258 assert!(
1259 d.confidence > 0.0,
1260 "empty query must match short-path signal"
1261 );
1262 }
1263
1264 #[test]
1265 fn routing_decision_route_matches_route_fn() {
1266 let queries = [
1268 "qdrant",
1269 "what is the agent loop",
1270 "context window token budget",
1271 "what did we discuss yesterday",
1272 ];
1273 for q in queries {
1274 let decision = HeuristicRouter.route_with_confidence(q);
1275 assert_eq!(
1276 decision.route,
1277 HeuristicRouter.route(q),
1278 "mismatch for query: {q}"
1279 );
1280 }
1281 }
1282}