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
29pub trait MemoryRouter: Send + Sync {
31 fn route(&self, query: &str) -> MemoryRoute;
33}
34
35#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct TemporalRange {
47 pub after: Option<String>,
49 pub before: Option<String>,
51}
52
53const TEMPORAL_PATTERNS: &[&str] = &[
63 "yesterday",
65 "today",
66 "this morning",
67 "tonight",
68 "last night",
69 "last week",
71 "this week",
72 "past week",
73 "last month",
75 "this month",
76 "past month",
77 "when did",
79 "remember when",
80 "last time",
81 "how long ago",
82 "few days ago",
85 "few hours ago",
86 "earlier today",
87];
88
89const WORD_BOUNDARY_TEMPORAL: &[&str] = &["ago"];
92
93pub(crate) const CAUSAL_MARKERS: &[&str] = &[
98 "why",
99 "because",
100 "caused",
101 "cause",
102 "reason",
103 "result",
104 "led to",
105 "consequence",
106 "trigger",
107 "effect",
108 "blame",
109 "fault",
110];
111
112pub(crate) const TEMPORAL_MARKERS: &[&str] = &[
119 "before", "after", "first", "then", "timeline", "sequence", "preceded", "followed", "started",
120 "ended", "during", "prior",
121];
122
123pub(crate) const ENTITY_MARKERS: &[&str] = &[
125 "is a",
126 "type of",
127 "kind of",
128 "part of",
129 "instance",
130 "same as",
131 "alias",
132 "subtype",
133 "subclass",
134 "belongs to",
135];
136
137#[must_use]
159pub fn classify_graph_subgraph(query: &str) -> Vec<EdgeType> {
160 let lower = query.to_ascii_lowercase();
161 let mut types: Vec<EdgeType> = Vec::new();
162
163 if CAUSAL_MARKERS.iter().any(|m| lower.contains(m)) {
164 types.push(EdgeType::Causal);
165 }
166 if TEMPORAL_MARKERS.iter().any(|m| lower.contains(m)) {
167 types.push(EdgeType::Temporal);
168 }
169 if ENTITY_MARKERS.iter().any(|m| lower.contains(m)) {
170 types.push(EdgeType::Entity);
171 }
172
173 if !types.contains(&EdgeType::Semantic) {
175 types.push(EdgeType::Semantic);
176 }
177
178 types
179}
180
181pub struct HeuristicRouter;
191
192const QUESTION_WORDS: &[&str] = &[
193 "what", "how", "why", "when", "where", "who", "which", "explain", "describe",
194];
195
196const RELATIONSHIP_PATTERNS: &[&str] = &[
199 "related to",
200 "relates to",
201 "connection between",
202 "relationship",
203 "opinion on",
204 "thinks about",
205 "preference for",
206 "history of",
207 "know about",
208];
209
210fn contains_word(text: &str, word: &str) -> bool {
215 let bytes = text.as_bytes();
216 let wbytes = word.as_bytes();
217 let wlen = wbytes.len();
218 if wlen > bytes.len() {
219 return false;
220 }
221 for start in 0..=(bytes.len() - wlen) {
222 if bytes[start..start + wlen].eq_ignore_ascii_case(wbytes) {
223 let before_ok =
224 start == 0 || !bytes[start - 1].is_ascii_alphanumeric() && bytes[start - 1] != b'_';
225 let after_ok = start + wlen == bytes.len()
226 || !bytes[start + wlen].is_ascii_alphanumeric() && bytes[start + wlen] != b'_';
227 if before_ok && after_ok {
228 return true;
229 }
230 }
231 }
232 false
233}
234
235fn has_temporal_cue(lower: &str) -> bool {
238 if TEMPORAL_PATTERNS.iter().any(|p| lower.contains(p)) {
239 return true;
240 }
241 WORD_BOUNDARY_TEMPORAL
242 .iter()
243 .any(|w| contains_word(lower, w))
244}
245
246static SORTED_TEMPORAL_PATTERNS: std::sync::LazyLock<Vec<&'static str>> =
249 std::sync::LazyLock::new(|| {
250 let mut v: Vec<&str> = TEMPORAL_PATTERNS.to_vec();
251 v.sort_by_key(|p| std::cmp::Reverse(p.len()));
252 v
253 });
254
255#[must_use]
272pub fn strip_temporal_keywords(query: &str) -> String {
273 let lower = query.to_ascii_lowercase();
278 let mut remove: Vec<(usize, usize)> = Vec::new();
280
281 for pattern in SORTED_TEMPORAL_PATTERNS.iter() {
282 let plen = pattern.len();
283 let mut search_from = 0;
284 while let Some(pos) = lower[search_from..].find(pattern) {
285 let abs = search_from + pos;
286 remove.push((abs, abs + plen));
287 search_from = abs + plen;
288 }
289 }
290
291 for word in WORD_BOUNDARY_TEMPORAL {
293 let wlen = word.len();
294 let lbytes = lower.as_bytes();
295 let mut i = 0;
296 while i + wlen <= lower.len() {
297 if lower[i..].starts_with(*word) {
298 let before_ok =
299 i == 0 || !lbytes[i - 1].is_ascii_alphanumeric() && lbytes[i - 1] != b'_';
300 let after_ok = i + wlen == lower.len()
301 || !lbytes[i + wlen].is_ascii_alphanumeric() && lbytes[i + wlen] != b'_';
302 if before_ok && after_ok {
303 remove.push((i, i + wlen));
304 i += wlen;
305 continue;
306 }
307 }
308 i += 1;
309 }
310 }
311
312 if remove.is_empty() {
313 return query.split_whitespace().collect::<Vec<_>>().join(" ");
315 }
316
317 remove.sort_unstable_by_key(|r| r.0);
319 let bytes = query.as_bytes();
320 let mut result = Vec::with_capacity(query.len());
321 let mut cursor = 0;
322 for (start, end) in remove {
323 if start > cursor {
324 result.extend_from_slice(&bytes[cursor..start]);
325 }
326 cursor = cursor.max(end);
327 }
328 if cursor < bytes.len() {
329 result.extend_from_slice(&bytes[cursor..]);
330 }
331
332 let s = String::from_utf8(result).unwrap_or_default();
335 s.split_whitespace()
336 .filter(|t| !t.is_empty())
337 .collect::<Vec<_>>()
338 .join(" ")
339}
340
341#[must_use]
351pub fn resolve_temporal_range(query: &str, now: DateTime<Utc>) -> Option<TemporalRange> {
352 let lower = query.to_ascii_lowercase();
353
354 if lower.contains("yesterday") {
356 let yesterday = now.date_naive() - Duration::days(1);
357 return Some(TemporalRange {
358 after: Some(format!("{yesterday} 00:00:00")),
359 before: Some(format!("{yesterday} 23:59:59")),
360 });
361 }
362
363 if lower.contains("last night") {
365 let yesterday = now.date_naive() - Duration::days(1);
366 let today = now.date_naive();
367 return Some(TemporalRange {
368 after: Some(format!("{yesterday} 18:00:00")),
369 before: Some(format!("{today} 06:00:00")),
370 });
371 }
372
373 if lower.contains("tonight") {
375 let today = now.date_naive();
376 return Some(TemporalRange {
377 after: Some(format!("{today} 18:00:00")),
378 before: None,
379 });
380 }
381
382 if lower.contains("this morning") {
384 let today = now.date_naive();
385 return Some(TemporalRange {
386 after: Some(format!("{today} 00:00:00")),
387 before: Some(format!("{today} 12:00:00")),
388 });
389 }
390
391 if lower.contains("today") {
395 let today = now.date_naive();
396 return Some(TemporalRange {
397 after: Some(format!("{today} 00:00:00")),
398 before: None,
399 });
400 }
401
402 if lower.contains("last week") || lower.contains("past week") || lower.contains("this week") {
404 let start = now - Duration::days(7);
405 return Some(TemporalRange {
406 after: Some(start.format("%Y-%m-%d %H:%M:%S").to_string()),
407 before: None,
408 });
409 }
410
411 if lower.contains("last month") || lower.contains("past month") || lower.contains("this month")
413 {
414 let start = now - Duration::days(30);
415 return Some(TemporalRange {
416 after: Some(start.format("%Y-%m-%d %H:%M:%S").to_string()),
417 before: None,
418 });
419 }
420
421 if lower.contains("few days ago") {
423 let start = now - Duration::days(3);
424 return Some(TemporalRange {
425 after: Some(start.format("%Y-%m-%d %H:%M:%S").to_string()),
426 before: None,
427 });
428 }
429 if lower.contains("few hours ago") {
430 let start = now - Duration::hours(6);
431 return Some(TemporalRange {
432 after: Some(start.format("%Y-%m-%d %H:%M:%S").to_string()),
433 before: None,
434 });
435 }
436
437 if contains_word(&lower, "ago") {
439 let start = now - Duration::hours(24);
440 return Some(TemporalRange {
441 after: Some(start.format("%Y-%m-%d %H:%M:%S").to_string()),
442 before: None,
443 });
444 }
445
446 None
449}
450
451fn starts_with_question(words: &[&str]) -> bool {
452 words
453 .first()
454 .is_some_and(|w| QUESTION_WORDS.iter().any(|qw| w.eq_ignore_ascii_case(qw)))
455}
456
457fn is_pure_snake_case(word: &str) -> bool {
460 if word.is_empty() {
461 return false;
462 }
463 let has_underscore = word.contains('_');
464 if !has_underscore {
465 return false;
466 }
467 word.chars()
468 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
469 && !word.chars().all(|c| c.is_ascii_digit() || c == '_')
470}
471
472impl MemoryRouter for HeuristicRouter {
473 fn route(&self, query: &str) -> MemoryRoute {
474 let lower = query.to_ascii_lowercase();
475
476 if has_temporal_cue(&lower) {
479 return MemoryRoute::Episodic;
480 }
481
482 let has_relationship = RELATIONSHIP_PATTERNS.iter().any(|p| lower.contains(p));
484 if has_relationship {
485 return MemoryRoute::Graph;
486 }
487
488 let words: Vec<&str> = query.split_whitespace().collect();
489 let word_count = words.len();
490
491 let has_structural_code_pattern = query.contains('/') || query.contains("::");
494
495 let has_snake_case = words.iter().any(|w| is_pure_snake_case(w));
498 let question = starts_with_question(&words);
499
500 if has_structural_code_pattern && !question {
501 return MemoryRoute::Keyword;
502 }
503
504 if question || word_count >= 6 {
506 return MemoryRoute::Semantic;
507 }
508
509 if word_count <= 3 && !question {
511 return MemoryRoute::Keyword;
512 }
513
514 if has_snake_case {
516 return MemoryRoute::Keyword;
517 }
518
519 MemoryRoute::Hybrid
521 }
522}
523
524#[cfg(test)]
525mod tests {
526 use chrono::TimeZone as _;
527
528 use super::*;
529
530 fn route(q: &str) -> MemoryRoute {
531 HeuristicRouter.route(q)
532 }
533
534 fn fixed_now() -> DateTime<Utc> {
535 Utc.with_ymd_and_hms(2026, 3, 14, 12, 0, 0).unwrap()
537 }
538
539 #[test]
540 fn rust_path_routes_keyword() {
541 assert_eq!(route("zeph_memory::recall"), MemoryRoute::Keyword);
542 }
543
544 #[test]
545 fn file_path_routes_keyword() {
546 assert_eq!(
547 route("crates/zeph-core/src/agent/mod.rs"),
548 MemoryRoute::Keyword
549 );
550 }
551
552 #[test]
553 fn pure_snake_case_routes_keyword() {
554 assert_eq!(route("memory_limit"), MemoryRoute::Keyword);
555 assert_eq!(route("error_handling"), MemoryRoute::Keyword);
556 }
557
558 #[test]
559 fn question_with_snake_case_routes_semantic() {
560 assert_eq!(
562 route("what is the memory_limit setting"),
563 MemoryRoute::Semantic
564 );
565 assert_eq!(route("how does error_handling work"), MemoryRoute::Semantic);
566 }
567
568 #[test]
569 fn short_query_routes_keyword() {
570 assert_eq!(route("context compaction"), MemoryRoute::Keyword);
571 assert_eq!(route("qdrant"), MemoryRoute::Keyword);
572 }
573
574 #[test]
575 fn question_routes_semantic() {
576 assert_eq!(
577 route("what is the purpose of semantic memory"),
578 MemoryRoute::Semantic
579 );
580 assert_eq!(route("how does the agent loop work"), MemoryRoute::Semantic);
581 assert_eq!(route("why does compaction fail"), MemoryRoute::Semantic);
582 assert_eq!(route("explain context compression"), MemoryRoute::Semantic);
583 }
584
585 #[test]
586 fn long_natural_query_routes_semantic() {
587 assert_eq!(
588 route("the agent keeps running out of context during long conversations"),
589 MemoryRoute::Semantic
590 );
591 }
592
593 #[test]
594 fn medium_non_question_routes_hybrid() {
595 assert_eq!(route("context window token budget"), MemoryRoute::Hybrid);
597 }
598
599 #[test]
600 fn empty_query_routes_keyword() {
601 assert_eq!(route(""), MemoryRoute::Keyword);
603 }
604
605 #[test]
606 fn question_word_only_routes_semantic() {
607 assert_eq!(route("what"), MemoryRoute::Semantic);
612 }
613
614 #[test]
615 fn camel_case_does_not_route_keyword_without_pattern() {
616 assert_eq!(
619 route("SemanticMemory configuration and options"),
620 MemoryRoute::Hybrid
621 );
622 }
623
624 #[test]
625 fn relationship_query_routes_graph() {
626 assert_eq!(
627 route("what is user's opinion on neovim"),
628 MemoryRoute::Graph
629 );
630 assert_eq!(
631 route("show the relationship between Alice and Bob"),
632 MemoryRoute::Graph
633 );
634 }
635
636 #[test]
637 fn relationship_query_related_to_routes_graph() {
638 assert_eq!(
639 route("how is Rust related to this project"),
640 MemoryRoute::Graph
641 );
642 assert_eq!(
643 route("how does this relates to the config"),
644 MemoryRoute::Graph
645 );
646 }
647
648 #[test]
649 fn relationship_know_about_routes_graph() {
650 assert_eq!(route("what do I know about neovim"), MemoryRoute::Graph);
651 }
652
653 #[test]
654 fn translate_does_not_route_graph() {
655 assert_ne!(route("translate this code to Python"), MemoryRoute::Graph);
658 }
659
660 #[test]
661 fn non_relationship_stays_semantic() {
662 assert_eq!(
663 route("find similar code patterns in the codebase"),
664 MemoryRoute::Semantic
665 );
666 }
667
668 #[test]
669 fn short_keyword_unchanged() {
670 assert_eq!(route("qdrant"), MemoryRoute::Keyword);
671 }
672
673 #[test]
675 fn long_nl_with_snake_case_routes_semantic() {
676 assert_eq!(
677 route("Use memory_search to find information about Rust ownership"),
678 MemoryRoute::Semantic
679 );
680 }
681
682 #[test]
683 fn short_snake_case_only_routes_keyword() {
684 assert_eq!(route("memory_search"), MemoryRoute::Keyword);
685 }
686
687 #[test]
688 fn question_with_snake_case_short_routes_semantic() {
689 assert_eq!(
690 route("What does memory_search return?"),
691 MemoryRoute::Semantic
692 );
693 }
694
695 #[test]
698 fn temporal_yesterday_routes_episodic() {
699 assert_eq!(
700 route("what did we discuss yesterday"),
701 MemoryRoute::Episodic
702 );
703 }
704
705 #[test]
706 fn temporal_last_week_routes_episodic() {
707 assert_eq!(
708 route("remember what happened last week"),
709 MemoryRoute::Episodic
710 );
711 }
712
713 #[test]
714 fn temporal_when_did_routes_episodic() {
715 assert_eq!(
716 route("when did we last talk about Qdrant"),
717 MemoryRoute::Episodic
718 );
719 }
720
721 #[test]
722 fn temporal_last_time_routes_episodic() {
723 assert_eq!(
724 route("last time we discussed the scheduler"),
725 MemoryRoute::Episodic
726 );
727 }
728
729 #[test]
730 fn temporal_today_routes_episodic() {
731 assert_eq!(
732 route("what did I mention today about testing"),
733 MemoryRoute::Episodic
734 );
735 }
736
737 #[test]
738 fn temporal_this_morning_routes_episodic() {
739 assert_eq!(route("what did we say this morning"), MemoryRoute::Episodic);
740 }
741
742 #[test]
743 fn temporal_last_month_routes_episodic() {
744 assert_eq!(
745 route("find the config change from last month"),
746 MemoryRoute::Episodic
747 );
748 }
749
750 #[test]
751 fn temporal_history_collision_routes_episodic() {
752 assert_eq!(route("history of changes last week"), MemoryRoute::Episodic);
755 }
756
757 #[test]
758 fn temporal_ago_word_boundary_routes_episodic() {
759 assert_eq!(route("we fixed this a day ago"), MemoryRoute::Episodic);
760 }
761
762 #[test]
763 fn ago_in_chicago_no_false_positive() {
764 assert_ne!(
767 route("meeting in Chicago about the project"),
768 MemoryRoute::Episodic
769 );
770 }
771
772 #[test]
773 fn non_temporal_unchanged() {
774 assert_eq!(route("how does the agent loop work"), MemoryRoute::Semantic);
775 }
776
777 #[test]
778 fn code_query_unchanged() {
779 assert_eq!(route("zeph_memory::recall"), MemoryRoute::Keyword);
780 }
781
782 #[test]
785 fn resolve_yesterday_range() {
786 let now = fixed_now(); let range = resolve_temporal_range("what did we discuss yesterday", now).unwrap();
788 assert_eq!(range.after.as_deref(), Some("2026-03-13 00:00:00"));
789 assert_eq!(range.before.as_deref(), Some("2026-03-13 23:59:59"));
790 }
791
792 #[test]
793 fn resolve_last_week_range() {
794 let now = fixed_now(); let range = resolve_temporal_range("remember last week's discussion", now).unwrap();
796 assert!(range.after.as_deref().unwrap().starts_with("2026-03-07"));
798 assert!(range.before.is_none());
799 }
800
801 #[test]
802 fn resolve_last_month_range() {
803 let now = fixed_now();
804 let range = resolve_temporal_range("find the bug from last month", now).unwrap();
805 assert!(range.after.as_deref().unwrap().starts_with("2026-02-12"));
807 assert!(range.before.is_none());
808 }
809
810 #[test]
811 fn resolve_today_range() {
812 let now = fixed_now();
813 let range = resolve_temporal_range("what did we do today", now).unwrap();
814 assert_eq!(range.after.as_deref(), Some("2026-03-14 00:00:00"));
815 assert!(range.before.is_none());
816 }
817
818 #[test]
819 fn resolve_this_morning_range() {
820 let now = fixed_now();
821 let range = resolve_temporal_range("what did we say this morning", now).unwrap();
822 assert_eq!(range.after.as_deref(), Some("2026-03-14 00:00:00"));
823 assert_eq!(range.before.as_deref(), Some("2026-03-14 12:00:00"));
824 }
825
826 #[test]
827 fn resolve_last_night_range() {
828 let now = fixed_now();
829 let range = resolve_temporal_range("last night's conversation", now).unwrap();
830 assert_eq!(range.after.as_deref(), Some("2026-03-13 18:00:00"));
831 assert_eq!(range.before.as_deref(), Some("2026-03-14 06:00:00"));
832 }
833
834 #[test]
835 fn resolve_tonight_range() {
836 let now = fixed_now();
837 let range = resolve_temporal_range("remind me tonight what we agreed on", now).unwrap();
838 assert_eq!(range.after.as_deref(), Some("2026-03-14 18:00:00"));
839 assert!(range.before.is_none());
840 }
841
842 #[test]
843 fn resolve_no_temporal_returns_none() {
844 let now = fixed_now();
845 assert!(resolve_temporal_range("what is the purpose of semantic memory", now).is_none());
846 }
847
848 #[test]
849 fn resolve_generic_temporal_returns_none() {
850 let now = fixed_now();
852 assert!(resolve_temporal_range("when did we discuss this feature", now).is_none());
853 assert!(resolve_temporal_range("remember when we fixed that bug", now).is_none());
854 }
855
856 #[test]
859 fn strip_yesterday_from_query() {
860 let cleaned = strip_temporal_keywords("what did we discuss yesterday about Rust");
861 assert_eq!(cleaned, "what did we discuss about Rust");
862 }
863
864 #[test]
865 fn strip_last_week_from_query() {
866 let cleaned = strip_temporal_keywords("find the config change from last week");
867 assert_eq!(cleaned, "find the config change from");
868 }
869
870 #[test]
871 fn strip_does_not_alter_non_temporal() {
872 let q = "what is the purpose of semantic memory";
873 assert_eq!(strip_temporal_keywords(q), q);
874 }
875
876 #[test]
877 fn strip_ago_word_boundary() {
878 let cleaned = strip_temporal_keywords("we fixed this a day ago in the scheduler");
879 assert!(!cleaned.contains("ago"));
881 assert!(cleaned.contains("scheduler"));
882 }
883
884 #[test]
885 fn strip_does_not_touch_chicago() {
886 let q = "meeting in Chicago about the project";
887 assert_eq!(strip_temporal_keywords(q), q);
888 }
889
890 #[test]
891 fn strip_empty_string_returns_empty() {
892 assert_eq!(strip_temporal_keywords(""), "");
893 }
894
895 #[test]
896 fn strip_only_temporal_keyword_returns_empty() {
897 assert_eq!(strip_temporal_keywords("yesterday"), "");
900 }
901
902 #[test]
903 fn strip_repeated_temporal_keyword_removes_all_occurrences() {
904 let cleaned = strip_temporal_keywords("yesterday I mentioned yesterday's bug");
906 assert!(
907 !cleaned.contains("yesterday"),
908 "both occurrences must be removed: got '{cleaned}'"
909 );
910 assert!(cleaned.contains("mentioned"));
911 }
912}