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
4//! Memory routing — classify recall queries and dispatch to the right backend.
5//!
6//! The [`MemoryRouter`] trait is implemented by:
7//!
8//! | Type | Strategy |
9//! |------|----------|
10//! | [`HeuristicRouter`] | Fast regex/keyword pattern matching. No LLM call. |
11//! | [`LlmRouter`] | Uses an LLM classifier for high-accuracy routing. |
12//! | [`HybridRouter`] | Runs [`HeuristicRouter`] first; escalates to [`LlmRouter`] when confidence is low. |
13//! | [`AsyncMemoryRouter`] | Async wrapper over any `MemoryRouter` for use in async contexts. |
14//!
15//! # Routing pipeline
16//!
17//! 1. `HeuristicRouter` classifies the query in < 1 ms using temporal-keyword detection
18//!    and graph-relationship pattern matching.
19//! 2. If confidence >= threshold, the route is used directly.
20//! 3. Otherwise, `HybridRouter` forwards the query to `LlmRouter` for a second opinion.
21
22use chrono::{DateTime, Duration, Utc};
23
24pub use zeph_common::memory::{
25    AsyncMemoryRouter, CAUSAL_MARKERS, ENTITY_MARKERS, MemoryRoute, MemoryRouter, RoutingDecision,
26    TEMPORAL_MARKERS, WORD_BOUNDARY_TEMPORAL, classify_graph_subgraph, parse_route_str,
27};
28
29/// Resolved datetime boundaries for a temporal query.
30///
31/// Both fields use `SQLite` datetime format (`YYYY-MM-DD HH:MM:SS`, UTC).
32/// `None` means "no bound" on that side.
33///
34/// Note: All timestamps are UTC. The `created_at` column in the `messages` table
35/// defaults to `datetime('now')` which is also UTC, so comparisons are consistent.
36/// Users in non-UTC timezones may get slightly unexpected results for "yesterday"
37/// queries (e.g. at 01:00 UTC+5 the user's local yesterday differs from UTC yesterday).
38/// This is an accepted approximation for the heuristic-only MVP.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct TemporalRange {
41    /// Exclusive lower bound: `created_at > after`.
42    pub after: Option<String>,
43    /// Exclusive upper bound: `created_at < before`.
44    pub before: Option<String>,
45}
46
47/// Temporal patterns that indicate an episodic / time-scoped recall query.
48///
49/// Multi-word patterns are preferred over single-word ones to reduce false positives.
50/// Single-word patterns that can appear inside other words (e.g. "ago" in "Chicago")
51/// must be checked with `contains_word()` to enforce word-boundary semantics.
52///
53/// Omitted on purpose: "before", "after", "since", "during", "earlier", "recently"
54/// — these are too ambiguous in technical contexts ("before the function returns",
55/// "since you asked", "during compilation"). They are not in this list.
56const TEMPORAL_PATTERNS: &[&str] = &[
57    // relative day
58    "yesterday",
59    "today",
60    "this morning",
61    "tonight",
62    "last night",
63    // relative week
64    "last week",
65    "this week",
66    "past week",
67    // relative month
68    "last month",
69    "this month",
70    "past month",
71    // temporal questions
72    "when did",
73    "remember when",
74    "last time",
75    "how long ago",
76    // relative phrases requiring word-boundary check
77    // (checked separately via `contains_word` to avoid matching "a few days ago" substring in longer words)
78    "few days ago",
79    "few hours ago",
80    "earlier today",
81];
82
83/// Heuristic-based memory router.
84///
85/// Decision logic (in priority order):
86/// 1. Temporal patterns → `Episodic`
87/// 2. Relationship patterns → `Graph`
88/// 3. Code-like patterns (paths, `::`) without question word → `Keyword`
89/// 4. Long NL query or question word → `Semantic`
90/// 5. Short non-question query → `Keyword`
91/// 6. Default → `Hybrid`
92pub struct HeuristicRouter;
93
94const QUESTION_WORDS: &[&str] = &[
95    "what", "how", "why", "when", "where", "who", "which", "explain", "describe",
96];
97
98/// Simple substrings that signal a relationship query (checked via `str::contains`).
99/// Only used when the `graph-memory` feature is enabled.
100const RELATIONSHIP_PATTERNS: &[&str] = &[
101    "related to",
102    "relates to",
103    "connection between",
104    "relationship",
105    "opinion on",
106    "thinks about",
107    "preference for",
108    "history of",
109    "know about",
110];
111
112/// Returns true if `text` contains `word` as a whole word (word-boundary semantics).
113///
114/// A "word boundary" here means the character before and after `word` (if present)
115/// is not an ASCII alphanumeric character or underscore.
116fn contains_word(text: &str, word: &str) -> bool {
117    let bytes = text.as_bytes();
118    let wbytes = word.as_bytes();
119    let wlen = wbytes.len();
120    if wlen > bytes.len() {
121        return false;
122    }
123    for start in 0..=(bytes.len() - wlen) {
124        if bytes[start..start + wlen].eq_ignore_ascii_case(wbytes) {
125            let before_ok =
126                start == 0 || !bytes[start - 1].is_ascii_alphanumeric() && bytes[start - 1] != b'_';
127            let after_ok = start + wlen == bytes.len()
128                || !bytes[start + wlen].is_ascii_alphanumeric() && bytes[start + wlen] != b'_';
129            if before_ok && after_ok {
130                return true;
131            }
132        }
133    }
134    false
135}
136
137/// Returns true if the lowercased query contains any temporal cue that indicates
138/// an episodic / time-scoped recall request.
139fn has_temporal_cue(lower: &str) -> bool {
140    if TEMPORAL_PATTERNS.iter().any(|p| lower.contains(p)) {
141        return true;
142    }
143    WORD_BOUNDARY_TEMPORAL
144        .iter()
145        .any(|w| contains_word(lower, w))
146}
147
148/// Temporal patterns sorted longest-first for stripping. Initialized once via `LazyLock`
149/// to avoid allocating and sorting on every call to `strip_temporal_keywords`.
150static SORTED_TEMPORAL_PATTERNS: std::sync::LazyLock<Vec<&'static str>> =
151    std::sync::LazyLock::new(|| {
152        let mut v: Vec<&str> = TEMPORAL_PATTERNS.to_vec();
153        v.sort_by_key(|p| std::cmp::Reverse(p.len()));
154        v
155    });
156
157/// Strip matched temporal keywords from a query string before passing to FTS5.
158///
159/// Temporal keywords are routing metadata, not search terms. Passing them to FTS5
160/// causes BM25 score distortion — messages that literally mention "yesterday" get
161/// boosted regardless of actual content relevance.
162///
163/// All occurrences of each pattern are removed (not just the first), preventing
164/// score distortion from repeated temporal tokens in edge cases like
165/// "yesterday I mentioned yesterday's bug".
166///
167/// # Example
168/// ```
169/// # use zeph_memory::router::strip_temporal_keywords;
170/// let cleaned = strip_temporal_keywords("what did we discuss yesterday about Rust");
171/// assert_eq!(cleaned, "what did we discuss about Rust");
172/// ```
173#[must_use]
174pub fn strip_temporal_keywords(query: &str) -> String {
175    // Lowercase once for pattern matching; track removal positions in the original string.
176    // We operate on the lowercased copy for matching, then remove spans from `result`
177    // by rebuilding via byte indices (both strings have identical byte lengths because
178    // to_ascii_lowercase is a 1:1 byte mapping for ASCII).
179    let lower = query.to_ascii_lowercase();
180    // Collect all (start, end) spans to remove, then rebuild the string in one pass.
181    let mut remove: Vec<(usize, usize)> = Vec::new();
182
183    for pattern in SORTED_TEMPORAL_PATTERNS.iter() {
184        let plen = pattern.len();
185        let mut search_from = 0;
186        while let Some(pos) = lower[search_from..].find(pattern) {
187            let abs = search_from + pos;
188            remove.push((abs, abs + plen));
189            search_from = abs + plen;
190        }
191    }
192
193    // Strip word-boundary tokens (single-word, e.g. "ago") — all occurrences.
194    for word in WORD_BOUNDARY_TEMPORAL {
195        let wlen = word.len();
196        let lbytes = lower.as_bytes();
197        let mut i = 0;
198        while i + wlen <= lower.len() {
199            if lower[i..].starts_with(*word) {
200                let before_ok =
201                    i == 0 || !lbytes[i - 1].is_ascii_alphanumeric() && lbytes[i - 1] != b'_';
202                let after_ok = i + wlen == lower.len()
203                    || !lbytes[i + wlen].is_ascii_alphanumeric() && lbytes[i + wlen] != b'_';
204                if before_ok && after_ok {
205                    remove.push((i, i + wlen));
206                    i += wlen;
207                    continue;
208                }
209            }
210            i += 1;
211        }
212    }
213
214    if remove.is_empty() {
215        // Fast path: no patterns found — return the original string.
216        return query.split_whitespace().collect::<Vec<_>>().join(" ");
217    }
218
219    // Merge overlapping/adjacent spans and remove them from the original string.
220    remove.sort_unstable_by_key(|r| r.0);
221    let bytes = query.as_bytes();
222    let mut result = Vec::with_capacity(query.len());
223    let mut cursor = 0;
224    for (start, end) in remove {
225        if start > cursor {
226            result.extend_from_slice(&bytes[cursor..start]);
227        }
228        cursor = cursor.max(end);
229    }
230    if cursor < bytes.len() {
231        result.extend_from_slice(&bytes[cursor..]);
232    }
233
234    // Collapse multiple spaces and trim.
235    // SAFETY: We only removed ASCII byte spans; remaining bytes are still valid UTF-8.
236    let s = String::from_utf8(result).unwrap_or_default();
237    s.split_whitespace()
238        .filter(|t| !t.is_empty())
239        .collect::<Vec<_>>()
240        .join(" ")
241}
242
243/// Resolve temporal keywords in `query` to a `(after, before)` datetime boundary pair.
244///
245/// Returns `None` when no specific range can be computed (the episodic path then falls
246/// back to FTS5 without a time filter, relying on temporal decay for recency boosting).
247///
248/// The `now` parameter is injectable for deterministic unit testing. Production callers
249/// should pass `chrono::Utc::now()`.
250///
251/// All datetime strings are in `SQLite` format: `YYYY-MM-DD HH:MM:SS` (UTC).
252#[must_use]
253pub fn resolve_temporal_range(query: &str, now: DateTime<Utc>) -> Option<TemporalRange> {
254    let lower = query.to_ascii_lowercase();
255
256    // yesterday: the full calendar day before today (UTC)
257    if lower.contains("yesterday") {
258        let yesterday = now.date_naive() - Duration::days(1);
259        return Some(TemporalRange {
260            after: Some(format!("{yesterday} 00:00:00")),
261            before: Some(format!("{yesterday} 23:59:59")),
262        });
263    }
264
265    // last night: 18:00 yesterday to 06:00 today (UTC approximation)
266    if lower.contains("last night") {
267        let yesterday = now.date_naive() - Duration::days(1);
268        let today = now.date_naive();
269        return Some(TemporalRange {
270            after: Some(format!("{yesterday} 18:00:00")),
271            before: Some(format!("{today} 06:00:00")),
272        });
273    }
274
275    // tonight: 18:00 today onwards
276    if lower.contains("tonight") {
277        let today = now.date_naive();
278        return Some(TemporalRange {
279            after: Some(format!("{today} 18:00:00")),
280            before: None,
281        });
282    }
283
284    // this morning: midnight to noon today
285    if lower.contains("this morning") {
286        let today = now.date_naive();
287        return Some(TemporalRange {
288            after: Some(format!("{today} 00:00:00")),
289            before: Some(format!("{today} 12:00:00")),
290        });
291    }
292
293    // today / earlier today: midnight to now.
294    // Note: "earlier today" always contains "today", so a separate branch would be
295    // dead code — the "today" check subsumes it.
296    if lower.contains("today") {
297        let today = now.date_naive();
298        return Some(TemporalRange {
299            after: Some(format!("{today} 00:00:00")),
300            before: None,
301        });
302    }
303
304    // last week / past week / this week: 7-day lookback
305    if lower.contains("last week") || lower.contains("past week") || lower.contains("this week") {
306        let start = now - Duration::days(7);
307        return Some(TemporalRange {
308            after: Some(start.format("%Y-%m-%d %H:%M:%S").to_string()),
309            before: None,
310        });
311    }
312
313    // last month / past month / this month: 30-day lookback (approximate)
314    if lower.contains("last month") || lower.contains("past month") || lower.contains("this month")
315    {
316        let start = now - Duration::days(30);
317        return Some(TemporalRange {
318            after: Some(start.format("%Y-%m-%d %H:%M:%S").to_string()),
319            before: None,
320        });
321    }
322
323    // "few days ago" / "few hours ago": 3-day lookback
324    if lower.contains("few days ago") {
325        let start = now - Duration::days(3);
326        return Some(TemporalRange {
327            after: Some(start.format("%Y-%m-%d %H:%M:%S").to_string()),
328            before: None,
329        });
330    }
331    if lower.contains("few hours ago") {
332        let start = now - Duration::hours(6);
333        return Some(TemporalRange {
334            after: Some(start.format("%Y-%m-%d %H:%M:%S").to_string()),
335            before: None,
336        });
337    }
338
339    // "ago" (word-boundary): generic recent lookback (24h)
340    if contains_word(&lower, "ago") {
341        let start = now - Duration::hours(24);
342        return Some(TemporalRange {
343            after: Some(start.format("%Y-%m-%d %H:%M:%S").to_string()),
344            before: None,
345        });
346    }
347
348    // Generic temporal cues without a specific range ("when did", "remember when",
349    // "last time", "how long ago") — fall back to FTS5-only with temporal decay.
350    None
351}
352
353fn starts_with_question(words: &[&str]) -> bool {
354    words
355        .first()
356        .is_some_and(|w| QUESTION_WORDS.iter().any(|qw| w.eq_ignore_ascii_case(qw)))
357}
358
359/// Classification signals computed once per query and shared by `route()` and
360/// `route_with_confidence()`, so neither has to recompute the same six checks.
361#[allow(clippy::struct_excessive_bools)] // independent classification flags, not state — each is checked separately in the priority chain
362struct RouteSignals {
363    temporal: bool,
364    relationship: bool,
365    structural_code: bool,
366    question: bool,
367    word_count: usize,
368    snake_case: bool,
369}
370
371/// Compute every routing signal for `query` exactly once.
372fn compute_signals(query: &str) -> RouteSignals {
373    let lower = query.to_ascii_lowercase();
374    let words: Vec<&str> = query.split_whitespace().collect();
375    RouteSignals {
376        temporal: has_temporal_cue(&lower),
377        relationship: RELATIONSHIP_PATTERNS.iter().any(|p| lower.contains(p)),
378        structural_code: query.contains('/') || query.contains("::"),
379        question: starts_with_question(&words),
380        word_count: words.len(),
381        snake_case: words.iter().any(|w| is_pure_snake_case(w)),
382    }
383}
384
385/// Returns true if `word` is a pure `snake_case` identifier (all ASCII, lowercase letters,
386/// digits and underscores, contains at least one underscore, not purely numeric).
387fn is_pure_snake_case(word: &str) -> bool {
388    if word.is_empty() {
389        return false;
390    }
391    let has_underscore = word.contains('_');
392    if !has_underscore {
393        return false;
394    }
395    word.chars()
396        .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
397        && !word.chars().all(|c| c.is_ascii_digit() || c == '_')
398}
399
400impl HeuristicRouter {
401    /// Decide the route from precomputed signals.
402    ///
403    /// Priority order:
404    /// 1. Temporal patterns → `Episodic` (checked first so e.g. "history of changes
405    ///    last week" routes to `Episodic`, not `Graph`).
406    /// 2. Relationship patterns → `Graph`.
407    /// 3. Code-like patterns (paths, `::`) without a question word → `Keyword`.
408    /// 4. Long NL query or question word → `Semantic`.
409    /// 5. Short non-question query → `Keyword`.
410    /// 6. Pure `snake_case` identifiers → `Keyword`.
411    /// 7. Default → `Hybrid`.
412    fn decide_route(signals: &RouteSignals) -> MemoryRoute {
413        if signals.temporal {
414            return MemoryRoute::Episodic;
415        }
416        if signals.relationship {
417            return MemoryRoute::Graph;
418        }
419        if signals.structural_code && !signals.question {
420            return MemoryRoute::Keyword;
421        }
422        if signals.question || signals.word_count >= 6 {
423            return MemoryRoute::Semantic;
424        }
425        if signals.word_count <= 3 && !signals.question {
426            return MemoryRoute::Keyword;
427        }
428        if signals.snake_case {
429            return MemoryRoute::Keyword;
430        }
431        MemoryRoute::Hybrid
432    }
433
434    /// Returns a confidence signal based on pattern match count (W2.1 fix: gradual scale).
435    ///
436    /// - Exactly one route pattern matches → confidence `1.0` (clear signal)
437    /// - Zero patterns match → confidence `0.0` (pure default fallback)
438    /// - More than one pattern matches → confidence `1.0 / matched_count` (ambiguous, decreasing)
439    #[allow(clippy::cast_precision_loss)]
440    fn decide_confidence(signals: &RouteSignals) -> f32 {
441        let mut matched: u32 = 0;
442        if signals.temporal {
443            matched += 1;
444        }
445        if signals.relationship {
446            matched += 1;
447        }
448        if signals.structural_code && !signals.question {
449            matched += 1;
450        }
451        if signals.question || signals.word_count >= 6 {
452            matched += 1;
453        }
454        if signals.word_count <= 3 && !signals.question {
455            matched += 1;
456        }
457        if signals.snake_case {
458            matched += 1;
459        }
460
461        match matched {
462            0 => 0.0,
463            1 => 1.0,
464            n => 1.0 / n as f32,
465        }
466    }
467}
468
469impl MemoryRouter for HeuristicRouter {
470    fn route_with_confidence(&self, query: &str) -> RoutingDecision {
471        let signals = compute_signals(query);
472        RoutingDecision {
473            route: Self::decide_route(&signals),
474            confidence: Self::decide_confidence(&signals),
475            reasoning: None,
476        }
477    }
478
479    fn route(&self, query: &str) -> MemoryRoute {
480        Self::decide_route(&compute_signals(query))
481    }
482}
483
484/// LLM-based memory router.
485///
486/// Sends the query to the configured provider and parses a JSON response:
487/// `{"route": "keyword|semantic|hybrid|graph|episodic", "confidence": 0.0-1.0}`.
488///
489/// On LLM failure, falls back to `HeuristicRouter`.
490pub struct LlmRouter {
491    provider: std::sync::Arc<zeph_llm::any::AnyProvider>,
492    fallback_route: MemoryRoute,
493}
494
495impl LlmRouter {
496    /// Create a new `LlmRouter`.
497    ///
498    /// - `provider` — LLM provider used for classification.
499    /// - `fallback_route` — route used when the LLM call fails.
500    #[must_use]
501    pub fn new(
502        provider: std::sync::Arc<zeph_llm::any::AnyProvider>,
503        fallback_route: MemoryRoute,
504    ) -> Self {
505        Self {
506            provider,
507            fallback_route,
508        }
509    }
510
511    async fn classify_async(&self, query: &str) -> RoutingDecision {
512        use zeph_llm::provider::{LlmProvider as _, Message, MessageMetadata, Role};
513
514        let system = "You are a memory store routing classifier. \
515            Given a user query, decide which memory backend is most appropriate. \
516            Respond with ONLY a JSON object: \
517            {\"route\": \"<route>\", \"confidence\": <0.0-1.0>, \"reasoning\": \"<brief>\"} \
518            where <route> is one of: keyword, semantic, hybrid, graph, episodic. \
519            Use 'keyword' for exact/code lookups, 'semantic' for conceptual questions, \
520            'hybrid' for mixed, 'graph' for relationship queries, 'episodic' for time-scoped queries.";
521
522        // Wrap query in delimiters to prevent injection (W2.2 fix).
523        let user = format!(
524            "<query>{}</query>",
525            query.chars().take(500).collect::<String>()
526        );
527
528        let messages = vec![
529            Message {
530                role: Role::System,
531                content: system.to_owned(),
532                parts: vec![],
533                metadata: MessageMetadata::default(),
534            },
535            Message {
536                role: Role::User,
537                content: user,
538                parts: vec![],
539                metadata: MessageMetadata::default(),
540            },
541        ];
542
543        let result = match tokio::time::timeout(
544            std::time::Duration::from_secs(5),
545            self.provider.chat(&messages),
546        )
547        .await
548        {
549            Ok(Ok(r)) => r,
550            Ok(Err(e)) => {
551                tracing::debug!(error = %e, "LlmRouter: LLM call failed, falling back to heuristic");
552                return Self::heuristic_fallback(query);
553            }
554            Err(_) => {
555                tracing::debug!("LlmRouter: LLM timed out, falling back to heuristic");
556                return Self::heuristic_fallback(query);
557            }
558        };
559
560        self.parse_llm_response(&result, query)
561    }
562
563    fn parse_llm_response(&self, raw: &str, query: &str) -> RoutingDecision {
564        // Extract JSON object from the response (may have surrounding text).
565        let json_str = raw
566            .find('{')
567            .and_then(|start| raw[start..].rfind('}').map(|end| &raw[start..=start + end]))
568            .unwrap_or("");
569
570        if let Ok(v) = serde_json::from_str::<serde_json::Value>(json_str) {
571            let route_str = v.get("route").and_then(|r| r.as_str()).unwrap_or("hybrid");
572            #[allow(clippy::cast_possible_truncation)]
573            let confidence = v
574                .get("confidence")
575                .and_then(serde_json::Value::as_f64)
576                .map_or(0.5, |c| c.clamp(0.0, 1.0) as f32);
577            let reasoning = v
578                .get("reasoning")
579                .and_then(|r| r.as_str())
580                .map(str::to_owned);
581
582            let route = parse_route_str(route_str, self.fallback_route);
583
584            tracing::debug!(
585                query = &query[..query.len().min(60)],
586                ?route,
587                confidence,
588                "LlmRouter: classified"
589            );
590
591            return RoutingDecision {
592                route,
593                confidence,
594                reasoning,
595            };
596        }
597
598        tracing::debug!("LlmRouter: failed to parse JSON response, falling back to heuristic");
599        Self::heuristic_fallback(query)
600    }
601
602    fn heuristic_fallback(query: &str) -> RoutingDecision {
603        HeuristicRouter.route_with_confidence(query)
604    }
605}
606
607impl MemoryRouter for LlmRouter {
608    fn route(&self, query: &str) -> MemoryRoute {
609        // Sync path: LLM is not available without an async executor.
610        // Falls back to heuristic — use route_async() for LLM-based classification.
611        HeuristicRouter.route(query)
612    }
613
614    fn route_with_confidence(&self, query: &str) -> RoutingDecision {
615        // LlmRouter is designed for use in async contexts via classify_async.
616        // When called synchronously (e.g. in tests), fall back to heuristic.
617        HeuristicRouter.route_with_confidence(query)
618    }
619}
620
621impl AsyncMemoryRouter for LlmRouter {
622    fn route_async<'a>(
623        &'a self,
624        query: &'a str,
625    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = RoutingDecision> + Send + 'a>> {
626        Box::pin(self.classify_async(query))
627    }
628}
629
630/// Hybrid router: heuristic-first, escalates to LLM when confidence is low.
631///
632/// The `HybridRouter` runs `HeuristicRouter` first. If the heuristic confidence
633/// is below `confidence_threshold`, it escalates to the LLM router.
634/// LLM failures always fall back to the heuristic result.
635pub struct HybridRouter {
636    llm: LlmRouter,
637    confidence_threshold: f32,
638}
639
640impl HybridRouter {
641    /// Create a new `HybridRouter`.
642    ///
643    /// - `confidence_threshold` — heuristic decisions with confidence below this value
644    ///   are escalated to the LLM classifier.  `0.7` is a good default.
645    #[must_use]
646    pub fn new(
647        provider: std::sync::Arc<zeph_llm::any::AnyProvider>,
648        fallback_route: MemoryRoute,
649        confidence_threshold: f32,
650    ) -> Self {
651        Self {
652            llm: LlmRouter::new(provider, fallback_route),
653            confidence_threshold,
654        }
655    }
656
657    pub async fn classify_async(&self, query: &str) -> RoutingDecision {
658        let heuristic = HeuristicRouter.route_with_confidence(query);
659        if heuristic.confidence >= self.confidence_threshold {
660            tracing::debug!(
661                query = &query[..query.len().min(60)],
662                confidence = heuristic.confidence,
663                route = ?heuristic.route,
664                "HybridRouter: heuristic sufficient, skipping LLM"
665            );
666            return heuristic;
667        }
668
669        tracing::debug!(
670            query = &query[..query.len().min(60)],
671            confidence = heuristic.confidence,
672            threshold = self.confidence_threshold,
673            "HybridRouter: low confidence, escalating to LLM"
674        );
675
676        let llm_result = self.llm.classify_async(query).await;
677
678        // LLM failure path: classify_async returns a heuristic fallback on error.
679        // Always log the final decision.
680        tracing::debug!(
681            route = ?llm_result.route,
682            confidence = llm_result.confidence,
683            "HybridRouter: final route after LLM escalation"
684        );
685        llm_result
686    }
687}
688
689impl MemoryRouter for HybridRouter {
690    fn route(&self, query: &str) -> MemoryRoute {
691        HeuristicRouter.route(query)
692    }
693
694    fn route_with_confidence(&self, query: &str) -> RoutingDecision {
695        // Synchronous path: can't call async LLM, use heuristic only.
696        HeuristicRouter.route_with_confidence(query)
697    }
698}
699
700impl AsyncMemoryRouter for HeuristicRouter {
701    fn route_async<'a>(
702        &'a self,
703        query: &'a str,
704    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = RoutingDecision> + Send + 'a>> {
705        Box::pin(std::future::ready(self.route_with_confidence(query)))
706    }
707}
708
709impl AsyncMemoryRouter for HybridRouter {
710    fn route_async<'a>(
711        &'a self,
712        query: &'a str,
713    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = RoutingDecision> + Send + 'a>> {
714        Box::pin(self.classify_async(query))
715    }
716}
717
718#[cfg(test)]
719mod tests {
720    use chrono::TimeZone as _;
721
722    use super::*;
723
724    fn route(q: &str) -> MemoryRoute {
725        HeuristicRouter.route(q)
726    }
727
728    fn fixed_now() -> DateTime<Utc> {
729        // 2026-03-14 12:00:00 UTC — fixed reference point for all temporal tests
730        Utc.with_ymd_and_hms(2026, 3, 14, 12, 0, 0).unwrap()
731    }
732
733    #[test]
734    fn rust_path_routes_keyword() {
735        assert_eq!(route("zeph_memory::recall"), MemoryRoute::Keyword);
736    }
737
738    #[test]
739    fn file_path_routes_keyword() {
740        assert_eq!(
741            route("crates/zeph-core/src/agent/mod.rs"),
742            MemoryRoute::Keyword
743        );
744    }
745
746    #[test]
747    fn pure_snake_case_routes_keyword() {
748        assert_eq!(route("memory_limit"), MemoryRoute::Keyword);
749        assert_eq!(route("error_handling"), MemoryRoute::Keyword);
750    }
751
752    #[test]
753    fn question_with_snake_case_routes_semantic() {
754        // "what is the memory_limit setting" — question word overrides snake_case heuristic
755        assert_eq!(
756            route("what is the memory_limit setting"),
757            MemoryRoute::Semantic
758        );
759        assert_eq!(route("how does error_handling work"), MemoryRoute::Semantic);
760    }
761
762    #[test]
763    fn short_query_routes_keyword() {
764        assert_eq!(route("context compaction"), MemoryRoute::Keyword);
765        assert_eq!(route("qdrant"), MemoryRoute::Keyword);
766    }
767
768    #[test]
769    fn question_routes_semantic() {
770        assert_eq!(
771            route("what is the purpose of semantic memory"),
772            MemoryRoute::Semantic
773        );
774        assert_eq!(route("how does the agent loop work"), MemoryRoute::Semantic);
775        assert_eq!(route("why does compaction fail"), MemoryRoute::Semantic);
776        assert_eq!(route("explain context compression"), MemoryRoute::Semantic);
777    }
778
779    #[test]
780    fn long_natural_query_routes_semantic() {
781        assert_eq!(
782            route("the agent keeps running out of context during long conversations"),
783            MemoryRoute::Semantic
784        );
785    }
786
787    #[test]
788    fn medium_non_question_routes_hybrid() {
789        // 4-5 words, no question word, no code pattern
790        assert_eq!(route("context window token budget"), MemoryRoute::Hybrid);
791    }
792
793    #[test]
794    fn empty_query_routes_keyword() {
795        // 0 words, no question → keyword (short path)
796        assert_eq!(route(""), MemoryRoute::Keyword);
797    }
798
799    #[test]
800    fn question_word_only_routes_semantic() {
801        // single question word → word_count = 1, but starts_with_question = true
802        // short query with question: the question check happens first in semantic branch
803        // Actually with word_count=1 and question=true: short path `<= 3 && !question` is false,
804        // then `question || word_count >= 6` is true → Semantic
805        assert_eq!(route("what"), MemoryRoute::Semantic);
806    }
807
808    #[test]
809    fn camel_case_does_not_route_keyword_without_pattern() {
810        // CamelCase words without :: or / — 4-word query without question word → Hybrid
811        // (4 words: no question, no snake_case, no structural code pattern → Hybrid)
812        assert_eq!(
813            route("SemanticMemory configuration and options"),
814            MemoryRoute::Hybrid
815        );
816    }
817
818    #[test]
819    fn relationship_query_routes_graph() {
820        assert_eq!(
821            route("what is user's opinion on neovim"),
822            MemoryRoute::Graph
823        );
824        assert_eq!(
825            route("show the relationship between Alice and Bob"),
826            MemoryRoute::Graph
827        );
828    }
829
830    #[test]
831    fn relationship_query_related_to_routes_graph() {
832        assert_eq!(
833            route("how is Rust related to this project"),
834            MemoryRoute::Graph
835        );
836        assert_eq!(
837            route("how does this relates to the config"),
838            MemoryRoute::Graph
839        );
840    }
841
842    #[test]
843    fn relationship_know_about_routes_graph() {
844        assert_eq!(route("what do I know about neovim"), MemoryRoute::Graph);
845    }
846
847    #[test]
848    fn translate_does_not_route_graph() {
849        // "translate" contains "relate" substring but is not in RELATIONSHIP_PATTERNS
850        // (we removed bare "relate", keeping only "related to" and "relates to")
851        assert_ne!(route("translate this code to Python"), MemoryRoute::Graph);
852    }
853
854    #[test]
855    fn non_relationship_stays_semantic() {
856        assert_eq!(
857            route("find similar code patterns in the codebase"),
858            MemoryRoute::Semantic
859        );
860    }
861
862    #[test]
863    fn short_keyword_unchanged() {
864        assert_eq!(route("qdrant"), MemoryRoute::Keyword);
865    }
866
867    // Regression tests for #1661: long NL queries with snake_case must go to Semantic
868    #[test]
869    fn long_nl_with_snake_case_routes_semantic() {
870        assert_eq!(
871            route("Use memory_search to find information about Rust ownership"),
872            MemoryRoute::Semantic
873        );
874    }
875
876    #[test]
877    fn short_snake_case_only_routes_keyword() {
878        assert_eq!(route("memory_search"), MemoryRoute::Keyword);
879    }
880
881    #[test]
882    fn question_with_snake_case_short_routes_semantic() {
883        assert_eq!(
884            route("What does memory_search return?"),
885            MemoryRoute::Semantic
886        );
887    }
888
889    // ── Temporal routing tests ────────────────────────────────────────────────
890
891    #[test]
892    fn temporal_yesterday_routes_episodic() {
893        assert_eq!(
894            route("what did we discuss yesterday"),
895            MemoryRoute::Episodic
896        );
897    }
898
899    #[test]
900    fn temporal_last_week_routes_episodic() {
901        assert_eq!(
902            route("remember what happened last week"),
903            MemoryRoute::Episodic
904        );
905    }
906
907    #[test]
908    fn temporal_when_did_routes_episodic() {
909        assert_eq!(
910            route("when did we last talk about Qdrant"),
911            MemoryRoute::Episodic
912        );
913    }
914
915    #[test]
916    fn temporal_last_time_routes_episodic() {
917        assert_eq!(
918            route("last time we discussed the scheduler"),
919            MemoryRoute::Episodic
920        );
921    }
922
923    #[test]
924    fn temporal_today_routes_episodic() {
925        assert_eq!(
926            route("what did I mention today about testing"),
927            MemoryRoute::Episodic
928        );
929    }
930
931    #[test]
932    fn temporal_this_morning_routes_episodic() {
933        assert_eq!(route("what did we say this morning"), MemoryRoute::Episodic);
934    }
935
936    #[test]
937    fn temporal_last_month_routes_episodic() {
938        assert_eq!(
939            route("find the config change from last month"),
940            MemoryRoute::Episodic
941        );
942    }
943
944    #[test]
945    fn temporal_history_collision_routes_episodic() {
946        // CRIT-01: "history of" is a relationship pattern, but temporal wins when both match.
947        // Temporal check is first — "last week" causes Episodic, not Graph.
948        assert_eq!(route("history of changes last week"), MemoryRoute::Episodic);
949    }
950
951    #[test]
952    fn temporal_ago_word_boundary_routes_episodic() {
953        assert_eq!(route("we fixed this a day ago"), MemoryRoute::Episodic);
954    }
955
956    #[test]
957    fn ago_in_chicago_no_false_positive() {
958        // MED-01: "Chicago" contains "ago" but must NOT route to Episodic.
959        // word-boundary check prevents this false positive.
960        assert_ne!(
961            route("meeting in Chicago about the project"),
962            MemoryRoute::Episodic
963        );
964    }
965
966    #[test]
967    fn non_temporal_unchanged() {
968        assert_eq!(route("how does the agent loop work"), MemoryRoute::Semantic);
969    }
970
971    #[test]
972    fn code_query_unchanged() {
973        assert_eq!(route("zeph_memory::recall"), MemoryRoute::Keyword);
974    }
975
976    // ── resolve_temporal_range tests ─────────────────────────────────────────
977
978    #[test]
979    fn resolve_yesterday_range() {
980        let now = fixed_now(); // 2026-03-14 12:00:00 UTC
981        let range = resolve_temporal_range("what did we discuss yesterday", now).unwrap();
982        assert_eq!(range.after.as_deref(), Some("2026-03-13 00:00:00"));
983        assert_eq!(range.before.as_deref(), Some("2026-03-13 23:59:59"));
984    }
985
986    #[test]
987    fn resolve_last_week_range() {
988        let now = fixed_now(); // 2026-03-14 12:00:00 UTC
989        let range = resolve_temporal_range("remember last week's discussion", now).unwrap();
990        // 7 days before 2026-03-14 = 2026-03-07
991        assert!(range.after.as_deref().unwrap().starts_with("2026-03-07"));
992        assert!(range.before.is_none());
993    }
994
995    #[test]
996    fn resolve_last_month_range() {
997        let now = fixed_now();
998        let range = resolve_temporal_range("find the bug from last month", now).unwrap();
999        // 30 days before 2026-03-14 = 2026-02-12
1000        assert!(range.after.as_deref().unwrap().starts_with("2026-02-12"));
1001        assert!(range.before.is_none());
1002    }
1003
1004    #[test]
1005    fn resolve_today_range() {
1006        let now = fixed_now();
1007        let range = resolve_temporal_range("what did we do today", now).unwrap();
1008        assert_eq!(range.after.as_deref(), Some("2026-03-14 00:00:00"));
1009        assert!(range.before.is_none());
1010    }
1011
1012    #[test]
1013    fn resolve_this_morning_range() {
1014        let now = fixed_now();
1015        let range = resolve_temporal_range("what did we say this morning", now).unwrap();
1016        assert_eq!(range.after.as_deref(), Some("2026-03-14 00:00:00"));
1017        assert_eq!(range.before.as_deref(), Some("2026-03-14 12:00:00"));
1018    }
1019
1020    #[test]
1021    fn resolve_last_night_range() {
1022        let now = fixed_now();
1023        let range = resolve_temporal_range("last night's conversation", now).unwrap();
1024        assert_eq!(range.after.as_deref(), Some("2026-03-13 18:00:00"));
1025        assert_eq!(range.before.as_deref(), Some("2026-03-14 06:00:00"));
1026    }
1027
1028    #[test]
1029    fn resolve_tonight_range() {
1030        let now = fixed_now();
1031        let range = resolve_temporal_range("remind me tonight what we agreed on", now).unwrap();
1032        assert_eq!(range.after.as_deref(), Some("2026-03-14 18:00:00"));
1033        assert!(range.before.is_none());
1034    }
1035
1036    #[test]
1037    fn resolve_no_temporal_returns_none() {
1038        let now = fixed_now();
1039        assert!(resolve_temporal_range("what is the purpose of semantic memory", now).is_none());
1040    }
1041
1042    #[test]
1043    fn resolve_generic_temporal_returns_none() {
1044        // "when did", "remember when", "last time", "how long ago" — no specific range
1045        let now = fixed_now();
1046        assert!(resolve_temporal_range("when did we discuss this feature", now).is_none());
1047        assert!(resolve_temporal_range("remember when we fixed that bug", now).is_none());
1048    }
1049
1050    // ── strip_temporal_keywords tests ────────────────────────────────────────
1051
1052    #[test]
1053    fn strip_yesterday_from_query() {
1054        let cleaned = strip_temporal_keywords("what did we discuss yesterday about Rust");
1055        assert_eq!(cleaned, "what did we discuss about Rust");
1056    }
1057
1058    #[test]
1059    fn strip_last_week_from_query() {
1060        let cleaned = strip_temporal_keywords("find the config change from last week");
1061        assert_eq!(cleaned, "find the config change from");
1062    }
1063
1064    #[test]
1065    fn strip_does_not_alter_non_temporal() {
1066        let q = "what is the purpose of semantic memory";
1067        assert_eq!(strip_temporal_keywords(q), q);
1068    }
1069
1070    #[test]
1071    fn strip_ago_word_boundary() {
1072        let cleaned = strip_temporal_keywords("we fixed this a day ago in the scheduler");
1073        // "ago" removed, rest preserved
1074        assert!(!cleaned.contains("ago"));
1075        assert!(cleaned.contains("scheduler"));
1076    }
1077
1078    #[test]
1079    fn strip_does_not_touch_chicago() {
1080        let q = "meeting in Chicago about the project";
1081        assert_eq!(strip_temporal_keywords(q), q);
1082    }
1083
1084    #[test]
1085    fn strip_empty_string_returns_empty() {
1086        assert_eq!(strip_temporal_keywords(""), "");
1087    }
1088
1089    #[test]
1090    fn strip_only_temporal_keyword_returns_empty() {
1091        // When the entire query is a temporal keyword, stripping leaves an empty string.
1092        // recall_routed falls back to the original query in this case.
1093        assert_eq!(strip_temporal_keywords("yesterday"), "");
1094    }
1095
1096    #[test]
1097    fn strip_repeated_temporal_keyword_removes_all_occurrences() {
1098        // IMPL-02: all occurrences must be removed, not just the first.
1099        let cleaned = strip_temporal_keywords("yesterday I mentioned yesterday's bug");
1100        assert!(
1101            !cleaned.contains("yesterday"),
1102            "both occurrences must be removed: got '{cleaned}'"
1103        );
1104        assert!(cleaned.contains("mentioned"));
1105    }
1106
1107    // ── route_with_confidence tests ───────────────────────────────────────────
1108
1109    #[test]
1110    fn confidence_multiple_matches_is_less_than_one() {
1111        // Structural code pattern + snake_case + short query fire 3 signals →
1112        // confidence = 1.0 / 3 < 1.0
1113        let d = HeuristicRouter.route_with_confidence("zeph_memory::recall");
1114        assert!(
1115            d.confidence < 1.0,
1116            "ambiguous query should have confidence < 1.0, got {}",
1117            d.confidence
1118        );
1119        assert_eq!(d.route, MemoryRoute::Keyword);
1120    }
1121
1122    #[test]
1123    fn confidence_long_question_with_snake_fires_multiple_signals() {
1124        // Long question with snake_case fires multiple signals → confidence < 1.0
1125        let d = HeuristicRouter
1126            .route_with_confidence("what is the purpose of memory_limit in the config system");
1127        assert!(
1128            d.confidence < 1.0,
1129            "ambiguous query must have confidence < 1.0, got {}",
1130            d.confidence
1131        );
1132    }
1133
1134    #[test]
1135    fn confidence_empty_query_is_nonzero() {
1136        // Empty string: word_count=0 → short path fires (<=3 && !question) → matched=1 → confidence=1.0
1137        let d = HeuristicRouter.route_with_confidence("");
1138        assert!(
1139            d.confidence > 0.0,
1140            "empty query must match short-path signal"
1141        );
1142    }
1143
1144    #[test]
1145    fn routing_decision_route_matches_route_fn() {
1146        // route_with_confidence().route must agree with route()
1147        let queries = [
1148            "qdrant",
1149            "what is the agent loop",
1150            "context window token budget",
1151            "what did we discuss yesterday",
1152        ];
1153        for q in queries {
1154            let decision = HeuristicRouter.route_with_confidence(q);
1155            assert_eq!(
1156                decision.route,
1157                HeuristicRouter.route(q),
1158                "mismatch for query: {q}"
1159            );
1160        }
1161    }
1162}