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
359#[allow(clippy::struct_excessive_bools)] struct RouteSignals {
363 temporal: bool,
364 relationship: bool,
365 structural_code: bool,
366 question: bool,
367 word_count: usize,
368 snake_case: bool,
369}
370
371fn compute_signals(query: &str) -> RouteSignals {
373 let lower = query.to_ascii_lowercase();
374 let words: Vec<&str> = query.split_whitespace().collect();
375 RouteSignals {
376 temporal: has_temporal_cue(&lower),
377 relationship: RELATIONSHIP_PATTERNS.iter().any(|p| lower.contains(p)),
378 structural_code: query.contains('/') || query.contains("::"),
379 question: starts_with_question(&words),
380 word_count: words.len(),
381 snake_case: words.iter().any(|w| is_pure_snake_case(w)),
382 }
383}
384
385fn is_pure_snake_case(word: &str) -> bool {
388 if word.is_empty() {
389 return false;
390 }
391 let has_underscore = word.contains('_');
392 if !has_underscore {
393 return false;
394 }
395 word.chars()
396 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
397 && !word.chars().all(|c| c.is_ascii_digit() || c == '_')
398}
399
400impl HeuristicRouter {
401 fn decide_route(signals: &RouteSignals) -> MemoryRoute {
413 if signals.temporal {
414 return MemoryRoute::Episodic;
415 }
416 if signals.relationship {
417 return MemoryRoute::Graph;
418 }
419 if signals.structural_code && !signals.question {
420 return MemoryRoute::Keyword;
421 }
422 if signals.question || signals.word_count >= 6 {
423 return MemoryRoute::Semantic;
424 }
425 if signals.word_count <= 3 && !signals.question {
426 return MemoryRoute::Keyword;
427 }
428 if signals.snake_case {
429 return MemoryRoute::Keyword;
430 }
431 MemoryRoute::Hybrid
432 }
433
434 #[allow(clippy::cast_precision_loss)]
440 fn decide_confidence(signals: &RouteSignals) -> f32 {
441 let mut matched: u32 = 0;
442 if signals.temporal {
443 matched += 1;
444 }
445 if signals.relationship {
446 matched += 1;
447 }
448 if signals.structural_code && !signals.question {
449 matched += 1;
450 }
451 if signals.question || signals.word_count >= 6 {
452 matched += 1;
453 }
454 if signals.word_count <= 3 && !signals.question {
455 matched += 1;
456 }
457 if signals.snake_case {
458 matched += 1;
459 }
460
461 match matched {
462 0 => 0.0,
463 1 => 1.0,
464 n => 1.0 / n as f32,
465 }
466 }
467}
468
469impl MemoryRouter for HeuristicRouter {
470 fn route_with_confidence(&self, query: &str) -> RoutingDecision {
471 let signals = compute_signals(query);
472 RoutingDecision {
473 route: Self::decide_route(&signals),
474 confidence: Self::decide_confidence(&signals),
475 reasoning: None,
476 }
477 }
478
479 fn route(&self, query: &str) -> MemoryRoute {
480 Self::decide_route(&compute_signals(query))
481 }
482}
483
484pub struct LlmRouter {
491 provider: std::sync::Arc<zeph_llm::any::AnyProvider>,
492 fallback_route: MemoryRoute,
493}
494
495impl LlmRouter {
496 #[must_use]
501 pub fn new(
502 provider: std::sync::Arc<zeph_llm::any::AnyProvider>,
503 fallback_route: MemoryRoute,
504 ) -> Self {
505 Self {
506 provider,
507 fallback_route,
508 }
509 }
510
511 async fn classify_async(&self, query: &str) -> RoutingDecision {
512 use zeph_llm::provider::{LlmProvider as _, Message, MessageMetadata, Role};
513
514 let system = "You are a memory store routing classifier. \
515 Given a user query, decide which memory backend is most appropriate. \
516 Respond with ONLY a JSON object: \
517 {\"route\": \"<route>\", \"confidence\": <0.0-1.0>, \"reasoning\": \"<brief>\"} \
518 where <route> is one of: keyword, semantic, hybrid, graph, episodic. \
519 Use 'keyword' for exact/code lookups, 'semantic' for conceptual questions, \
520 'hybrid' for mixed, 'graph' for relationship queries, 'episodic' for time-scoped queries.";
521
522 let user = format!(
524 "<query>{}</query>",
525 query.chars().take(500).collect::<String>()
526 );
527
528 let messages = vec![
529 Message {
530 role: Role::System,
531 content: system.to_owned(),
532 parts: vec![],
533 metadata: MessageMetadata::default(),
534 },
535 Message {
536 role: Role::User,
537 content: user,
538 parts: vec![],
539 metadata: MessageMetadata::default(),
540 },
541 ];
542
543 let result = match tokio::time::timeout(
544 std::time::Duration::from_secs(5),
545 self.provider.chat(&messages),
546 )
547 .await
548 {
549 Ok(Ok(r)) => r,
550 Ok(Err(e)) => {
551 tracing::debug!(error = %e, "LlmRouter: LLM call failed, falling back to heuristic");
552 return Self::heuristic_fallback(query);
553 }
554 Err(_) => {
555 tracing::debug!("LlmRouter: LLM timed out, falling back to heuristic");
556 return Self::heuristic_fallback(query);
557 }
558 };
559
560 self.parse_llm_response(&result, query)
561 }
562
563 fn parse_llm_response(&self, raw: &str, query: &str) -> RoutingDecision {
564 let json_str = raw
566 .find('{')
567 .and_then(|start| raw[start..].rfind('}').map(|end| &raw[start..=start + end]))
568 .unwrap_or("");
569
570 if let Ok(v) = serde_json::from_str::<serde_json::Value>(json_str) {
571 let route_str = v.get("route").and_then(|r| r.as_str()).unwrap_or("hybrid");
572 #[allow(clippy::cast_possible_truncation)]
573 let confidence = v
574 .get("confidence")
575 .and_then(serde_json::Value::as_f64)
576 .map_or(0.5, |c| c.clamp(0.0, 1.0) as f32);
577 let reasoning = v
578 .get("reasoning")
579 .and_then(|r| r.as_str())
580 .map(str::to_owned);
581
582 let route = parse_route_str(route_str, self.fallback_route);
583
584 tracing::debug!(
585 query = &query[..query.len().min(60)],
586 ?route,
587 confidence,
588 "LlmRouter: classified"
589 );
590
591 return RoutingDecision {
592 route,
593 confidence,
594 reasoning,
595 };
596 }
597
598 tracing::debug!("LlmRouter: failed to parse JSON response, falling back to heuristic");
599 Self::heuristic_fallback(query)
600 }
601
602 fn heuristic_fallback(query: &str) -> RoutingDecision {
603 HeuristicRouter.route_with_confidence(query)
604 }
605}
606
607impl MemoryRouter for LlmRouter {
608 fn route(&self, query: &str) -> MemoryRoute {
609 HeuristicRouter.route(query)
612 }
613
614 fn route_with_confidence(&self, query: &str) -> RoutingDecision {
615 HeuristicRouter.route_with_confidence(query)
618 }
619}
620
621impl AsyncMemoryRouter for LlmRouter {
622 fn route_async<'a>(
623 &'a self,
624 query: &'a str,
625 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = RoutingDecision> + Send + 'a>> {
626 Box::pin(self.classify_async(query))
627 }
628}
629
630pub struct HybridRouter {
636 llm: LlmRouter,
637 confidence_threshold: f32,
638}
639
640impl HybridRouter {
641 #[must_use]
646 pub fn new(
647 provider: std::sync::Arc<zeph_llm::any::AnyProvider>,
648 fallback_route: MemoryRoute,
649 confidence_threshold: f32,
650 ) -> Self {
651 Self {
652 llm: LlmRouter::new(provider, fallback_route),
653 confidence_threshold,
654 }
655 }
656
657 pub async fn classify_async(&self, query: &str) -> RoutingDecision {
658 let heuristic = HeuristicRouter.route_with_confidence(query);
659 if heuristic.confidence >= self.confidence_threshold {
660 tracing::debug!(
661 query = &query[..query.len().min(60)],
662 confidence = heuristic.confidence,
663 route = ?heuristic.route,
664 "HybridRouter: heuristic sufficient, skipping LLM"
665 );
666 return heuristic;
667 }
668
669 tracing::debug!(
670 query = &query[..query.len().min(60)],
671 confidence = heuristic.confidence,
672 threshold = self.confidence_threshold,
673 "HybridRouter: low confidence, escalating to LLM"
674 );
675
676 let llm_result = self.llm.classify_async(query).await;
677
678 tracing::debug!(
681 route = ?llm_result.route,
682 confidence = llm_result.confidence,
683 "HybridRouter: final route after LLM escalation"
684 );
685 llm_result
686 }
687}
688
689impl MemoryRouter for HybridRouter {
690 fn route(&self, query: &str) -> MemoryRoute {
691 HeuristicRouter.route(query)
692 }
693
694 fn route_with_confidence(&self, query: &str) -> RoutingDecision {
695 HeuristicRouter.route_with_confidence(query)
697 }
698}
699
700impl AsyncMemoryRouter for HeuristicRouter {
701 fn route_async<'a>(
702 &'a self,
703 query: &'a str,
704 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = RoutingDecision> + Send + 'a>> {
705 Box::pin(std::future::ready(self.route_with_confidence(query)))
706 }
707}
708
709impl AsyncMemoryRouter for HybridRouter {
710 fn route_async<'a>(
711 &'a self,
712 query: &'a str,
713 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = RoutingDecision> + Send + 'a>> {
714 Box::pin(self.classify_async(query))
715 }
716}
717
718#[cfg(test)]
719mod tests {
720 use chrono::TimeZone as _;
721
722 use super::*;
723
724 fn route(q: &str) -> MemoryRoute {
725 HeuristicRouter.route(q)
726 }
727
728 fn fixed_now() -> DateTime<Utc> {
729 Utc.with_ymd_and_hms(2026, 3, 14, 12, 0, 0).unwrap()
731 }
732
733 #[test]
734 fn rust_path_routes_keyword() {
735 assert_eq!(route("zeph_memory::recall"), MemoryRoute::Keyword);
736 }
737
738 #[test]
739 fn file_path_routes_keyword() {
740 assert_eq!(
741 route("crates/zeph-core/src/agent/mod.rs"),
742 MemoryRoute::Keyword
743 );
744 }
745
746 #[test]
747 fn pure_snake_case_routes_keyword() {
748 assert_eq!(route("memory_limit"), MemoryRoute::Keyword);
749 assert_eq!(route("error_handling"), MemoryRoute::Keyword);
750 }
751
752 #[test]
753 fn question_with_snake_case_routes_semantic() {
754 assert_eq!(
756 route("what is the memory_limit setting"),
757 MemoryRoute::Semantic
758 );
759 assert_eq!(route("how does error_handling work"), MemoryRoute::Semantic);
760 }
761
762 #[test]
763 fn short_query_routes_keyword() {
764 assert_eq!(route("context compaction"), MemoryRoute::Keyword);
765 assert_eq!(route("qdrant"), MemoryRoute::Keyword);
766 }
767
768 #[test]
769 fn question_routes_semantic() {
770 assert_eq!(
771 route("what is the purpose of semantic memory"),
772 MemoryRoute::Semantic
773 );
774 assert_eq!(route("how does the agent loop work"), MemoryRoute::Semantic);
775 assert_eq!(route("why does compaction fail"), MemoryRoute::Semantic);
776 assert_eq!(route("explain context compression"), MemoryRoute::Semantic);
777 }
778
779 #[test]
780 fn long_natural_query_routes_semantic() {
781 assert_eq!(
782 route("the agent keeps running out of context during long conversations"),
783 MemoryRoute::Semantic
784 );
785 }
786
787 #[test]
788 fn medium_non_question_routes_hybrid() {
789 assert_eq!(route("context window token budget"), MemoryRoute::Hybrid);
791 }
792
793 #[test]
794 fn empty_query_routes_keyword() {
795 assert_eq!(route(""), MemoryRoute::Keyword);
797 }
798
799 #[test]
800 fn question_word_only_routes_semantic() {
801 assert_eq!(route("what"), MemoryRoute::Semantic);
806 }
807
808 #[test]
809 fn camel_case_does_not_route_keyword_without_pattern() {
810 assert_eq!(
813 route("SemanticMemory configuration and options"),
814 MemoryRoute::Hybrid
815 );
816 }
817
818 #[test]
819 fn relationship_query_routes_graph() {
820 assert_eq!(
821 route("what is user's opinion on neovim"),
822 MemoryRoute::Graph
823 );
824 assert_eq!(
825 route("show the relationship between Alice and Bob"),
826 MemoryRoute::Graph
827 );
828 }
829
830 #[test]
831 fn relationship_query_related_to_routes_graph() {
832 assert_eq!(
833 route("how is Rust related to this project"),
834 MemoryRoute::Graph
835 );
836 assert_eq!(
837 route("how does this relates to the config"),
838 MemoryRoute::Graph
839 );
840 }
841
842 #[test]
843 fn relationship_know_about_routes_graph() {
844 assert_eq!(route("what do I know about neovim"), MemoryRoute::Graph);
845 }
846
847 #[test]
848 fn translate_does_not_route_graph() {
849 assert_ne!(route("translate this code to Python"), MemoryRoute::Graph);
852 }
853
854 #[test]
855 fn non_relationship_stays_semantic() {
856 assert_eq!(
857 route("find similar code patterns in the codebase"),
858 MemoryRoute::Semantic
859 );
860 }
861
862 #[test]
863 fn short_keyword_unchanged() {
864 assert_eq!(route("qdrant"), MemoryRoute::Keyword);
865 }
866
867 #[test]
869 fn long_nl_with_snake_case_routes_semantic() {
870 assert_eq!(
871 route("Use memory_search to find information about Rust ownership"),
872 MemoryRoute::Semantic
873 );
874 }
875
876 #[test]
877 fn short_snake_case_only_routes_keyword() {
878 assert_eq!(route("memory_search"), MemoryRoute::Keyword);
879 }
880
881 #[test]
882 fn question_with_snake_case_short_routes_semantic() {
883 assert_eq!(
884 route("What does memory_search return?"),
885 MemoryRoute::Semantic
886 );
887 }
888
889 #[test]
892 fn temporal_yesterday_routes_episodic() {
893 assert_eq!(
894 route("what did we discuss yesterday"),
895 MemoryRoute::Episodic
896 );
897 }
898
899 #[test]
900 fn temporal_last_week_routes_episodic() {
901 assert_eq!(
902 route("remember what happened last week"),
903 MemoryRoute::Episodic
904 );
905 }
906
907 #[test]
908 fn temporal_when_did_routes_episodic() {
909 assert_eq!(
910 route("when did we last talk about Qdrant"),
911 MemoryRoute::Episodic
912 );
913 }
914
915 #[test]
916 fn temporal_last_time_routes_episodic() {
917 assert_eq!(
918 route("last time we discussed the scheduler"),
919 MemoryRoute::Episodic
920 );
921 }
922
923 #[test]
924 fn temporal_today_routes_episodic() {
925 assert_eq!(
926 route("what did I mention today about testing"),
927 MemoryRoute::Episodic
928 );
929 }
930
931 #[test]
932 fn temporal_this_morning_routes_episodic() {
933 assert_eq!(route("what did we say this morning"), MemoryRoute::Episodic);
934 }
935
936 #[test]
937 fn temporal_last_month_routes_episodic() {
938 assert_eq!(
939 route("find the config change from last month"),
940 MemoryRoute::Episodic
941 );
942 }
943
944 #[test]
945 fn temporal_history_collision_routes_episodic() {
946 assert_eq!(route("history of changes last week"), MemoryRoute::Episodic);
949 }
950
951 #[test]
952 fn temporal_ago_word_boundary_routes_episodic() {
953 assert_eq!(route("we fixed this a day ago"), MemoryRoute::Episodic);
954 }
955
956 #[test]
957 fn ago_in_chicago_no_false_positive() {
958 assert_ne!(
961 route("meeting in Chicago about the project"),
962 MemoryRoute::Episodic
963 );
964 }
965
966 #[test]
967 fn non_temporal_unchanged() {
968 assert_eq!(route("how does the agent loop work"), MemoryRoute::Semantic);
969 }
970
971 #[test]
972 fn code_query_unchanged() {
973 assert_eq!(route("zeph_memory::recall"), MemoryRoute::Keyword);
974 }
975
976 #[test]
979 fn resolve_yesterday_range() {
980 let now = fixed_now(); let range = resolve_temporal_range("what did we discuss yesterday", now).unwrap();
982 assert_eq!(range.after.as_deref(), Some("2026-03-13 00:00:00"));
983 assert_eq!(range.before.as_deref(), Some("2026-03-13 23:59:59"));
984 }
985
986 #[test]
987 fn resolve_last_week_range() {
988 let now = fixed_now(); let range = resolve_temporal_range("remember last week's discussion", now).unwrap();
990 assert!(range.after.as_deref().unwrap().starts_with("2026-03-07"));
992 assert!(range.before.is_none());
993 }
994
995 #[test]
996 fn resolve_last_month_range() {
997 let now = fixed_now();
998 let range = resolve_temporal_range("find the bug from last month", now).unwrap();
999 assert!(range.after.as_deref().unwrap().starts_with("2026-02-12"));
1001 assert!(range.before.is_none());
1002 }
1003
1004 #[test]
1005 fn resolve_today_range() {
1006 let now = fixed_now();
1007 let range = resolve_temporal_range("what did we do today", now).unwrap();
1008 assert_eq!(range.after.as_deref(), Some("2026-03-14 00:00:00"));
1009 assert!(range.before.is_none());
1010 }
1011
1012 #[test]
1013 fn resolve_this_morning_range() {
1014 let now = fixed_now();
1015 let range = resolve_temporal_range("what did we say this morning", now).unwrap();
1016 assert_eq!(range.after.as_deref(), Some("2026-03-14 00:00:00"));
1017 assert_eq!(range.before.as_deref(), Some("2026-03-14 12:00:00"));
1018 }
1019
1020 #[test]
1021 fn resolve_last_night_range() {
1022 let now = fixed_now();
1023 let range = resolve_temporal_range("last night's conversation", now).unwrap();
1024 assert_eq!(range.after.as_deref(), Some("2026-03-13 18:00:00"));
1025 assert_eq!(range.before.as_deref(), Some("2026-03-14 06:00:00"));
1026 }
1027
1028 #[test]
1029 fn resolve_tonight_range() {
1030 let now = fixed_now();
1031 let range = resolve_temporal_range("remind me tonight what we agreed on", now).unwrap();
1032 assert_eq!(range.after.as_deref(), Some("2026-03-14 18:00:00"));
1033 assert!(range.before.is_none());
1034 }
1035
1036 #[test]
1037 fn resolve_no_temporal_returns_none() {
1038 let now = fixed_now();
1039 assert!(resolve_temporal_range("what is the purpose of semantic memory", now).is_none());
1040 }
1041
1042 #[test]
1043 fn resolve_generic_temporal_returns_none() {
1044 let now = fixed_now();
1046 assert!(resolve_temporal_range("when did we discuss this feature", now).is_none());
1047 assert!(resolve_temporal_range("remember when we fixed that bug", now).is_none());
1048 }
1049
1050 #[test]
1053 fn strip_yesterday_from_query() {
1054 let cleaned = strip_temporal_keywords("what did we discuss yesterday about Rust");
1055 assert_eq!(cleaned, "what did we discuss about Rust");
1056 }
1057
1058 #[test]
1059 fn strip_last_week_from_query() {
1060 let cleaned = strip_temporal_keywords("find the config change from last week");
1061 assert_eq!(cleaned, "find the config change from");
1062 }
1063
1064 #[test]
1065 fn strip_does_not_alter_non_temporal() {
1066 let q = "what is the purpose of semantic memory";
1067 assert_eq!(strip_temporal_keywords(q), q);
1068 }
1069
1070 #[test]
1071 fn strip_ago_word_boundary() {
1072 let cleaned = strip_temporal_keywords("we fixed this a day ago in the scheduler");
1073 assert!(!cleaned.contains("ago"));
1075 assert!(cleaned.contains("scheduler"));
1076 }
1077
1078 #[test]
1079 fn strip_does_not_touch_chicago() {
1080 let q = "meeting in Chicago about the project";
1081 assert_eq!(strip_temporal_keywords(q), q);
1082 }
1083
1084 #[test]
1085 fn strip_empty_string_returns_empty() {
1086 assert_eq!(strip_temporal_keywords(""), "");
1087 }
1088
1089 #[test]
1090 fn strip_only_temporal_keyword_returns_empty() {
1091 assert_eq!(strip_temporal_keywords("yesterday"), "");
1094 }
1095
1096 #[test]
1097 fn strip_repeated_temporal_keyword_removes_all_occurrences() {
1098 let cleaned = strip_temporal_keywords("yesterday I mentioned yesterday's bug");
1100 assert!(
1101 !cleaned.contains("yesterday"),
1102 "both occurrences must be removed: got '{cleaned}'"
1103 );
1104 assert!(cleaned.contains("mentioned"));
1105 }
1106
1107 #[test]
1110 fn confidence_multiple_matches_is_less_than_one() {
1111 let d = HeuristicRouter.route_with_confidence("zeph_memory::recall");
1114 assert!(
1115 d.confidence < 1.0,
1116 "ambiguous query should have confidence < 1.0, got {}",
1117 d.confidence
1118 );
1119 assert_eq!(d.route, MemoryRoute::Keyword);
1120 }
1121
1122 #[test]
1123 fn confidence_long_question_with_snake_fires_multiple_signals() {
1124 let d = HeuristicRouter
1126 .route_with_confidence("what is the purpose of memory_limit in the config system");
1127 assert!(
1128 d.confidence < 1.0,
1129 "ambiguous query must have confidence < 1.0, got {}",
1130 d.confidence
1131 );
1132 }
1133
1134 #[test]
1135 fn confidence_empty_query_is_nonzero() {
1136 let d = HeuristicRouter.route_with_confidence("");
1138 assert!(
1139 d.confidence > 0.0,
1140 "empty query must match short-path signal"
1141 );
1142 }
1143
1144 #[test]
1145 fn routing_decision_route_matches_route_fn() {
1146 let queries = [
1148 "qdrant",
1149 "what is the agent loop",
1150 "context window token budget",
1151 "what did we discuss yesterday",
1152 ];
1153 for q in queries {
1154 let decision = HeuristicRouter.route_with_confidence(q);
1155 assert_eq!(
1156 decision.route,
1157 HeuristicRouter.route(q),
1158 "mismatch for query: {q}"
1159 );
1160 }
1161 }
1162}