Skip to main content

microscope_memory/
query.rs

1//! Microscope Query Language (MQL)
2//!
3//! Syntax:
4//!   "keyword"                     → text search
5//!   layer:long_term "keyword"     → filter by layer
6//!   depth:3 "keyword"             → filter by depth
7//!   depth:2..5 "keyword"          → depth range
8//!   near:0.2,0.3,0.1 "keyword"   → spatial filter (within radius 0.1 of coords)
9//!   "foo" AND "bar"               → both keywords must match
10//!   "foo" OR "bar"                → either keyword matches
11//!   limit:20                      → override default k
12//!
13//! Filters compose: `layer:long_term depth:3..5 "memory" AND "high_level"`
14
15use crate::{AppendEntry, MicroscopeReader, LAYER_NAMES};
16
17#[derive(Debug, Clone)]
18pub struct Query {
19    pub keywords: Vec<String>,
20    pub op: BoolOp,
21    pub layer_filter: Option<u8>,
22    pub depth_filter: Option<(u8, u8)>, // (min, max) inclusive
23    pub spatial_filter: Option<(f32, f32, f32, f32)>, // x, y, z, radius
24    pub limit: usize,
25}
26
27#[derive(Debug, Clone, PartialEq)]
28pub enum BoolOp {
29    And,
30    Or,
31}
32
33impl Default for Query {
34    fn default() -> Self {
35        Self {
36            keywords: Vec::new(),
37            op: BoolOp::And,
38            layer_filter: None,
39            depth_filter: None,
40            spatial_filter: None,
41            limit: 10,
42        }
43    }
44}
45
46/// Parse a query string into a structured Query.
47pub fn parse(input: &str) -> Query {
48    let mut q = Query::default();
49    let mut remaining = input.trim();
50
51    while !remaining.is_empty() {
52        remaining = remaining.trim_start();
53        if remaining.is_empty() {
54            break;
55        }
56
57        // layer:NAME
58        if let Some(rest) = remaining.strip_prefix("layer:") {
59            let (val, rest2) = take_word(rest);
60            q.layer_filter = Some(crate::layer_to_id(&val));
61            remaining = rest2;
62            continue;
63        }
64
65        // depth:N or depth:N..M
66        if let Some(rest) = remaining.strip_prefix("depth:") {
67            let (val, rest2) = take_word(rest);
68            if let Some((a, b)) = val.split_once("..") {
69                let lo = a.parse::<u8>().unwrap_or(0);
70                let hi = b.parse::<u8>().unwrap_or(8);
71                q.depth_filter = Some((lo, hi));
72            } else if let Ok(d) = val.parse::<u8>() {
73                q.depth_filter = Some((d, d));
74            }
75            remaining = rest2;
76            continue;
77        }
78
79        // near:X,Y,Z or near:X,Y,Z,R
80        if let Some(rest) = remaining.strip_prefix("near:") {
81            let (val, rest2) = take_word(rest);
82            let parts: Vec<f32> = val.split(',').filter_map(|s| s.parse().ok()).collect();
83            if parts.len() >= 3 {
84                let r = if parts.len() >= 4 { parts[3] } else { 0.1 };
85                q.spatial_filter = Some((parts[0], parts[1], parts[2], r));
86            }
87            remaining = rest2;
88            continue;
89        }
90
91        // limit:N
92        if let Some(rest) = remaining.strip_prefix("limit:") {
93            let (val, rest2) = take_word(rest);
94            if let Ok(n) = val.parse::<usize>() {
95                q.limit = n;
96            }
97            remaining = rest2;
98            continue;
99        }
100
101        // AND / OR operators
102        if let Some(rest) = remaining.strip_prefix("AND") {
103            if rest.is_empty() || rest.starts_with(' ') {
104                q.op = BoolOp::And;
105                remaining = rest;
106                continue;
107            }
108        }
109        if let Some(rest) = remaining.strip_prefix("OR") {
110            if rest.is_empty() || rest.starts_with(' ') {
111                q.op = BoolOp::Or;
112                remaining = rest;
113                continue;
114            }
115        }
116
117        // Quoted keyword: "..."
118        if remaining.starts_with('"') {
119            if let Some(end) = remaining[1..].find('"') {
120                q.keywords.push(remaining[1..1 + end].to_lowercase());
121                remaining = &remaining[2 + end..];
122                continue;
123            }
124        }
125
126        // Bare word as keyword
127        let (word, rest2) = take_word(remaining);
128        if !word.is_empty() {
129            q.keywords.push(word.to_lowercase());
130        }
131        remaining = rest2;
132    }
133
134    q
135}
136
137fn take_word(s: &str) -> (String, &str) {
138    let s = s.trim_start();
139    let end = s.find(char::is_whitespace).unwrap_or(s.len());
140    (s[..end].to_string(), &s[end..])
141}
142
143/// Result from query execution.
144#[derive(Debug)]
145pub struct QueryResult {
146    pub score: f32,
147    pub block_idx: usize,
148    pub is_main: bool, // true = main index, false = append log
149}
150
151/// Execute a parsed query against the reader and append log.
152pub fn execute(q: &Query, reader: &MicroscopeReader, appended: &[AppendEntry]) -> Vec<QueryResult> {
153    let mut results = Vec::new();
154
155    // Search main index
156    for i in 0..reader.block_count {
157        let h = reader.header(i);
158
159        // Layer filter
160        if let Some(lid) = q.layer_filter {
161            if h.layer_id != lid {
162                continue;
163            }
164        }
165
166        // Depth filter
167        if let Some((lo, hi)) = q.depth_filter {
168            if h.depth < lo || h.depth > hi {
169                continue;
170            }
171        }
172
173        // Spatial filter
174        if let Some((sx, sy, sz, sr)) = q.spatial_filter {
175            let dx = h.x - sx;
176            let dy = h.y - sy;
177            let dz = h.z - sz;
178            if dx * dx + dy * dy + dz * dz > sr * sr {
179                continue;
180            }
181        }
182
183        // Keyword match
184        let text = reader.text(i).to_lowercase();
185        let score = keyword_score(&text, &q.keywords, &q.op);
186        if score > 0.0 {
187            results.push(QueryResult {
188                score,
189                block_idx: i,
190                is_main: true,
191            });
192        }
193    }
194
195    // Search append log
196    for (ai, entry) in appended.iter().enumerate() {
197        if let Some(lid) = q.layer_filter {
198            if entry.layer_id != lid {
199                continue;
200            }
201        }
202        if let Some((lo, hi)) = q.depth_filter {
203            if entry.depth < lo || entry.depth > hi {
204                continue;
205            }
206        }
207        if let Some((sx, sy, sz, sr)) = q.spatial_filter {
208            let dx = entry.x - sx;
209            let dy = entry.y - sy;
210            let dz = entry.z - sz;
211            if dx * dx + dy * dy + dz * dz > sr * sr {
212                continue;
213            }
214        }
215
216        let text = entry.text.to_lowercase();
217        let score = keyword_score(&text, &q.keywords, &q.op);
218        if score > 0.0 {
219            results.push(QueryResult {
220                score,
221                block_idx: ai + 1_000_000,
222                is_main: false,
223            });
224        }
225    }
226
227    // Sort by score descending
228    results.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap());
229    results.truncate(q.limit);
230    results
231}
232
233fn keyword_score(text: &str, keywords: &[String], op: &BoolOp) -> f32 {
234    if keywords.is_empty() {
235        return 1.0;
236    }
237
238    let hits: Vec<bool> = keywords
239        .iter()
240        .map(|kw| text.contains(kw.as_str()))
241        .collect();
242    let hit_count = hits.iter().filter(|&&h| h).count();
243
244    match op {
245        BoolOp::And => {
246            if hit_count == keywords.len() {
247                hit_count as f32
248            } else {
249                0.0
250            }
251        }
252        BoolOp::Or => hit_count as f32,
253    }
254}
255
256/// Format a layer ID to its name.
257#[allow(dead_code)]
258pub fn layer_name(id: u8) -> &'static str {
259    LAYER_NAMES.get(id as usize).unwrap_or(&"?")
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265
266    #[test]
267    fn test_parse_simple() {
268        let q = parse("hello");
269        assert_eq!(q.keywords, vec!["hello"]);
270        assert_eq!(q.op, BoolOp::And);
271    }
272
273    #[test]
274    fn test_parse_quoted() {
275        let q = parse("\"hello world\"");
276        assert_eq!(q.keywords, vec!["hello world"]);
277    }
278
279    #[test]
280    fn test_parse_filters() {
281        let q = parse("layer:long_term depth:2..5 \"memory\"");
282        assert_eq!(q.layer_filter, Some(1)); // long_term = index 1
283        assert_eq!(q.depth_filter, Some((2, 5)));
284        assert_eq!(q.keywords, vec!["memory"]);
285    }
286
287    #[test]
288    fn test_parse_bool_op() {
289        let q = parse("\"foo\" OR \"bar\"");
290        assert_eq!(q.op, BoolOp::Or);
291        assert_eq!(q.keywords, vec!["foo", "bar"]);
292    }
293
294    #[test]
295    fn test_parse_limit() {
296        let q = parse("limit:20 hello");
297        assert_eq!(q.limit, 20);
298    }
299
300    #[test]
301    fn test_parse_spatial() {
302        let q = parse("near:0.2,0.3,0.1,0.05 test");
303        let (x, y, z, r) = q.spatial_filter.unwrap();
304        assert!((x - 0.2).abs() < 0.001);
305        assert!((y - 0.3).abs() < 0.001);
306        assert!((z - 0.1).abs() < 0.001);
307        assert!((r - 0.05).abs() < 0.001);
308    }
309
310    #[test]
311    fn test_keyword_score_and() {
312        assert_eq!(
313            keyword_score(
314                "hello world",
315                &["hello".into(), "world".into()],
316                &BoolOp::And
317            ),
318            2.0
319        );
320        assert_eq!(
321            keyword_score("hello", &["hello".into(), "world".into()], &BoolOp::And),
322            0.0
323        );
324    }
325
326    #[test]
327    fn test_keyword_score_or() {
328        assert_eq!(
329            keyword_score("hello", &["hello".into(), "world".into()], &BoolOp::Or),
330            1.0
331        );
332        assert_eq!(
333            keyword_score("nope", &["hello".into(), "world".into()], &BoolOp::Or),
334            0.0
335        );
336    }
337}