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
6use crate::graph::EdgeType;
7
8/// Classification of which memory backend(s) to query.
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum MemoryRoute {
11    /// Full-text search only (`SQLite` FTS5). Fast, good for keyword/exact queries.
12    Keyword,
13    /// Vector search only (Qdrant). Good for semantic/conceptual queries.
14    Semantic,
15    /// Both backends, results merged by reciprocal rank fusion.
16    Hybrid,
17    /// Graph-based retrieval via BFS traversal. Good for relationship queries.
18    /// When the `graph-memory` feature is disabled, callers treat this as `Hybrid`.
19    Graph,
20    /// FTS5 search with a timestamp-range filter. Used for temporal/episodic queries
21    /// ("what did we discuss yesterday", "last week's conversation about Rust").
22    ///
23    /// Known trade-off (MVP): skips vector search entirely for speed. Semantically similar
24    /// but lexically different messages may be missed. Use `Hybrid` route when semantic
25    /// precision matters more than temporal filtering.
26    Episodic,
27}
28
29/// Decides which memory backend(s) to query for a given input.
30pub trait MemoryRouter: Send + Sync {
31    /// Route a query to the appropriate backend(s).
32    fn route(&self, query: &str) -> MemoryRoute;
33}
34
35/// Resolved datetime boundaries for a temporal query.
36///
37/// Both fields use `SQLite` datetime format (`YYYY-MM-DD HH:MM:SS`, UTC).
38/// `None` means "no bound" on that side.
39///
40/// Note: All timestamps are UTC. The `created_at` column in the `messages` table
41/// defaults to `datetime('now')` which is also UTC, so comparisons are consistent.
42/// Users in non-UTC timezones may get slightly unexpected results for "yesterday"
43/// queries (e.g. at 01:00 UTC+5 the user's local yesterday differs from UTC yesterday).
44/// This is an accepted approximation for the heuristic-only MVP.
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct TemporalRange {
47    /// Exclusive lower bound: `created_at > after`.
48    pub after: Option<String>,
49    /// Exclusive upper bound: `created_at < before`.
50    pub before: Option<String>,
51}
52
53/// Temporal patterns that indicate an episodic / time-scoped recall query.
54///
55/// Multi-word patterns are preferred over single-word ones to reduce false positives.
56/// Single-word patterns that can appear inside other words (e.g. "ago" in "Chicago")
57/// must be checked with `contains_word()` to enforce word-boundary semantics.
58///
59/// Omitted on purpose: "before", "after", "since", "during", "earlier", "recently"
60/// — these are too ambiguous in technical contexts ("before the function returns",
61/// "since you asked", "during compilation"). They are not in this list.
62const TEMPORAL_PATTERNS: &[&str] = &[
63    // relative day
64    "yesterday",
65    "today",
66    "this morning",
67    "tonight",
68    "last night",
69    // relative week
70    "last week",
71    "this week",
72    "past week",
73    // relative month
74    "last month",
75    "this month",
76    "past month",
77    // temporal questions
78    "when did",
79    "remember when",
80    "last time",
81    "how long ago",
82    // relative phrases requiring word-boundary check
83    // (checked separately via `contains_word` to avoid matching "a few days ago" substring in longer words)
84    "few days ago",
85    "few hours ago",
86    "earlier today",
87];
88
89/// Single-word temporal tokens that require word-boundary checking.
90/// These are NOT in `TEMPORAL_PATTERNS` to avoid substring false positives.
91const WORD_BOUNDARY_TEMPORAL: &[&str] = &["ago"];
92
93/// MAGMA causal edge markers.
94///
95/// Shared between [`HeuristicRouter`] and [`classify_graph_subgraph`] to prevent
96/// pattern-list drift between the two classifiers (critic suggestion).
97pub(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
112/// MAGMA temporal edge markers for subgraph classification.
113///
114/// Shared between [`HeuristicRouter`] and [`classify_graph_subgraph`].
115/// Note: these are distinct from `TEMPORAL_PATTERNS` (which drive `Episodic` routing).
116/// `TEMPORAL_MARKERS` detect edges whose *semantics* are temporal (sequencing/ordering),
117/// while `TEMPORAL_PATTERNS` detect queries that ask about *when* events occurred.
118pub(crate) const TEMPORAL_MARKERS: &[&str] = &[
119    "before", "after", "first", "then", "timeline", "sequence", "preceded", "followed", "started",
120    "ended", "during", "prior",
121];
122
123/// MAGMA entity/structural markers.
124pub(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/// Classify a query into the MAGMA edge types to use for subgraph-scoped BFS retrieval.
138///
139/// Pure heuristic, zero latency — no LLM call. Returns a prioritised list of [`EdgeType`]s.
140///
141/// Rules (checked in order):
142/// 1. Causal markers → include `Causal`
143/// 2. Temporal markers → include `Temporal`
144/// 3. Entity/structural markers → include `Entity`
145/// 4. `Semantic` is always included as fallback to guarantee recall >= current untyped BFS.
146///
147/// Multiple markers may match, producing a union of detected types.
148///
149/// # Example
150///
151/// ```
152/// # use zeph_memory::router::classify_graph_subgraph;
153/// # use zeph_memory::EdgeType;
154/// let types = classify_graph_subgraph("why did X happen");
155/// assert!(types.contains(&EdgeType::Causal));
156/// assert!(types.contains(&EdgeType::Semantic));
157/// ```
158#[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    // Semantic is always included as fallback — recall cannot be worse than untyped BFS.
174    if !types.contains(&EdgeType::Semantic) {
175        types.push(EdgeType::Semantic);
176    }
177
178    types
179}
180
181/// Heuristic-based memory router.
182///
183/// Decision logic (in priority order):
184/// 1. Temporal patterns → `Episodic`
185/// 2. Relationship patterns → `Graph`
186/// 3. Code-like patterns (paths, `::`) without question word → `Keyword`
187/// 4. Long NL query or question word → `Semantic`
188/// 5. Short non-question query → `Keyword`
189/// 6. Default → `Hybrid`
190pub struct HeuristicRouter;
191
192const QUESTION_WORDS: &[&str] = &[
193    "what", "how", "why", "when", "where", "who", "which", "explain", "describe",
194];
195
196/// Simple substrings that signal a relationship query (checked via `str::contains`).
197/// Only used when the `graph-memory` feature is enabled.
198const 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
210/// Returns true if `text` contains `word` as a whole word (word-boundary semantics).
211///
212/// A "word boundary" here means the character before and after `word` (if present)
213/// is not an ASCII alphanumeric character or underscore.
214fn 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
235/// Returns true if the lowercased query contains any temporal cue that indicates
236/// an episodic / time-scoped recall request.
237fn 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
246/// Temporal patterns sorted longest-first for stripping. Initialized once via `LazyLock`
247/// to avoid allocating and sorting on every call to `strip_temporal_keywords`.
248static 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/// Strip matched temporal keywords from a query string before passing to FTS5.
256///
257/// Temporal keywords are routing metadata, not search terms. Passing them to FTS5
258/// causes BM25 score distortion — messages that literally mention "yesterday" get
259/// boosted regardless of actual content relevance.
260///
261/// All occurrences of each pattern are removed (not just the first), preventing
262/// score distortion from repeated temporal tokens in edge cases like
263/// "yesterday I mentioned yesterday's bug".
264///
265/// # Example
266/// ```
267/// # use zeph_memory::router::strip_temporal_keywords;
268/// let cleaned = strip_temporal_keywords("what did we discuss yesterday about Rust");
269/// assert_eq!(cleaned, "what did we discuss about Rust");
270/// ```
271#[must_use]
272pub fn strip_temporal_keywords(query: &str) -> String {
273    // Lowercase once for pattern matching; track removal positions in the original string.
274    // We operate on the lowercased copy for matching, then remove spans from `result`
275    // by rebuilding via byte indices (both strings have identical byte lengths because
276    // to_ascii_lowercase is a 1:1 byte mapping for ASCII).
277    let lower = query.to_ascii_lowercase();
278    // Collect all (start, end) spans to remove, then rebuild the string in one pass.
279    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    // Strip word-boundary tokens (single-word, e.g. "ago") — all occurrences.
292    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        // Fast path: no patterns found — return the original string.
314        return query.split_whitespace().collect::<Vec<_>>().join(" ");
315    }
316
317    // Merge overlapping/adjacent spans and remove them from the original string.
318    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    // Collapse multiple spaces and trim.
333    // SAFETY: We only removed ASCII byte spans; remaining bytes are still valid UTF-8.
334    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/// Resolve temporal keywords in `query` to a `(after, before)` datetime boundary pair.
342///
343/// Returns `None` when no specific range can be computed (the episodic path then falls
344/// back to FTS5 without a time filter, relying on temporal decay for recency boosting).
345///
346/// The `now` parameter is injectable for deterministic unit testing. Production callers
347/// should pass `chrono::Utc::now()`.
348///
349/// All datetime strings are in `SQLite` format: `YYYY-MM-DD HH:MM:SS` (UTC).
350#[must_use]
351pub fn resolve_temporal_range(query: &str, now: DateTime<Utc>) -> Option<TemporalRange> {
352    let lower = query.to_ascii_lowercase();
353
354    // yesterday: the full calendar day before today (UTC)
355    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    // last night: 18:00 yesterday to 06:00 today (UTC approximation)
364    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    // tonight: 18:00 today onwards
374    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    // this morning: midnight to noon today
383    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    // today / earlier today: midnight to now.
392    // Note: "earlier today" always contains "today", so a separate branch would be
393    // dead code — the "today" check subsumes it.
394    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    // last week / past week / this week: 7-day lookback
403    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    // last month / past month / this month: 30-day lookback (approximate)
412    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    // "few days ago" / "few hours ago": 3-day lookback
422    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    // "ago" (word-boundary): generic recent lookback (24h)
438    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    // Generic temporal cues without a specific range ("when did", "remember when",
447    // "last time", "how long ago") — fall back to FTS5-only with temporal decay.
448    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
457/// Returns true if `word` is a pure `snake_case` identifier (all ASCII, lowercase letters,
458/// digits and underscores, contains at least one underscore, not purely numeric).
459fn 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        // 1. Temporal queries take highest priority — must run before relationship check
477        //    to prevent "history of changes last week" from routing to Graph instead of Episodic.
478        if has_temporal_cue(&lower) {
479            return MemoryRoute::Episodic;
480        }
481
482        // 2. Relationship queries go to graph retrieval (feature-gated at call site)
483        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        // Code-like patterns that unambiguously indicate keyword search:
492        // file paths (contain '/'), Rust paths (contain '::')
493        let has_structural_code_pattern = query.contains('/') || query.contains("::");
494
495        // Pure snake_case identifiers (e.g. "memory_limit", "error_handling")
496        // but only if the query does NOT start with a question word
497        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        // Long NL queries → semantic, regardless of snake_case tokens
505        if question || word_count >= 6 {
506            return MemoryRoute::Semantic;
507        }
508
509        // Short queries without question words → keyword
510        if word_count <= 3 && !question {
511            return MemoryRoute::Keyword;
512        }
513
514        // Short code-like patterns → keyword
515        if has_snake_case {
516            return MemoryRoute::Keyword;
517        }
518
519        // Default
520        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        // 2026-03-14 12:00:00 UTC — fixed reference point for all temporal tests
536        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        // "what is the memory_limit setting" — question word overrides snake_case heuristic
561        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        // 4-5 words, no question word, no code pattern
596        assert_eq!(route("context window token budget"), MemoryRoute::Hybrid);
597    }
598
599    #[test]
600    fn empty_query_routes_keyword() {
601        // 0 words, no question → keyword (short path)
602        assert_eq!(route(""), MemoryRoute::Keyword);
603    }
604
605    #[test]
606    fn question_word_only_routes_semantic() {
607        // single question word → word_count = 1, but starts_with_question = true
608        // short query with question: the question check happens first in semantic branch
609        // Actually with word_count=1 and question=true: short path `<= 3 && !question` is false,
610        // then `question || word_count >= 6` is true → Semantic
611        assert_eq!(route("what"), MemoryRoute::Semantic);
612    }
613
614    #[test]
615    fn camel_case_does_not_route_keyword_without_pattern() {
616        // CamelCase words without :: or / — 4-word query without question word → Hybrid
617        // (4 words: no question, no snake_case, no structural code pattern → Hybrid)
618        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        // "translate" contains "relate" substring but is not in RELATIONSHIP_PATTERNS
656        // (we removed bare "relate", keeping only "related to" and "relates to")
657        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    // Regression tests for #1661: long NL queries with snake_case must go to Semantic
674    #[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    // ── Temporal routing tests ────────────────────────────────────────────────
696
697    #[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        // CRIT-01: "history of" is a relationship pattern, but temporal wins when both match.
753        // Temporal check is first — "last week" causes Episodic, not Graph.
754        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        // MED-01: "Chicago" contains "ago" but must NOT route to Episodic.
765        // word-boundary check prevents this false positive.
766        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    // ── resolve_temporal_range tests ─────────────────────────────────────────
783
784    #[test]
785    fn resolve_yesterday_range() {
786        let now = fixed_now(); // 2026-03-14 12:00:00 UTC
787        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(); // 2026-03-14 12:00:00 UTC
795        let range = resolve_temporal_range("remember last week's discussion", now).unwrap();
796        // 7 days before 2026-03-14 = 2026-03-07
797        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        // 30 days before 2026-03-14 = 2026-02-12
806        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        // "when did", "remember when", "last time", "how long ago" — no specific range
851        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    // ── strip_temporal_keywords tests ────────────────────────────────────────
857
858    #[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        // "ago" removed, rest preserved
880        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        // When the entire query is a temporal keyword, stripping leaves an empty string.
898        // recall_routed falls back to the original query in this case.
899        assert_eq!(strip_temporal_keywords("yesterday"), "");
900    }
901
902    #[test]
903    fn strip_repeated_temporal_keyword_removes_all_occurrences() {
904        // IMPL-02: all occurrences must be removed, not just the first.
905        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}