Skip to main content

zeph_memory/
router.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use chrono::{DateTime, Duration, Utc};
5
6/// Classification of which memory backend(s) to query.
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum MemoryRoute {
9    /// Full-text search only (`SQLite` FTS5). Fast, good for keyword/exact queries.
10    Keyword,
11    /// Vector search only (Qdrant). Good for semantic/conceptual queries.
12    Semantic,
13    /// Both backends, results merged by reciprocal rank fusion.
14    Hybrid,
15    /// Graph-based retrieval via BFS traversal. Good for relationship queries.
16    /// When the `graph-memory` feature is disabled, callers treat this as `Hybrid`.
17    Graph,
18    /// FTS5 search with a timestamp-range filter. Used for temporal/episodic queries
19    /// ("what did we discuss yesterday", "last week's conversation about Rust").
20    ///
21    /// Known trade-off (MVP): skips vector search entirely for speed. Semantically similar
22    /// but lexically different messages may be missed. Use `Hybrid` route when semantic
23    /// precision matters more than temporal filtering.
24    Episodic,
25}
26
27/// Decides which memory backend(s) to query for a given input.
28pub trait MemoryRouter: Send + Sync {
29    /// Route a query to the appropriate backend(s).
30    fn route(&self, query: &str) -> MemoryRoute;
31}
32
33/// Resolved datetime boundaries for a temporal query.
34///
35/// Both fields use `SQLite` datetime format (`YYYY-MM-DD HH:MM:SS`, UTC).
36/// `None` means "no bound" on that side.
37///
38/// Note: All timestamps are UTC. The `created_at` column in the `messages` table
39/// defaults to `datetime('now')` which is also UTC, so comparisons are consistent.
40/// Users in non-UTC timezones may get slightly unexpected results for "yesterday"
41/// queries (e.g. at 01:00 UTC+5 the user's local yesterday differs from UTC yesterday).
42/// This is an accepted approximation for the heuristic-only MVP.
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct TemporalRange {
45    /// Exclusive lower bound: `created_at > after`.
46    pub after: Option<String>,
47    /// Exclusive upper bound: `created_at < before`.
48    pub before: Option<String>,
49}
50
51/// Temporal patterns that indicate an episodic / time-scoped recall query.
52///
53/// Multi-word patterns are preferred over single-word ones to reduce false positives.
54/// Single-word patterns that can appear inside other words (e.g. "ago" in "Chicago")
55/// must be checked with `contains_word()` to enforce word-boundary semantics.
56///
57/// Omitted on purpose: "before", "after", "since", "during", "earlier", "recently"
58/// — these are too ambiguous in technical contexts ("before the function returns",
59/// "since you asked", "during compilation"). They are not in this list.
60const TEMPORAL_PATTERNS: &[&str] = &[
61    // relative day
62    "yesterday",
63    "today",
64    "this morning",
65    "tonight",
66    "last night",
67    // relative week
68    "last week",
69    "this week",
70    "past week",
71    // relative month
72    "last month",
73    "this month",
74    "past month",
75    // temporal questions
76    "when did",
77    "remember when",
78    "last time",
79    "how long ago",
80    // relative phrases requiring word-boundary check
81    // (checked separately via `contains_word` to avoid matching "a few days ago" substring in longer words)
82    "few days ago",
83    "few hours ago",
84    "earlier today",
85];
86
87/// Single-word temporal tokens that require word-boundary checking.
88/// These are NOT in `TEMPORAL_PATTERNS` to avoid substring false positives.
89const WORD_BOUNDARY_TEMPORAL: &[&str] = &["ago"];
90
91/// Heuristic-based memory router.
92///
93/// Decision logic (in priority order):
94/// 1. Temporal patterns → `Episodic`
95/// 2. Relationship patterns → `Graph`
96/// 3. Code-like patterns (paths, `::`) without question word → `Keyword`
97/// 4. Long NL query or question word → `Semantic`
98/// 5. Short non-question query → `Keyword`
99/// 6. Default → `Hybrid`
100pub struct HeuristicRouter;
101
102const QUESTION_WORDS: &[&str] = &[
103    "what", "how", "why", "when", "where", "who", "which", "explain", "describe",
104];
105
106/// Simple substrings that signal a relationship query (checked via `str::contains`).
107/// Only used when the `graph-memory` feature is enabled.
108const 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
120/// Returns true if `text` contains `word` as a whole word (word-boundary semantics).
121///
122/// A "word boundary" here means the character before and after `word` (if present)
123/// is not an ASCII alphanumeric character or underscore.
124fn 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
145/// Returns true if the lowercased query contains any temporal cue that indicates
146/// an episodic / time-scoped recall request.
147fn 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
156/// Temporal patterns sorted longest-first for stripping. Initialized once via `LazyLock`
157/// to avoid allocating and sorting on every call to `strip_temporal_keywords`.
158static 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/// Strip matched temporal keywords from a query string before passing to FTS5.
166///
167/// Temporal keywords are routing metadata, not search terms. Passing them to FTS5
168/// causes BM25 score distortion — messages that literally mention "yesterday" get
169/// boosted regardless of actual content relevance.
170///
171/// All occurrences of each pattern are removed (not just the first), preventing
172/// score distortion from repeated temporal tokens in edge cases like
173/// "yesterday I mentioned yesterday's bug".
174///
175/// # Example
176/// ```
177/// # use zeph_memory::router::strip_temporal_keywords;
178/// let cleaned = strip_temporal_keywords("what did we discuss yesterday about Rust");
179/// assert_eq!(cleaned, "what did we discuss about Rust");
180/// ```
181#[must_use]
182pub fn strip_temporal_keywords(query: &str) -> String {
183    // Lowercase once for pattern matching; track removal positions in the original string.
184    // We operate on the lowercased copy for matching, then remove spans from `result`
185    // by rebuilding via byte indices (both strings have identical byte lengths because
186    // to_ascii_lowercase is a 1:1 byte mapping for ASCII).
187    let lower = query.to_ascii_lowercase();
188    // Collect all (start, end) spans to remove, then rebuild the string in one pass.
189    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    // Strip word-boundary tokens (single-word, e.g. "ago") — all occurrences.
202    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        // Fast path: no patterns found — return the original string.
224        return query.split_whitespace().collect::<Vec<_>>().join(" ");
225    }
226
227    // Merge overlapping/adjacent spans and remove them from the original string.
228    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    // Collapse multiple spaces and trim.
243    // SAFETY: We only removed ASCII byte spans; remaining bytes are still valid UTF-8.
244    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/// Resolve temporal keywords in `query` to a `(after, before)` datetime boundary pair.
252///
253/// Returns `None` when no specific range can be computed (the episodic path then falls
254/// back to FTS5 without a time filter, relying on temporal decay for recency boosting).
255///
256/// The `now` parameter is injectable for deterministic unit testing. Production callers
257/// should pass `chrono::Utc::now()`.
258///
259/// All datetime strings are in `SQLite` format: `YYYY-MM-DD HH:MM:SS` (UTC).
260#[must_use]
261pub fn resolve_temporal_range(query: &str, now: DateTime<Utc>) -> Option<TemporalRange> {
262    let lower = query.to_ascii_lowercase();
263
264    // yesterday: the full calendar day before today (UTC)
265    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    // last night: 18:00 yesterday to 06:00 today (UTC approximation)
274    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    // tonight: 18:00 today onwards
284    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    // this morning: midnight to noon today
293    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    // today / earlier today: midnight to now.
302    // Note: "earlier today" always contains "today", so a separate branch would be
303    // dead code — the "today" check subsumes it.
304    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    // last week / past week / this week: 7-day lookback
313    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    // last month / past month / this month: 30-day lookback (approximate)
322    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    // "few days ago" / "few hours ago": 3-day lookback
332    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    // "ago" (word-boundary): generic recent lookback (24h)
348    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    // Generic temporal cues without a specific range ("when did", "remember when",
357    // "last time", "how long ago") — fall back to FTS5-only with temporal decay.
358    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
367/// Returns true if `word` is a pure `snake_case` identifier (all ASCII, lowercase letters,
368/// digits and underscores, contains at least one underscore, not purely numeric).
369fn 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        // 1. Temporal queries take highest priority — must run before relationship check
387        //    to prevent "history of changes last week" from routing to Graph instead of Episodic.
388        if has_temporal_cue(&lower) {
389            return MemoryRoute::Episodic;
390        }
391
392        // 2. Relationship queries go to graph retrieval (feature-gated at call site)
393        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        // Code-like patterns that unambiguously indicate keyword search:
402        // file paths (contain '/'), Rust paths (contain '::')
403        let has_structural_code_pattern = query.contains('/') || query.contains("::");
404
405        // Pure snake_case identifiers (e.g. "memory_limit", "error_handling")
406        // but only if the query does NOT start with a question word
407        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        // Long NL queries → semantic, regardless of snake_case tokens
415        if question || word_count >= 6 {
416            return MemoryRoute::Semantic;
417        }
418
419        // Short queries without question words → keyword
420        if word_count <= 3 && !question {
421            return MemoryRoute::Keyword;
422        }
423
424        // Short code-like patterns → keyword
425        if has_snake_case {
426            return MemoryRoute::Keyword;
427        }
428
429        // Default
430        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        // 2026-03-14 12:00:00 UTC — fixed reference point for all temporal tests
446        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        // "what is the memory_limit setting" — question word overrides snake_case heuristic
471        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        // 4-5 words, no question word, no code pattern
506        assert_eq!(route("context window token budget"), MemoryRoute::Hybrid);
507    }
508
509    #[test]
510    fn empty_query_routes_keyword() {
511        // 0 words, no question → keyword (short path)
512        assert_eq!(route(""), MemoryRoute::Keyword);
513    }
514
515    #[test]
516    fn question_word_only_routes_semantic() {
517        // single question word → word_count = 1, but starts_with_question = true
518        // short query with question: the question check happens first in semantic branch
519        // Actually with word_count=1 and question=true: short path `<= 3 && !question` is false,
520        // then `question || word_count >= 6` is true → Semantic
521        assert_eq!(route("what"), MemoryRoute::Semantic);
522    }
523
524    #[test]
525    fn camel_case_does_not_route_keyword_without_pattern() {
526        // CamelCase words without :: or / — 4-word query without question word → Hybrid
527        // (4 words: no question, no snake_case, no structural code pattern → Hybrid)
528        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        // "translate" contains "relate" substring but is not in RELATIONSHIP_PATTERNS
566        // (we removed bare "relate", keeping only "related to" and "relates to")
567        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    // Regression tests for #1661: long NL queries with snake_case must go to Semantic
584    #[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    // ── Temporal routing tests ────────────────────────────────────────────────
606
607    #[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        // CRIT-01: "history of" is a relationship pattern, but temporal wins when both match.
663        // Temporal check is first — "last week" causes Episodic, not Graph.
664        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        // MED-01: "Chicago" contains "ago" but must NOT route to Episodic.
675        // word-boundary check prevents this false positive.
676        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    // ── resolve_temporal_range tests ─────────────────────────────────────────
693
694    #[test]
695    fn resolve_yesterday_range() {
696        let now = fixed_now(); // 2026-03-14 12:00:00 UTC
697        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(); // 2026-03-14 12:00:00 UTC
705        let range = resolve_temporal_range("remember last week's discussion", now).unwrap();
706        // 7 days before 2026-03-14 = 2026-03-07
707        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        // 30 days before 2026-03-14 = 2026-02-12
716        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        // "when did", "remember when", "last time", "how long ago" — no specific range
761        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    // ── strip_temporal_keywords tests ────────────────────────────────────────
767
768    #[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        // "ago" removed, rest preserved
790        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        // When the entire query is a temporal keyword, stripping leaves an empty string.
808        // recall_routed falls back to the original query in this case.
809        assert_eq!(strip_temporal_keywords("yesterday"), "");
810    }
811
812    #[test]
813    fn strip_repeated_temporal_keyword_removes_all_occurrences() {
814        // IMPL-02: all occurrences must be removed, not just the first.
815        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}