1use chrono::{DateTime, Duration, Utc};
23
24pub use zeph_common::memory::{
25 AsyncMemoryRouter, CAUSAL_MARKERS, ENTITY_MARKERS, MemoryRoute, MemoryRouter, RoutingDecision,
26 TEMPORAL_MARKERS, WORD_BOUNDARY_TEMPORAL, classify_graph_subgraph, parse_route_str,
27};
28
29#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct TemporalRange {
41 pub after: Option<String>,
43 pub before: Option<String>,
45}
46
47const TEMPORAL_PATTERNS: &[&str] = &[
57 "yesterday",
59 "today",
60 "this morning",
61 "tonight",
62 "last night",
63 "last week",
65 "this week",
66 "past week",
67 "last month",
69 "this month",
70 "past month",
71 "when did",
73 "remember when",
74 "last time",
75 "how long ago",
76 "few days ago",
79 "few hours ago",
80 "earlier today",
81];
82
83pub struct HeuristicRouter;
93
94const QUESTION_WORDS: &[&str] = &[
95 "what", "how", "why", "when", "where", "who", "which", "explain", "describe",
96];
97
98const RELATIONSHIP_PATTERNS: &[&str] = &[
101 "related to",
102 "relates to",
103 "connection between",
104 "relationship",
105 "opinion on",
106 "thinks about",
107 "preference for",
108 "history of",
109 "know about",
110];
111
112fn contains_word(text: &str, word: &str) -> bool {
117 let bytes = text.as_bytes();
118 let wbytes = word.as_bytes();
119 let wlen = wbytes.len();
120 if wlen > bytes.len() {
121 return false;
122 }
123 for start in 0..=(bytes.len() - wlen) {
124 if bytes[start..start + wlen].eq_ignore_ascii_case(wbytes) {
125 let before_ok =
126 start == 0 || !bytes[start - 1].is_ascii_alphanumeric() && bytes[start - 1] != b'_';
127 let after_ok = start + wlen == bytes.len()
128 || !bytes[start + wlen].is_ascii_alphanumeric() && bytes[start + wlen] != b'_';
129 if before_ok && after_ok {
130 return true;
131 }
132 }
133 }
134 false
135}
136
137fn has_temporal_cue(lower: &str) -> bool {
140 if TEMPORAL_PATTERNS.iter().any(|p| lower.contains(p)) {
141 return true;
142 }
143 WORD_BOUNDARY_TEMPORAL
144 .iter()
145 .any(|w| contains_word(lower, w))
146}
147
148static SORTED_TEMPORAL_PATTERNS: std::sync::LazyLock<Vec<&'static str>> =
151 std::sync::LazyLock::new(|| {
152 let mut v: Vec<&str> = TEMPORAL_PATTERNS.to_vec();
153 v.sort_by_key(|p| std::cmp::Reverse(p.len()));
154 v
155 });
156
157#[must_use]
174pub fn strip_temporal_keywords(query: &str) -> String {
175 let lower = query.to_ascii_lowercase();
180 let mut remove: Vec<(usize, usize)> = Vec::new();
182
183 for pattern in SORTED_TEMPORAL_PATTERNS.iter() {
184 let plen = pattern.len();
185 let mut search_from = 0;
186 while let Some(pos) = lower[search_from..].find(pattern) {
187 let abs = search_from + pos;
188 remove.push((abs, abs + plen));
189 search_from = abs + plen;
190 }
191 }
192
193 for word in WORD_BOUNDARY_TEMPORAL {
195 let wlen = word.len();
196 let lbytes = lower.as_bytes();
197 let mut i = 0;
198 while i + wlen <= lower.len() {
199 if lower[i..].starts_with(*word) {
200 let before_ok =
201 i == 0 || !lbytes[i - 1].is_ascii_alphanumeric() && lbytes[i - 1] != b'_';
202 let after_ok = i + wlen == lower.len()
203 || !lbytes[i + wlen].is_ascii_alphanumeric() && lbytes[i + wlen] != b'_';
204 if before_ok && after_ok {
205 remove.push((i, i + wlen));
206 i += wlen;
207 continue;
208 }
209 }
210 i += 1;
211 }
212 }
213
214 if remove.is_empty() {
215 return query.split_whitespace().collect::<Vec<_>>().join(" ");
217 }
218
219 remove.sort_unstable_by_key(|r| r.0);
221 let bytes = query.as_bytes();
222 let mut result = Vec::with_capacity(query.len());
223 let mut cursor = 0;
224 for (start, end) in remove {
225 if start > cursor {
226 result.extend_from_slice(&bytes[cursor..start]);
227 }
228 cursor = cursor.max(end);
229 }
230 if cursor < bytes.len() {
231 result.extend_from_slice(&bytes[cursor..]);
232 }
233
234 let s = String::from_utf8(result).unwrap_or_default();
237 s.split_whitespace()
238 .filter(|t| !t.is_empty())
239 .collect::<Vec<_>>()
240 .join(" ")
241}
242
243#[must_use]
253pub fn resolve_temporal_range(query: &str, now: DateTime<Utc>) -> Option<TemporalRange> {
254 let lower = query.to_ascii_lowercase();
255
256 if lower.contains("yesterday") {
258 let yesterday = now.date_naive() - Duration::days(1);
259 return Some(TemporalRange {
260 after: Some(format!("{yesterday} 00:00:00")),
261 before: Some(format!("{yesterday} 23:59:59")),
262 });
263 }
264
265 if lower.contains("last night") {
267 let yesterday = now.date_naive() - Duration::days(1);
268 let today = now.date_naive();
269 return Some(TemporalRange {
270 after: Some(format!("{yesterday} 18:00:00")),
271 before: Some(format!("{today} 06:00:00")),
272 });
273 }
274
275 if lower.contains("tonight") {
277 let today = now.date_naive();
278 return Some(TemporalRange {
279 after: Some(format!("{today} 18:00:00")),
280 before: None,
281 });
282 }
283
284 if lower.contains("this morning") {
286 let today = now.date_naive();
287 return Some(TemporalRange {
288 after: Some(format!("{today} 00:00:00")),
289 before: Some(format!("{today} 12:00:00")),
290 });
291 }
292
293 if lower.contains("today") {
297 let today = now.date_naive();
298 return Some(TemporalRange {
299 after: Some(format!("{today} 00:00:00")),
300 before: None,
301 });
302 }
303
304 if lower.contains("last week") || lower.contains("past week") || lower.contains("this week") {
306 let start = now - Duration::days(7);
307 return Some(TemporalRange {
308 after: Some(start.format("%Y-%m-%d %H:%M:%S").to_string()),
309 before: None,
310 });
311 }
312
313 if lower.contains("last month") || lower.contains("past month") || lower.contains("this month")
315 {
316 let start = now - Duration::days(30);
317 return Some(TemporalRange {
318 after: Some(start.format("%Y-%m-%d %H:%M:%S").to_string()),
319 before: None,
320 });
321 }
322
323 if lower.contains("few days ago") {
325 let start = now - Duration::days(3);
326 return Some(TemporalRange {
327 after: Some(start.format("%Y-%m-%d %H:%M:%S").to_string()),
328 before: None,
329 });
330 }
331 if lower.contains("few hours ago") {
332 let start = now - Duration::hours(6);
333 return Some(TemporalRange {
334 after: Some(start.format("%Y-%m-%d %H:%M:%S").to_string()),
335 before: None,
336 });
337 }
338
339 if contains_word(&lower, "ago") {
341 let start = now - Duration::hours(24);
342 return Some(TemporalRange {
343 after: Some(start.format("%Y-%m-%d %H:%M:%S").to_string()),
344 before: None,
345 });
346 }
347
348 None
351}
352
353fn starts_with_question(words: &[&str]) -> bool {
354 words
355 .first()
356 .is_some_and(|w| QUESTION_WORDS.iter().any(|qw| w.eq_ignore_ascii_case(qw)))
357}
358
359fn is_pure_snake_case(word: &str) -> bool {
362 if word.is_empty() {
363 return false;
364 }
365 let has_underscore = word.contains('_');
366 if !has_underscore {
367 return false;
368 }
369 word.chars()
370 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
371 && !word.chars().all(|c| c.is_ascii_digit() || c == '_')
372}
373
374impl MemoryRouter for HeuristicRouter {
375 fn route_with_confidence(&self, query: &str) -> RoutingDecision {
381 let lower = query.to_ascii_lowercase();
382 let mut matched: u32 = 0;
383 if has_temporal_cue(&lower) {
384 matched += 1;
385 }
386 if RELATIONSHIP_PATTERNS.iter().any(|p| lower.contains(p)) {
387 matched += 1;
388 }
389 let words: Vec<&str> = query.split_whitespace().collect();
390 let word_count = words.len();
391 let has_structural = query.contains('/') || query.contains("::");
392 let question = starts_with_question(&words);
393 let has_snake = words.iter().any(|w| is_pure_snake_case(w));
394 if has_structural && !question {
395 matched += 1;
396 }
397 if question || word_count >= 6 {
398 matched += 1;
399 }
400 if word_count <= 3 && !question {
401 matched += 1;
402 }
403 if has_snake {
404 matched += 1;
405 }
406
407 #[allow(clippy::cast_precision_loss)]
408 let confidence = match matched {
409 0 => 0.0,
410 1 => 1.0,
411 n => 1.0 / n as f32,
412 };
413
414 RoutingDecision {
415 route: self.route(query),
416 confidence,
417 reasoning: None,
418 }
419 }
420
421 fn route(&self, query: &str) -> MemoryRoute {
422 let lower = query.to_ascii_lowercase();
423
424 if has_temporal_cue(&lower) {
427 return MemoryRoute::Episodic;
428 }
429
430 let has_relationship = RELATIONSHIP_PATTERNS.iter().any(|p| lower.contains(p));
432 if has_relationship {
433 return MemoryRoute::Graph;
434 }
435
436 let words: Vec<&str> = query.split_whitespace().collect();
437 let word_count = words.len();
438
439 let has_structural_code_pattern = query.contains('/') || query.contains("::");
442
443 let has_snake_case = words.iter().any(|w| is_pure_snake_case(w));
446 let question = starts_with_question(&words);
447
448 if has_structural_code_pattern && !question {
449 return MemoryRoute::Keyword;
450 }
451
452 if question || word_count >= 6 {
454 return MemoryRoute::Semantic;
455 }
456
457 if word_count <= 3 && !question {
459 return MemoryRoute::Keyword;
460 }
461
462 if has_snake_case {
464 return MemoryRoute::Keyword;
465 }
466
467 MemoryRoute::Hybrid
469 }
470}
471
472pub struct LlmRouter {
479 provider: std::sync::Arc<zeph_llm::any::AnyProvider>,
480 fallback_route: MemoryRoute,
481}
482
483impl LlmRouter {
484 #[must_use]
489 pub fn new(
490 provider: std::sync::Arc<zeph_llm::any::AnyProvider>,
491 fallback_route: MemoryRoute,
492 ) -> Self {
493 Self {
494 provider,
495 fallback_route,
496 }
497 }
498
499 async fn classify_async(&self, query: &str) -> RoutingDecision {
500 use zeph_llm::provider::{LlmProvider as _, Message, MessageMetadata, Role};
501
502 let system = "You are a memory store routing classifier. \
503 Given a user query, decide which memory backend is most appropriate. \
504 Respond with ONLY a JSON object: \
505 {\"route\": \"<route>\", \"confidence\": <0.0-1.0>, \"reasoning\": \"<brief>\"} \
506 where <route> is one of: keyword, semantic, hybrid, graph, episodic. \
507 Use 'keyword' for exact/code lookups, 'semantic' for conceptual questions, \
508 'hybrid' for mixed, 'graph' for relationship queries, 'episodic' for time-scoped queries.";
509
510 let user = format!(
512 "<query>{}</query>",
513 query.chars().take(500).collect::<String>()
514 );
515
516 let messages = vec![
517 Message {
518 role: Role::System,
519 content: system.to_owned(),
520 parts: vec![],
521 metadata: MessageMetadata::default(),
522 },
523 Message {
524 role: Role::User,
525 content: user,
526 parts: vec![],
527 metadata: MessageMetadata::default(),
528 },
529 ];
530
531 let result = match tokio::time::timeout(
532 std::time::Duration::from_secs(5),
533 self.provider.chat(&messages),
534 )
535 .await
536 {
537 Ok(Ok(r)) => r,
538 Ok(Err(e)) => {
539 tracing::debug!(error = %e, "LlmRouter: LLM call failed, falling back to heuristic");
540 return Self::heuristic_fallback(query);
541 }
542 Err(_) => {
543 tracing::debug!("LlmRouter: LLM timed out, falling back to heuristic");
544 return Self::heuristic_fallback(query);
545 }
546 };
547
548 self.parse_llm_response(&result, query)
549 }
550
551 fn parse_llm_response(&self, raw: &str, query: &str) -> RoutingDecision {
552 let json_str = raw
554 .find('{')
555 .and_then(|start| raw[start..].rfind('}').map(|end| &raw[start..=start + end]))
556 .unwrap_or("");
557
558 if let Ok(v) = serde_json::from_str::<serde_json::Value>(json_str) {
559 let route_str = v.get("route").and_then(|r| r.as_str()).unwrap_or("hybrid");
560 #[allow(clippy::cast_possible_truncation)]
561 let confidence = v
562 .get("confidence")
563 .and_then(serde_json::Value::as_f64)
564 .map_or(0.5, |c| c.clamp(0.0, 1.0) as f32);
565 let reasoning = v
566 .get("reasoning")
567 .and_then(|r| r.as_str())
568 .map(str::to_owned);
569
570 let route = parse_route_str(route_str, self.fallback_route);
571
572 tracing::debug!(
573 query = &query[..query.len().min(60)],
574 ?route,
575 confidence,
576 "LlmRouter: classified"
577 );
578
579 return RoutingDecision {
580 route,
581 confidence,
582 reasoning,
583 };
584 }
585
586 tracing::debug!("LlmRouter: failed to parse JSON response, falling back to heuristic");
587 Self::heuristic_fallback(query)
588 }
589
590 fn heuristic_fallback(query: &str) -> RoutingDecision {
591 HeuristicRouter.route_with_confidence(query)
592 }
593}
594
595impl MemoryRouter for LlmRouter {
596 fn route(&self, query: &str) -> MemoryRoute {
597 HeuristicRouter.route(query)
600 }
601
602 fn route_with_confidence(&self, query: &str) -> RoutingDecision {
603 HeuristicRouter.route_with_confidence(query)
606 }
607}
608
609impl AsyncMemoryRouter for LlmRouter {
610 fn route_async<'a>(
611 &'a self,
612 query: &'a str,
613 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = RoutingDecision> + Send + 'a>> {
614 Box::pin(self.classify_async(query))
615 }
616}
617
618pub struct HybridRouter {
624 llm: LlmRouter,
625 confidence_threshold: f32,
626}
627
628impl HybridRouter {
629 #[must_use]
634 pub fn new(
635 provider: std::sync::Arc<zeph_llm::any::AnyProvider>,
636 fallback_route: MemoryRoute,
637 confidence_threshold: f32,
638 ) -> Self {
639 Self {
640 llm: LlmRouter::new(provider, fallback_route),
641 confidence_threshold,
642 }
643 }
644
645 pub async fn classify_async(&self, query: &str) -> RoutingDecision {
646 let heuristic = HeuristicRouter.route_with_confidence(query);
647 if heuristic.confidence >= self.confidence_threshold {
648 tracing::debug!(
649 query = &query[..query.len().min(60)],
650 confidence = heuristic.confidence,
651 route = ?heuristic.route,
652 "HybridRouter: heuristic sufficient, skipping LLM"
653 );
654 return heuristic;
655 }
656
657 tracing::debug!(
658 query = &query[..query.len().min(60)],
659 confidence = heuristic.confidence,
660 threshold = self.confidence_threshold,
661 "HybridRouter: low confidence, escalating to LLM"
662 );
663
664 let llm_result = self.llm.classify_async(query).await;
665
666 tracing::debug!(
669 route = ?llm_result.route,
670 confidence = llm_result.confidence,
671 "HybridRouter: final route after LLM escalation"
672 );
673 llm_result
674 }
675}
676
677impl MemoryRouter for HybridRouter {
678 fn route(&self, query: &str) -> MemoryRoute {
679 HeuristicRouter.route(query)
680 }
681
682 fn route_with_confidence(&self, query: &str) -> RoutingDecision {
683 HeuristicRouter.route_with_confidence(query)
685 }
686}
687
688impl AsyncMemoryRouter for HeuristicRouter {
689 fn route_async<'a>(
690 &'a self,
691 query: &'a str,
692 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = RoutingDecision> + Send + 'a>> {
693 Box::pin(std::future::ready(self.route_with_confidence(query)))
694 }
695}
696
697impl AsyncMemoryRouter for HybridRouter {
698 fn route_async<'a>(
699 &'a self,
700 query: &'a str,
701 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = RoutingDecision> + Send + 'a>> {
702 Box::pin(self.classify_async(query))
703 }
704}
705
706#[cfg(test)]
707mod tests {
708 use chrono::TimeZone as _;
709
710 use super::*;
711
712 fn route(q: &str) -> MemoryRoute {
713 HeuristicRouter.route(q)
714 }
715
716 fn fixed_now() -> DateTime<Utc> {
717 Utc.with_ymd_and_hms(2026, 3, 14, 12, 0, 0).unwrap()
719 }
720
721 #[test]
722 fn rust_path_routes_keyword() {
723 assert_eq!(route("zeph_memory::recall"), MemoryRoute::Keyword);
724 }
725
726 #[test]
727 fn file_path_routes_keyword() {
728 assert_eq!(
729 route("crates/zeph-core/src/agent/mod.rs"),
730 MemoryRoute::Keyword
731 );
732 }
733
734 #[test]
735 fn pure_snake_case_routes_keyword() {
736 assert_eq!(route("memory_limit"), MemoryRoute::Keyword);
737 assert_eq!(route("error_handling"), MemoryRoute::Keyword);
738 }
739
740 #[test]
741 fn question_with_snake_case_routes_semantic() {
742 assert_eq!(
744 route("what is the memory_limit setting"),
745 MemoryRoute::Semantic
746 );
747 assert_eq!(route("how does error_handling work"), MemoryRoute::Semantic);
748 }
749
750 #[test]
751 fn short_query_routes_keyword() {
752 assert_eq!(route("context compaction"), MemoryRoute::Keyword);
753 assert_eq!(route("qdrant"), MemoryRoute::Keyword);
754 }
755
756 #[test]
757 fn question_routes_semantic() {
758 assert_eq!(
759 route("what is the purpose of semantic memory"),
760 MemoryRoute::Semantic
761 );
762 assert_eq!(route("how does the agent loop work"), MemoryRoute::Semantic);
763 assert_eq!(route("why does compaction fail"), MemoryRoute::Semantic);
764 assert_eq!(route("explain context compression"), MemoryRoute::Semantic);
765 }
766
767 #[test]
768 fn long_natural_query_routes_semantic() {
769 assert_eq!(
770 route("the agent keeps running out of context during long conversations"),
771 MemoryRoute::Semantic
772 );
773 }
774
775 #[test]
776 fn medium_non_question_routes_hybrid() {
777 assert_eq!(route("context window token budget"), MemoryRoute::Hybrid);
779 }
780
781 #[test]
782 fn empty_query_routes_keyword() {
783 assert_eq!(route(""), MemoryRoute::Keyword);
785 }
786
787 #[test]
788 fn question_word_only_routes_semantic() {
789 assert_eq!(route("what"), MemoryRoute::Semantic);
794 }
795
796 #[test]
797 fn camel_case_does_not_route_keyword_without_pattern() {
798 assert_eq!(
801 route("SemanticMemory configuration and options"),
802 MemoryRoute::Hybrid
803 );
804 }
805
806 #[test]
807 fn relationship_query_routes_graph() {
808 assert_eq!(
809 route("what is user's opinion on neovim"),
810 MemoryRoute::Graph
811 );
812 assert_eq!(
813 route("show the relationship between Alice and Bob"),
814 MemoryRoute::Graph
815 );
816 }
817
818 #[test]
819 fn relationship_query_related_to_routes_graph() {
820 assert_eq!(
821 route("how is Rust related to this project"),
822 MemoryRoute::Graph
823 );
824 assert_eq!(
825 route("how does this relates to the config"),
826 MemoryRoute::Graph
827 );
828 }
829
830 #[test]
831 fn relationship_know_about_routes_graph() {
832 assert_eq!(route("what do I know about neovim"), MemoryRoute::Graph);
833 }
834
835 #[test]
836 fn translate_does_not_route_graph() {
837 assert_ne!(route("translate this code to Python"), MemoryRoute::Graph);
840 }
841
842 #[test]
843 fn non_relationship_stays_semantic() {
844 assert_eq!(
845 route("find similar code patterns in the codebase"),
846 MemoryRoute::Semantic
847 );
848 }
849
850 #[test]
851 fn short_keyword_unchanged() {
852 assert_eq!(route("qdrant"), MemoryRoute::Keyword);
853 }
854
855 #[test]
857 fn long_nl_with_snake_case_routes_semantic() {
858 assert_eq!(
859 route("Use memory_search to find information about Rust ownership"),
860 MemoryRoute::Semantic
861 );
862 }
863
864 #[test]
865 fn short_snake_case_only_routes_keyword() {
866 assert_eq!(route("memory_search"), MemoryRoute::Keyword);
867 }
868
869 #[test]
870 fn question_with_snake_case_short_routes_semantic() {
871 assert_eq!(
872 route("What does memory_search return?"),
873 MemoryRoute::Semantic
874 );
875 }
876
877 #[test]
880 fn temporal_yesterday_routes_episodic() {
881 assert_eq!(
882 route("what did we discuss yesterday"),
883 MemoryRoute::Episodic
884 );
885 }
886
887 #[test]
888 fn temporal_last_week_routes_episodic() {
889 assert_eq!(
890 route("remember what happened last week"),
891 MemoryRoute::Episodic
892 );
893 }
894
895 #[test]
896 fn temporal_when_did_routes_episodic() {
897 assert_eq!(
898 route("when did we last talk about Qdrant"),
899 MemoryRoute::Episodic
900 );
901 }
902
903 #[test]
904 fn temporal_last_time_routes_episodic() {
905 assert_eq!(
906 route("last time we discussed the scheduler"),
907 MemoryRoute::Episodic
908 );
909 }
910
911 #[test]
912 fn temporal_today_routes_episodic() {
913 assert_eq!(
914 route("what did I mention today about testing"),
915 MemoryRoute::Episodic
916 );
917 }
918
919 #[test]
920 fn temporal_this_morning_routes_episodic() {
921 assert_eq!(route("what did we say this morning"), MemoryRoute::Episodic);
922 }
923
924 #[test]
925 fn temporal_last_month_routes_episodic() {
926 assert_eq!(
927 route("find the config change from last month"),
928 MemoryRoute::Episodic
929 );
930 }
931
932 #[test]
933 fn temporal_history_collision_routes_episodic() {
934 assert_eq!(route("history of changes last week"), MemoryRoute::Episodic);
937 }
938
939 #[test]
940 fn temporal_ago_word_boundary_routes_episodic() {
941 assert_eq!(route("we fixed this a day ago"), MemoryRoute::Episodic);
942 }
943
944 #[test]
945 fn ago_in_chicago_no_false_positive() {
946 assert_ne!(
949 route("meeting in Chicago about the project"),
950 MemoryRoute::Episodic
951 );
952 }
953
954 #[test]
955 fn non_temporal_unchanged() {
956 assert_eq!(route("how does the agent loop work"), MemoryRoute::Semantic);
957 }
958
959 #[test]
960 fn code_query_unchanged() {
961 assert_eq!(route("zeph_memory::recall"), MemoryRoute::Keyword);
962 }
963
964 #[test]
967 fn resolve_yesterday_range() {
968 let now = fixed_now(); let range = resolve_temporal_range("what did we discuss yesterday", now).unwrap();
970 assert_eq!(range.after.as_deref(), Some("2026-03-13 00:00:00"));
971 assert_eq!(range.before.as_deref(), Some("2026-03-13 23:59:59"));
972 }
973
974 #[test]
975 fn resolve_last_week_range() {
976 let now = fixed_now(); let range = resolve_temporal_range("remember last week's discussion", now).unwrap();
978 assert!(range.after.as_deref().unwrap().starts_with("2026-03-07"));
980 assert!(range.before.is_none());
981 }
982
983 #[test]
984 fn resolve_last_month_range() {
985 let now = fixed_now();
986 let range = resolve_temporal_range("find the bug from last month", now).unwrap();
987 assert!(range.after.as_deref().unwrap().starts_with("2026-02-12"));
989 assert!(range.before.is_none());
990 }
991
992 #[test]
993 fn resolve_today_range() {
994 let now = fixed_now();
995 let range = resolve_temporal_range("what did we do today", now).unwrap();
996 assert_eq!(range.after.as_deref(), Some("2026-03-14 00:00:00"));
997 assert!(range.before.is_none());
998 }
999
1000 #[test]
1001 fn resolve_this_morning_range() {
1002 let now = fixed_now();
1003 let range = resolve_temporal_range("what did we say this morning", now).unwrap();
1004 assert_eq!(range.after.as_deref(), Some("2026-03-14 00:00:00"));
1005 assert_eq!(range.before.as_deref(), Some("2026-03-14 12:00:00"));
1006 }
1007
1008 #[test]
1009 fn resolve_last_night_range() {
1010 let now = fixed_now();
1011 let range = resolve_temporal_range("last night's conversation", now).unwrap();
1012 assert_eq!(range.after.as_deref(), Some("2026-03-13 18:00:00"));
1013 assert_eq!(range.before.as_deref(), Some("2026-03-14 06:00:00"));
1014 }
1015
1016 #[test]
1017 fn resolve_tonight_range() {
1018 let now = fixed_now();
1019 let range = resolve_temporal_range("remind me tonight what we agreed on", now).unwrap();
1020 assert_eq!(range.after.as_deref(), Some("2026-03-14 18:00:00"));
1021 assert!(range.before.is_none());
1022 }
1023
1024 #[test]
1025 fn resolve_no_temporal_returns_none() {
1026 let now = fixed_now();
1027 assert!(resolve_temporal_range("what is the purpose of semantic memory", now).is_none());
1028 }
1029
1030 #[test]
1031 fn resolve_generic_temporal_returns_none() {
1032 let now = fixed_now();
1034 assert!(resolve_temporal_range("when did we discuss this feature", now).is_none());
1035 assert!(resolve_temporal_range("remember when we fixed that bug", now).is_none());
1036 }
1037
1038 #[test]
1041 fn strip_yesterday_from_query() {
1042 let cleaned = strip_temporal_keywords("what did we discuss yesterday about Rust");
1043 assert_eq!(cleaned, "what did we discuss about Rust");
1044 }
1045
1046 #[test]
1047 fn strip_last_week_from_query() {
1048 let cleaned = strip_temporal_keywords("find the config change from last week");
1049 assert_eq!(cleaned, "find the config change from");
1050 }
1051
1052 #[test]
1053 fn strip_does_not_alter_non_temporal() {
1054 let q = "what is the purpose of semantic memory";
1055 assert_eq!(strip_temporal_keywords(q), q);
1056 }
1057
1058 #[test]
1059 fn strip_ago_word_boundary() {
1060 let cleaned = strip_temporal_keywords("we fixed this a day ago in the scheduler");
1061 assert!(!cleaned.contains("ago"));
1063 assert!(cleaned.contains("scheduler"));
1064 }
1065
1066 #[test]
1067 fn strip_does_not_touch_chicago() {
1068 let q = "meeting in Chicago about the project";
1069 assert_eq!(strip_temporal_keywords(q), q);
1070 }
1071
1072 #[test]
1073 fn strip_empty_string_returns_empty() {
1074 assert_eq!(strip_temporal_keywords(""), "");
1075 }
1076
1077 #[test]
1078 fn strip_only_temporal_keyword_returns_empty() {
1079 assert_eq!(strip_temporal_keywords("yesterday"), "");
1082 }
1083
1084 #[test]
1085 fn strip_repeated_temporal_keyword_removes_all_occurrences() {
1086 let cleaned = strip_temporal_keywords("yesterday I mentioned yesterday's bug");
1088 assert!(
1089 !cleaned.contains("yesterday"),
1090 "both occurrences must be removed: got '{cleaned}'"
1091 );
1092 assert!(cleaned.contains("mentioned"));
1093 }
1094
1095 #[test]
1098 fn confidence_multiple_matches_is_less_than_one() {
1099 let d = HeuristicRouter.route_with_confidence("zeph_memory::recall");
1102 assert!(
1103 d.confidence < 1.0,
1104 "ambiguous query should have confidence < 1.0, got {}",
1105 d.confidence
1106 );
1107 assert_eq!(d.route, MemoryRoute::Keyword);
1108 }
1109
1110 #[test]
1111 fn confidence_long_question_with_snake_fires_multiple_signals() {
1112 let d = HeuristicRouter
1114 .route_with_confidence("what is the purpose of memory_limit in the config system");
1115 assert!(
1116 d.confidence < 1.0,
1117 "ambiguous query must have confidence < 1.0, got {}",
1118 d.confidence
1119 );
1120 }
1121
1122 #[test]
1123 fn confidence_empty_query_is_nonzero() {
1124 let d = HeuristicRouter.route_with_confidence("");
1126 assert!(
1127 d.confidence > 0.0,
1128 "empty query must match short-path signal"
1129 );
1130 }
1131
1132 #[test]
1133 fn routing_decision_route_matches_route_fn() {
1134 let queries = [
1136 "qdrant",
1137 "what is the agent loop",
1138 "context window token budget",
1139 "what did we discuss yesterday",
1140 ];
1141 for q in queries {
1142 let decision = HeuristicRouter.route_with_confidence(q);
1143 assert_eq!(
1144 decision.route,
1145 HeuristicRouter.route(q),
1146 "mismatch for query: {q}"
1147 );
1148 }
1149 }
1150}