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/// Classification of which memory backend(s) to query.
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6pub enum MemoryRoute {
7    /// Full-text search only (`SQLite` FTS5). Fast, good for keyword/exact queries.
8    Keyword,
9    /// Vector search only (Qdrant). Good for semantic/conceptual queries.
10    Semantic,
11    /// Both backends, results merged by reciprocal rank fusion.
12    Hybrid,
13    /// Graph-based retrieval via BFS traversal. Good for relationship queries.
14    /// When the `graph-memory` feature is disabled, callers treat this as `Hybrid`.
15    Graph,
16}
17
18/// Decides which memory backend(s) to query for a given input.
19pub trait MemoryRouter: Send + Sync {
20    /// Route a query to the appropriate backend(s).
21    fn route(&self, query: &str) -> MemoryRoute;
22}
23
24/// Heuristic-based memory router.
25///
26/// Decision logic:
27/// - If query contains code-like patterns (paths, `::`, pure `snake_case` identifiers)
28///   AND does NOT start with a question word → Keyword
29/// - If query is a natural language question or long → Semantic
30/// - Default → Hybrid
31pub struct HeuristicRouter;
32
33const QUESTION_WORDS: &[&str] = &[
34    "what", "how", "why", "when", "where", "who", "which", "explain", "describe",
35];
36
37/// Simple substrings that signal a relationship query (checked via `str::contains`).
38/// Only used when the `graph-memory` feature is enabled.
39const RELATIONSHIP_PATTERNS: &[&str] = &[
40    "related to",
41    "relates to",
42    "connection between",
43    "relationship",
44    "opinion on",
45    "thinks about",
46    "preference for",
47    "history of",
48    "know about",
49];
50
51fn starts_with_question(words: &[&str]) -> bool {
52    words
53        .first()
54        .is_some_and(|w| QUESTION_WORDS.iter().any(|qw| w.eq_ignore_ascii_case(qw)))
55}
56
57/// Returns true if `word` is a pure `snake_case` identifier (all ASCII, lowercase letters,
58/// digits and underscores, contains at least one underscore, not purely numeric).
59fn is_pure_snake_case(word: &str) -> bool {
60    if word.is_empty() {
61        return false;
62    }
63    let has_underscore = word.contains('_');
64    if !has_underscore {
65        return false;
66    }
67    word.chars()
68        .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
69        && !word.chars().all(|c| c.is_ascii_digit() || c == '_')
70}
71
72impl MemoryRouter for HeuristicRouter {
73    fn route(&self, query: &str) -> MemoryRoute {
74        let words: Vec<&str> = query.split_whitespace().collect();
75        let word_count = words.len();
76
77        // Relationship queries go to graph retrieval (feature-gated at call site)
78        {
79            let lower = query.to_ascii_lowercase();
80            let has_relationship = RELATIONSHIP_PATTERNS.iter().any(|p| lower.contains(p));
81            if has_relationship {
82                return MemoryRoute::Graph;
83            }
84        }
85
86        // Code-like patterns that unambiguously indicate keyword search:
87        // file paths (contain '/'), Rust paths (contain '::')
88        let has_structural_code_pattern = query.contains('/') || query.contains("::");
89
90        // Pure snake_case identifiers (e.g. "memory_limit", "error_handling")
91        // but only if the query does NOT start with a question word
92        let has_snake_case = words.iter().any(|w| is_pure_snake_case(w));
93        let question = starts_with_question(&words);
94
95        if has_structural_code_pattern && !question {
96            return MemoryRoute::Keyword;
97        }
98
99        if has_snake_case && !question {
100            return MemoryRoute::Keyword;
101        }
102
103        // Short queries without question words → keyword
104        if word_count <= 3 && !question {
105            return MemoryRoute::Keyword;
106        }
107
108        // Natural language questions or long queries → semantic
109        if question || word_count >= 6 {
110            return MemoryRoute::Semantic;
111        }
112
113        // Default
114        MemoryRoute::Hybrid
115    }
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121
122    fn route(q: &str) -> MemoryRoute {
123        HeuristicRouter.route(q)
124    }
125
126    #[test]
127    fn rust_path_routes_keyword() {
128        assert_eq!(route("zeph_memory::recall"), MemoryRoute::Keyword);
129    }
130
131    #[test]
132    fn file_path_routes_keyword() {
133        assert_eq!(
134            route("crates/zeph-core/src/agent/mod.rs"),
135            MemoryRoute::Keyword
136        );
137    }
138
139    #[test]
140    fn pure_snake_case_routes_keyword() {
141        assert_eq!(route("memory_limit"), MemoryRoute::Keyword);
142        assert_eq!(route("error_handling"), MemoryRoute::Keyword);
143    }
144
145    #[test]
146    fn question_with_snake_case_routes_semantic() {
147        // "what is the memory_limit setting" — question word overrides snake_case heuristic
148        assert_eq!(
149            route("what is the memory_limit setting"),
150            MemoryRoute::Semantic
151        );
152        assert_eq!(route("how does error_handling work"), MemoryRoute::Semantic);
153    }
154
155    #[test]
156    fn short_query_routes_keyword() {
157        assert_eq!(route("context compaction"), MemoryRoute::Keyword);
158        assert_eq!(route("qdrant"), MemoryRoute::Keyword);
159    }
160
161    #[test]
162    fn question_routes_semantic() {
163        assert_eq!(
164            route("what is the purpose of semantic memory"),
165            MemoryRoute::Semantic
166        );
167        assert_eq!(route("how does the agent loop work"), MemoryRoute::Semantic);
168        assert_eq!(route("why does compaction fail"), MemoryRoute::Semantic);
169        assert_eq!(route("explain context compression"), MemoryRoute::Semantic);
170    }
171
172    #[test]
173    fn long_natural_query_routes_semantic() {
174        assert_eq!(
175            route("the agent keeps running out of context during long conversations"),
176            MemoryRoute::Semantic
177        );
178    }
179
180    #[test]
181    fn medium_non_question_routes_hybrid() {
182        // 4-5 words, no question word, no code pattern
183        assert_eq!(route("context window token budget"), MemoryRoute::Hybrid);
184    }
185
186    #[test]
187    fn empty_query_routes_keyword() {
188        // 0 words, no question → keyword (short path)
189        assert_eq!(route(""), MemoryRoute::Keyword);
190    }
191
192    #[test]
193    fn question_word_only_routes_semantic() {
194        // single question word → word_count = 1, but starts_with_question = true
195        // short query with question: the question check happens first in semantic branch
196        // Actually with word_count=1 and question=true: short path `<= 3 && !question` is false,
197        // then `question || word_count >= 6` is true → Semantic
198        assert_eq!(route("what"), MemoryRoute::Semantic);
199    }
200
201    #[test]
202    fn camel_case_does_not_route_keyword_without_pattern() {
203        // CamelCase words without :: or / — 4-word query without question word → Hybrid
204        // (4 words: no question, no snake_case, no structural code pattern → Hybrid)
205        assert_eq!(
206            route("SemanticMemory configuration and options"),
207            MemoryRoute::Hybrid
208        );
209    }
210
211    #[test]
212    fn relationship_query_routes_graph() {
213        assert_eq!(
214            route("what is user's opinion on neovim"),
215            MemoryRoute::Graph
216        );
217        assert_eq!(
218            route("show the relationship between Alice and Bob"),
219            MemoryRoute::Graph
220        );
221    }
222
223    #[test]
224    fn relationship_query_related_to_routes_graph() {
225        assert_eq!(
226            route("how is Rust related to this project"),
227            MemoryRoute::Graph
228        );
229        assert_eq!(
230            route("how does this relates to the config"),
231            MemoryRoute::Graph
232        );
233    }
234
235    #[test]
236    fn relationship_know_about_routes_graph() {
237        assert_eq!(route("what do I know about neovim"), MemoryRoute::Graph);
238    }
239
240    #[test]
241    fn translate_does_not_route_graph() {
242        // "translate" contains "relate" substring but is not in RELATIONSHIP_PATTERNS
243        // (we removed bare "relate", keeping only "related to" and "relates to")
244        assert_ne!(route("translate this code to Python"), MemoryRoute::Graph);
245    }
246
247    #[test]
248    fn non_relationship_stays_semantic() {
249        assert_eq!(
250            route("find similar code patterns in the codebase"),
251            MemoryRoute::Semantic
252        );
253    }
254
255    #[test]
256    fn short_keyword_unchanged() {
257        assert_eq!(route("qdrant"), MemoryRoute::Keyword);
258    }
259}