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