1use chrono::{DateTime, Duration, Utc};
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum MemoryRoute {
9 Keyword,
11 Semantic,
13 Hybrid,
15 Graph,
18 Episodic,
25}
26
27pub trait MemoryRouter: Send + Sync {
29 fn route(&self, query: &str) -> MemoryRoute;
31}
32
33#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct TemporalRange {
45 pub after: Option<String>,
47 pub before: Option<String>,
49}
50
51const TEMPORAL_PATTERNS: &[&str] = &[
61 "yesterday",
63 "today",
64 "this morning",
65 "tonight",
66 "last night",
67 "last week",
69 "this week",
70 "past week",
71 "last month",
73 "this month",
74 "past month",
75 "when did",
77 "remember when",
78 "last time",
79 "how long ago",
80 "few days ago",
83 "few hours ago",
84 "earlier today",
85];
86
87const WORD_BOUNDARY_TEMPORAL: &[&str] = &["ago"];
90
91pub struct HeuristicRouter;
101
102const QUESTION_WORDS: &[&str] = &[
103 "what", "how", "why", "when", "where", "who", "which", "explain", "describe",
104];
105
106const RELATIONSHIP_PATTERNS: &[&str] = &[
109 "related to",
110 "relates to",
111 "connection between",
112 "relationship",
113 "opinion on",
114 "thinks about",
115 "preference for",
116 "history of",
117 "know about",
118];
119
120fn contains_word(text: &str, word: &str) -> bool {
125 let bytes = text.as_bytes();
126 let wbytes = word.as_bytes();
127 let wlen = wbytes.len();
128 if wlen > bytes.len() {
129 return false;
130 }
131 for start in 0..=(bytes.len() - wlen) {
132 if bytes[start..start + wlen].eq_ignore_ascii_case(wbytes) {
133 let before_ok =
134 start == 0 || !bytes[start - 1].is_ascii_alphanumeric() && bytes[start - 1] != b'_';
135 let after_ok = start + wlen == bytes.len()
136 || !bytes[start + wlen].is_ascii_alphanumeric() && bytes[start + wlen] != b'_';
137 if before_ok && after_ok {
138 return true;
139 }
140 }
141 }
142 false
143}
144
145fn has_temporal_cue(lower: &str) -> bool {
148 if TEMPORAL_PATTERNS.iter().any(|p| lower.contains(p)) {
149 return true;
150 }
151 WORD_BOUNDARY_TEMPORAL
152 .iter()
153 .any(|w| contains_word(lower, w))
154}
155
156static SORTED_TEMPORAL_PATTERNS: std::sync::LazyLock<Vec<&'static str>> =
159 std::sync::LazyLock::new(|| {
160 let mut v: Vec<&str> = TEMPORAL_PATTERNS.to_vec();
161 v.sort_by_key(|p| std::cmp::Reverse(p.len()));
162 v
163 });
164
165#[must_use]
182pub fn strip_temporal_keywords(query: &str) -> String {
183 let lower = query.to_ascii_lowercase();
188 let mut remove: Vec<(usize, usize)> = Vec::new();
190
191 for pattern in SORTED_TEMPORAL_PATTERNS.iter() {
192 let plen = pattern.len();
193 let mut search_from = 0;
194 while let Some(pos) = lower[search_from..].find(pattern) {
195 let abs = search_from + pos;
196 remove.push((abs, abs + plen));
197 search_from = abs + plen;
198 }
199 }
200
201 for word in WORD_BOUNDARY_TEMPORAL {
203 let wlen = word.len();
204 let lbytes = lower.as_bytes();
205 let mut i = 0;
206 while i + wlen <= lower.len() {
207 if lower[i..].starts_with(*word) {
208 let before_ok =
209 i == 0 || !lbytes[i - 1].is_ascii_alphanumeric() && lbytes[i - 1] != b'_';
210 let after_ok = i + wlen == lower.len()
211 || !lbytes[i + wlen].is_ascii_alphanumeric() && lbytes[i + wlen] != b'_';
212 if before_ok && after_ok {
213 remove.push((i, i + wlen));
214 i += wlen;
215 continue;
216 }
217 }
218 i += 1;
219 }
220 }
221
222 if remove.is_empty() {
223 return query.split_whitespace().collect::<Vec<_>>().join(" ");
225 }
226
227 remove.sort_unstable_by_key(|r| r.0);
229 let bytes = query.as_bytes();
230 let mut result = Vec::with_capacity(query.len());
231 let mut cursor = 0;
232 for (start, end) in remove {
233 if start > cursor {
234 result.extend_from_slice(&bytes[cursor..start]);
235 }
236 cursor = cursor.max(end);
237 }
238 if cursor < bytes.len() {
239 result.extend_from_slice(&bytes[cursor..]);
240 }
241
242 let s = String::from_utf8(result).unwrap_or_default();
245 s.split_whitespace()
246 .filter(|t| !t.is_empty())
247 .collect::<Vec<_>>()
248 .join(" ")
249}
250
251#[must_use]
261pub fn resolve_temporal_range(query: &str, now: DateTime<Utc>) -> Option<TemporalRange> {
262 let lower = query.to_ascii_lowercase();
263
264 if lower.contains("yesterday") {
266 let yesterday = now.date_naive() - Duration::days(1);
267 return Some(TemporalRange {
268 after: Some(format!("{yesterday} 00:00:00")),
269 before: Some(format!("{yesterday} 23:59:59")),
270 });
271 }
272
273 if lower.contains("last night") {
275 let yesterday = now.date_naive() - Duration::days(1);
276 let today = now.date_naive();
277 return Some(TemporalRange {
278 after: Some(format!("{yesterday} 18:00:00")),
279 before: Some(format!("{today} 06:00:00")),
280 });
281 }
282
283 if lower.contains("tonight") {
285 let today = now.date_naive();
286 return Some(TemporalRange {
287 after: Some(format!("{today} 18:00:00")),
288 before: None,
289 });
290 }
291
292 if lower.contains("this morning") {
294 let today = now.date_naive();
295 return Some(TemporalRange {
296 after: Some(format!("{today} 00:00:00")),
297 before: Some(format!("{today} 12:00:00")),
298 });
299 }
300
301 if lower.contains("today") {
305 let today = now.date_naive();
306 return Some(TemporalRange {
307 after: Some(format!("{today} 00:00:00")),
308 before: None,
309 });
310 }
311
312 if lower.contains("last week") || lower.contains("past week") || lower.contains("this week") {
314 let start = now - Duration::days(7);
315 return Some(TemporalRange {
316 after: Some(start.format("%Y-%m-%d %H:%M:%S").to_string()),
317 before: None,
318 });
319 }
320
321 if lower.contains("last month") || lower.contains("past month") || lower.contains("this month")
323 {
324 let start = now - Duration::days(30);
325 return Some(TemporalRange {
326 after: Some(start.format("%Y-%m-%d %H:%M:%S").to_string()),
327 before: None,
328 });
329 }
330
331 if lower.contains("few days ago") {
333 let start = now - Duration::days(3);
334 return Some(TemporalRange {
335 after: Some(start.format("%Y-%m-%d %H:%M:%S").to_string()),
336 before: None,
337 });
338 }
339 if lower.contains("few hours ago") {
340 let start = now - Duration::hours(6);
341 return Some(TemporalRange {
342 after: Some(start.format("%Y-%m-%d %H:%M:%S").to_string()),
343 before: None,
344 });
345 }
346
347 if contains_word(&lower, "ago") {
349 let start = now - Duration::hours(24);
350 return Some(TemporalRange {
351 after: Some(start.format("%Y-%m-%d %H:%M:%S").to_string()),
352 before: None,
353 });
354 }
355
356 None
359}
360
361fn starts_with_question(words: &[&str]) -> bool {
362 words
363 .first()
364 .is_some_and(|w| QUESTION_WORDS.iter().any(|qw| w.eq_ignore_ascii_case(qw)))
365}
366
367fn is_pure_snake_case(word: &str) -> bool {
370 if word.is_empty() {
371 return false;
372 }
373 let has_underscore = word.contains('_');
374 if !has_underscore {
375 return false;
376 }
377 word.chars()
378 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
379 && !word.chars().all(|c| c.is_ascii_digit() || c == '_')
380}
381
382impl MemoryRouter for HeuristicRouter {
383 fn route(&self, query: &str) -> MemoryRoute {
384 let lower = query.to_ascii_lowercase();
385
386 if has_temporal_cue(&lower) {
389 return MemoryRoute::Episodic;
390 }
391
392 let has_relationship = RELATIONSHIP_PATTERNS.iter().any(|p| lower.contains(p));
394 if has_relationship {
395 return MemoryRoute::Graph;
396 }
397
398 let words: Vec<&str> = query.split_whitespace().collect();
399 let word_count = words.len();
400
401 let has_structural_code_pattern = query.contains('/') || query.contains("::");
404
405 let has_snake_case = words.iter().any(|w| is_pure_snake_case(w));
408 let question = starts_with_question(&words);
409
410 if has_structural_code_pattern && !question {
411 return MemoryRoute::Keyword;
412 }
413
414 if question || word_count >= 6 {
416 return MemoryRoute::Semantic;
417 }
418
419 if word_count <= 3 && !question {
421 return MemoryRoute::Keyword;
422 }
423
424 if has_snake_case {
426 return MemoryRoute::Keyword;
427 }
428
429 MemoryRoute::Hybrid
431 }
432}
433
434#[cfg(test)]
435mod tests {
436 use chrono::TimeZone as _;
437
438 use super::*;
439
440 fn route(q: &str) -> MemoryRoute {
441 HeuristicRouter.route(q)
442 }
443
444 fn fixed_now() -> DateTime<Utc> {
445 Utc.with_ymd_and_hms(2026, 3, 14, 12, 0, 0).unwrap()
447 }
448
449 #[test]
450 fn rust_path_routes_keyword() {
451 assert_eq!(route("zeph_memory::recall"), MemoryRoute::Keyword);
452 }
453
454 #[test]
455 fn file_path_routes_keyword() {
456 assert_eq!(
457 route("crates/zeph-core/src/agent/mod.rs"),
458 MemoryRoute::Keyword
459 );
460 }
461
462 #[test]
463 fn pure_snake_case_routes_keyword() {
464 assert_eq!(route("memory_limit"), MemoryRoute::Keyword);
465 assert_eq!(route("error_handling"), MemoryRoute::Keyword);
466 }
467
468 #[test]
469 fn question_with_snake_case_routes_semantic() {
470 assert_eq!(
472 route("what is the memory_limit setting"),
473 MemoryRoute::Semantic
474 );
475 assert_eq!(route("how does error_handling work"), MemoryRoute::Semantic);
476 }
477
478 #[test]
479 fn short_query_routes_keyword() {
480 assert_eq!(route("context compaction"), MemoryRoute::Keyword);
481 assert_eq!(route("qdrant"), MemoryRoute::Keyword);
482 }
483
484 #[test]
485 fn question_routes_semantic() {
486 assert_eq!(
487 route("what is the purpose of semantic memory"),
488 MemoryRoute::Semantic
489 );
490 assert_eq!(route("how does the agent loop work"), MemoryRoute::Semantic);
491 assert_eq!(route("why does compaction fail"), MemoryRoute::Semantic);
492 assert_eq!(route("explain context compression"), MemoryRoute::Semantic);
493 }
494
495 #[test]
496 fn long_natural_query_routes_semantic() {
497 assert_eq!(
498 route("the agent keeps running out of context during long conversations"),
499 MemoryRoute::Semantic
500 );
501 }
502
503 #[test]
504 fn medium_non_question_routes_hybrid() {
505 assert_eq!(route("context window token budget"), MemoryRoute::Hybrid);
507 }
508
509 #[test]
510 fn empty_query_routes_keyword() {
511 assert_eq!(route(""), MemoryRoute::Keyword);
513 }
514
515 #[test]
516 fn question_word_only_routes_semantic() {
517 assert_eq!(route("what"), MemoryRoute::Semantic);
522 }
523
524 #[test]
525 fn camel_case_does_not_route_keyword_without_pattern() {
526 assert_eq!(
529 route("SemanticMemory configuration and options"),
530 MemoryRoute::Hybrid
531 );
532 }
533
534 #[test]
535 fn relationship_query_routes_graph() {
536 assert_eq!(
537 route("what is user's opinion on neovim"),
538 MemoryRoute::Graph
539 );
540 assert_eq!(
541 route("show the relationship between Alice and Bob"),
542 MemoryRoute::Graph
543 );
544 }
545
546 #[test]
547 fn relationship_query_related_to_routes_graph() {
548 assert_eq!(
549 route("how is Rust related to this project"),
550 MemoryRoute::Graph
551 );
552 assert_eq!(
553 route("how does this relates to the config"),
554 MemoryRoute::Graph
555 );
556 }
557
558 #[test]
559 fn relationship_know_about_routes_graph() {
560 assert_eq!(route("what do I know about neovim"), MemoryRoute::Graph);
561 }
562
563 #[test]
564 fn translate_does_not_route_graph() {
565 assert_ne!(route("translate this code to Python"), MemoryRoute::Graph);
568 }
569
570 #[test]
571 fn non_relationship_stays_semantic() {
572 assert_eq!(
573 route("find similar code patterns in the codebase"),
574 MemoryRoute::Semantic
575 );
576 }
577
578 #[test]
579 fn short_keyword_unchanged() {
580 assert_eq!(route("qdrant"), MemoryRoute::Keyword);
581 }
582
583 #[test]
585 fn long_nl_with_snake_case_routes_semantic() {
586 assert_eq!(
587 route("Use memory_search to find information about Rust ownership"),
588 MemoryRoute::Semantic
589 );
590 }
591
592 #[test]
593 fn short_snake_case_only_routes_keyword() {
594 assert_eq!(route("memory_search"), MemoryRoute::Keyword);
595 }
596
597 #[test]
598 fn question_with_snake_case_short_routes_semantic() {
599 assert_eq!(
600 route("What does memory_search return?"),
601 MemoryRoute::Semantic
602 );
603 }
604
605 #[test]
608 fn temporal_yesterday_routes_episodic() {
609 assert_eq!(
610 route("what did we discuss yesterday"),
611 MemoryRoute::Episodic
612 );
613 }
614
615 #[test]
616 fn temporal_last_week_routes_episodic() {
617 assert_eq!(
618 route("remember what happened last week"),
619 MemoryRoute::Episodic
620 );
621 }
622
623 #[test]
624 fn temporal_when_did_routes_episodic() {
625 assert_eq!(
626 route("when did we last talk about Qdrant"),
627 MemoryRoute::Episodic
628 );
629 }
630
631 #[test]
632 fn temporal_last_time_routes_episodic() {
633 assert_eq!(
634 route("last time we discussed the scheduler"),
635 MemoryRoute::Episodic
636 );
637 }
638
639 #[test]
640 fn temporal_today_routes_episodic() {
641 assert_eq!(
642 route("what did I mention today about testing"),
643 MemoryRoute::Episodic
644 );
645 }
646
647 #[test]
648 fn temporal_this_morning_routes_episodic() {
649 assert_eq!(route("what did we say this morning"), MemoryRoute::Episodic);
650 }
651
652 #[test]
653 fn temporal_last_month_routes_episodic() {
654 assert_eq!(
655 route("find the config change from last month"),
656 MemoryRoute::Episodic
657 );
658 }
659
660 #[test]
661 fn temporal_history_collision_routes_episodic() {
662 assert_eq!(route("history of changes last week"), MemoryRoute::Episodic);
665 }
666
667 #[test]
668 fn temporal_ago_word_boundary_routes_episodic() {
669 assert_eq!(route("we fixed this a day ago"), MemoryRoute::Episodic);
670 }
671
672 #[test]
673 fn ago_in_chicago_no_false_positive() {
674 assert_ne!(
677 route("meeting in Chicago about the project"),
678 MemoryRoute::Episodic
679 );
680 }
681
682 #[test]
683 fn non_temporal_unchanged() {
684 assert_eq!(route("how does the agent loop work"), MemoryRoute::Semantic);
685 }
686
687 #[test]
688 fn code_query_unchanged() {
689 assert_eq!(route("zeph_memory::recall"), MemoryRoute::Keyword);
690 }
691
692 #[test]
695 fn resolve_yesterday_range() {
696 let now = fixed_now(); let range = resolve_temporal_range("what did we discuss yesterday", now).unwrap();
698 assert_eq!(range.after.as_deref(), Some("2026-03-13 00:00:00"));
699 assert_eq!(range.before.as_deref(), Some("2026-03-13 23:59:59"));
700 }
701
702 #[test]
703 fn resolve_last_week_range() {
704 let now = fixed_now(); let range = resolve_temporal_range("remember last week's discussion", now).unwrap();
706 assert!(range.after.as_deref().unwrap().starts_with("2026-03-07"));
708 assert!(range.before.is_none());
709 }
710
711 #[test]
712 fn resolve_last_month_range() {
713 let now = fixed_now();
714 let range = resolve_temporal_range("find the bug from last month", now).unwrap();
715 assert!(range.after.as_deref().unwrap().starts_with("2026-02-12"));
717 assert!(range.before.is_none());
718 }
719
720 #[test]
721 fn resolve_today_range() {
722 let now = fixed_now();
723 let range = resolve_temporal_range("what did we do today", now).unwrap();
724 assert_eq!(range.after.as_deref(), Some("2026-03-14 00:00:00"));
725 assert!(range.before.is_none());
726 }
727
728 #[test]
729 fn resolve_this_morning_range() {
730 let now = fixed_now();
731 let range = resolve_temporal_range("what did we say this morning", now).unwrap();
732 assert_eq!(range.after.as_deref(), Some("2026-03-14 00:00:00"));
733 assert_eq!(range.before.as_deref(), Some("2026-03-14 12:00:00"));
734 }
735
736 #[test]
737 fn resolve_last_night_range() {
738 let now = fixed_now();
739 let range = resolve_temporal_range("last night's conversation", now).unwrap();
740 assert_eq!(range.after.as_deref(), Some("2026-03-13 18:00:00"));
741 assert_eq!(range.before.as_deref(), Some("2026-03-14 06:00:00"));
742 }
743
744 #[test]
745 fn resolve_tonight_range() {
746 let now = fixed_now();
747 let range = resolve_temporal_range("remind me tonight what we agreed on", now).unwrap();
748 assert_eq!(range.after.as_deref(), Some("2026-03-14 18:00:00"));
749 assert!(range.before.is_none());
750 }
751
752 #[test]
753 fn resolve_no_temporal_returns_none() {
754 let now = fixed_now();
755 assert!(resolve_temporal_range("what is the purpose of semantic memory", now).is_none());
756 }
757
758 #[test]
759 fn resolve_generic_temporal_returns_none() {
760 let now = fixed_now();
762 assert!(resolve_temporal_range("when did we discuss this feature", now).is_none());
763 assert!(resolve_temporal_range("remember when we fixed that bug", now).is_none());
764 }
765
766 #[test]
769 fn strip_yesterday_from_query() {
770 let cleaned = strip_temporal_keywords("what did we discuss yesterday about Rust");
771 assert_eq!(cleaned, "what did we discuss about Rust");
772 }
773
774 #[test]
775 fn strip_last_week_from_query() {
776 let cleaned = strip_temporal_keywords("find the config change from last week");
777 assert_eq!(cleaned, "find the config change from");
778 }
779
780 #[test]
781 fn strip_does_not_alter_non_temporal() {
782 let q = "what is the purpose of semantic memory";
783 assert_eq!(strip_temporal_keywords(q), q);
784 }
785
786 #[test]
787 fn strip_ago_word_boundary() {
788 let cleaned = strip_temporal_keywords("we fixed this a day ago in the scheduler");
789 assert!(!cleaned.contains("ago"));
791 assert!(cleaned.contains("scheduler"));
792 }
793
794 #[test]
795 fn strip_does_not_touch_chicago() {
796 let q = "meeting in Chicago about the project";
797 assert_eq!(strip_temporal_keywords(q), q);
798 }
799
800 #[test]
801 fn strip_empty_string_returns_empty() {
802 assert_eq!(strip_temporal_keywords(""), "");
803 }
804
805 #[test]
806 fn strip_only_temporal_keyword_returns_empty() {
807 assert_eq!(strip_temporal_keywords("yesterday"), "");
810 }
811
812 #[test]
813 fn strip_repeated_temporal_keyword_removes_all_occurrences() {
814 let cleaned = strip_temporal_keywords("yesterday I mentioned yesterday's bug");
816 assert!(
817 !cleaned.contains("yesterday"),
818 "both occurrences must be removed: got '{cleaned}'"
819 );
820 assert!(cleaned.contains("mentioned"));
821 }
822}