Skip to main content

mentedb_query/
planner.rs

1//! Query planner: converts a parsed `Statement` into a `QueryPlan`.
2
3use crate::ast::*;
4use mentedb_core::edge::EdgeType;
5use mentedb_core::error::{MenteError, MenteResult};
6use mentedb_core::types::{MemoryId, Timestamp};
7use serde::{Deserialize, Serialize};
8
9/// A physical query plan describing how to execute a query.
10#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
11pub enum QueryPlan {
12    VectorSearch {
13        query: Vec<f32>,
14        k: usize,
15        filters: Vec<Filter>,
16        /// Full boolean WHERE tree, when the clause used OR/NOT/grouping. When set,
17        /// the executor post-filters candidates by this instead of `filters`.
18        #[serde(default)]
19        condition: Option<Condition>,
20        /// ORDER BY clause; the executor sorts the result set by it before LIMIT.
21        #[serde(default)]
22        order_by: Option<OrderBy>,
23    },
24    TagScan {
25        tags: Vec<String>,
26        filters: Vec<Filter>,
27        limit: Option<usize>,
28        /// Full boolean WHERE tree, when the clause used OR/NOT/grouping. When set,
29        /// the executor post-filters candidates by this instead of `filters`.
30        #[serde(default)]
31        condition: Option<Condition>,
32        /// ORDER BY clause; the executor sorts the result set by it before LIMIT.
33        #[serde(default)]
34        order_by: Option<OrderBy>,
35    },
36    /// A scan narrowed by the temporal (creation time) index. `start..=end` is
37    /// the creation range the index admits; `filters` holds every WHERE filter,
38    /// the range ones included, and the executor re-checks them all on each
39    /// loaded memory, so strict bounds (`>`, `<`) stay exact.
40    TemporalScan {
41        start: Timestamp,
42        end: Timestamp,
43        filters: Vec<Filter>,
44        /// LIMIT; the executor falls back to its configured default when unset.
45        #[serde(default)]
46        limit: Option<usize>,
47        /// ORDER BY clause; the executor sorts the result set by it before LIMIT.
48        #[serde(default)]
49        order_by: Option<OrderBy>,
50    },
51    GraphTraversal {
52        start: MemoryId,
53        depth: usize,
54        edge_types: Vec<EdgeType>,
55    },
56    PointLookup {
57        id: MemoryId,
58    },
59    EdgeInsert {
60        source: MemoryId,
61        target: MemoryId,
62        edge_type: EdgeType,
63        weight: f32,
64    },
65    Delete {
66        id: MemoryId,
67    },
68    Consolidate {
69        filters: Vec<Filter>,
70    },
71}
72
73const DEFAULT_LIMIT: usize = 20;
74
75/// Produce an execution plan from a parsed statement.
76pub fn plan(statement: &Statement) -> MenteResult<QueryPlan> {
77    match statement {
78        Statement::Recall(recall) => {
79            validate_filters(&recall.filters)?;
80            if let Some(cond) = &recall.condition {
81                validate_condition(cond)?;
82            }
83        }
84        Statement::Consolidate(cons) => validate_filters(&cons.filters)?,
85        _ => {}
86    }
87    match statement {
88        Statement::Recall(recall) => plan_recall(recall),
89        Statement::Relate(relate) => plan_relate(relate),
90        Statement::Forget(forget) => Ok(QueryPlan::Delete { id: forget.target }),
91        Statement::Consolidate(cons) => Ok(QueryPlan::Consolidate {
92            filters: cons.filters.clone(),
93        }),
94        Statement::Traverse(trav) => plan_traverse(trav),
95    }
96}
97
98fn plan_recall(recall: &RecallStatement) -> MenteResult<QueryPlan> {
99    let limit = recall.limit.unwrap_or(DEFAULT_LIMIT);
100
101    // If there's a NEAR clause or a SimilarTo filter, use vector search
102    if let Some(ref vec) = recall.near {
103        return Ok(QueryPlan::VectorSearch {
104            query: vec.clone(),
105            k: limit,
106            filters: recall.filters.clone(),
107            condition: recall.condition.clone(),
108            order_by: recall.order_by.clone(),
109        });
110    }
111
112    // OR/NOT/grouped WHERE: none of the leaf-based index optimizations below are
113    // safe (they assume every filter must hold), so full-scan and let the executor
114    // evaluate the boolean tree. Correct, if less selective, for these rarer queries.
115    if recall.condition.is_some() {
116        return Ok(QueryPlan::TagScan {
117            tags: Vec::new(),
118            filters: Vec::new(),
119            limit: Some(limit),
120            condition: recall.condition.clone(),
121            order_by: recall.order_by.clone(),
122        });
123    }
124
125    // Check for SimilarTo operator in filters — implies vector search via text embedding
126    if let Some(sim_filter) = recall.filters.iter().find(|f| f.op == Operator::SimilarTo) {
127        if let Value::Text(ref _text) = sim_filter.value {
128            // The text is embedded at execution time (no embedder here), so keep
129            // ALL filters, including the SimilarTo one: the executor reads its
130            // text to build the query vector and applies the rest (type, tag).
131            return Ok(QueryPlan::VectorSearch {
132                query: Vec::new(), // placeholder — executor embeds the SimilarTo text
133                k: limit,
134                filters: recall.filters.clone(),
135                condition: None,
136                order_by: recall.order_by.clone(),
137            });
138        }
139        // SimilarTo with non-text value doesn't make sense
140        return Err(MenteError::Query(
141            "~> operator requires a text value on the right-hand side".into(),
142        ));
143    }
144
145    // If only tag filters, use TagScan
146    let tag_filters: Vec<&Filter> = recall
147        .filters
148        .iter()
149        .filter(|f| f.field == Field::Tag)
150        .collect();
151    if !tag_filters.is_empty() && recall.filters.iter().all(|f| f.field == Field::Tag) {
152        let tags: Vec<String> = tag_filters
153            .iter()
154            .filter_map(|f| match &f.value {
155                Value::Text(t) => Some(t.clone()),
156                _ => None,
157            })
158            .collect();
159        return Ok(QueryPlan::TagScan {
160            tags,
161            filters: Vec::new(),
162            limit: Some(limit),
163            condition: None,
164            order_by: recall.order_by.clone(),
165        });
166    }
167
168    // A numeric range on `created` narrows through the temporal index, which
169    // is keyed by creation time. Ranges on `accessed` (or non-numeric bounds)
170    // cannot use it and fall through to the filtered scan below.
171    let mut start: Option<Timestamp> = None;
172    let mut end: Option<Timestamp> = None;
173    for f in &recall.filters {
174        if f.field != Field::Created {
175            continue;
176        }
177        let Some(ts) = timestamp_value(&f.value) else {
178            continue;
179        };
180        match f.op {
181            Operator::Gt | Operator::Gte => start = Some(start.map_or(ts, |s| s.max(ts))),
182            Operator::Lt | Operator::Lte => end = Some(end.map_or(ts, |e| e.min(ts))),
183            _ => {}
184        }
185    }
186    if start.is_some() || end.is_some() {
187        return Ok(QueryPlan::TemporalScan {
188            start: start.unwrap_or(0),
189            end: end.unwrap_or(Timestamp::MAX),
190            // Every filter, the range ones too: the index bound is inclusive,
191            // so the executor's re-check is what makes `>` and `<` strict.
192            filters: recall.filters.clone(),
193            limit: Some(limit),
194            order_by: recall.order_by.clone(),
195        });
196    }
197
198    // Fallback: tag scan with no tags (full scan with filters)
199    Ok(QueryPlan::TagScan {
200        tags: Vec::new(),
201        filters: recall.filters.clone(),
202        limit: Some(limit),
203        condition: None,
204        order_by: recall.order_by.clone(),
205    })
206}
207
208/// A numeric literal as a timestamp, clamping negatives to zero.
209fn timestamp_value(v: &Value) -> Option<Timestamp> {
210    match v {
211        Value::Integer(i) => Some((*i).max(0) as Timestamp),
212        Value::Number(n) if n.is_finite() => Some(n.max(0.0) as Timestamp),
213        _ => None,
214    }
215}
216
217/// Reject WHERE leaves the executor cannot evaluate, so a malformed clause is a
218/// query error instead of silently matching every memory.
219pub fn validate_filters(filters: &[Filter]) -> MenteResult<()> {
220    filters.iter().try_for_each(validate_filter)
221}
222
223/// [`validate_filters`] over every leaf of a boolean WHERE tree.
224pub fn validate_condition(c: &Condition) -> MenteResult<()> {
225    match c {
226        Condition::Leaf(f) => validate_filter(f),
227        Condition::And(children) | Condition::Or(children) => {
228            children.iter().try_for_each(validate_condition)
229        }
230        Condition::Not(inner) => validate_condition(inner),
231    }
232}
233
234/// Check one filter on the owner and score fields:
235///
236/// - `agent` and `space` compare an id (a UUID, quoted or bare) with `=`,
237///   `!=`, or `IN [...]`.
238/// - `salience` and `confidence` compare a number with `=`, `!=`, `<`, `<=`,
239///   `>`, `>=`, or `IN [...]`. At execution `salience` is the memory's current
240///   decayed salience, not the stored base; `confidence` is the stored value.
241///
242/// Other fields keep their existing handling.
243pub fn validate_filter(f: &Filter) -> MenteResult<()> {
244    let (name, id_field) = match f.field {
245        Field::Agent => ("agent", true),
246        Field::Space => ("space", true),
247        Field::Salience => ("salience", false),
248        Field::Confidence => ("confidence", false),
249        _ => return Ok(()),
250    };
251    let op_ok = if id_field {
252        matches!(f.op, Operator::Eq | Operator::Neq | Operator::In)
253    } else {
254        matches!(
255            f.op,
256            Operator::Eq
257                | Operator::Neq
258                | Operator::Lt
259                | Operator::Lte
260                | Operator::Gt
261                | Operator::Gte
262                | Operator::In
263        )
264    };
265    if !op_ok {
266        let allowed = if id_field {
267            "=, != or IN"
268        } else {
269            "=, !=, <, <=, >, >= or IN"
270        };
271        return Err(MenteError::Query(format!(
272            "{name} supports only {allowed}, found {:?}",
273            f.op
274        )));
275    }
276    let value_ok = |v: &Value| {
277        if id_field {
278            matches!(v, Value::Uuid(_))
279        } else {
280            matches!(v, Value::Number(_) | Value::Integer(_))
281        }
282    };
283    let ok = match &f.value {
284        Value::List(items) => f.op == Operator::In && items.iter().all(value_ok),
285        v => f.op != Operator::In && value_ok(v),
286    };
287    if ok {
288        Ok(())
289    } else {
290        let expected = if id_field { "a UUID" } else { "a number" };
291        Err(MenteError::Query(format!(
292            "{name} must be compared with {expected}, found {:?}",
293            f.value
294        )))
295    }
296}
297
298fn plan_relate(relate: &RelateStatement) -> MenteResult<QueryPlan> {
299    Ok(QueryPlan::EdgeInsert {
300        source: relate.source,
301        target: relate.target,
302        edge_type: relate.edge_type,
303        weight: relate.weight.unwrap_or(1.0),
304    })
305}
306
307fn plan_traverse(trav: &TraverseStatement) -> MenteResult<QueryPlan> {
308    Ok(QueryPlan::GraphTraversal {
309        start: trav.start,
310        depth: trav.depth,
311        edge_types: trav.edge_filter.clone().unwrap_or_default(),
312    })
313}
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318    use crate::lexer::tokenize;
319    use crate::parser::Parser;
320
321    fn plan_mql(input: &str) -> QueryPlan {
322        let tokens = tokenize(input).unwrap();
323        let stmt = Parser::parse(&tokens).unwrap();
324        plan(&stmt).unwrap()
325    }
326
327    #[test]
328    fn test_near_produces_vector_search() {
329        let qp = plan_mql("RECALL memories NEAR [0.1, 0.2, 0.3] LIMIT 5");
330        match qp {
331            QueryPlan::VectorSearch { query, k, .. } => {
332                assert_eq!(query, vec![0.1, 0.2, 0.3]);
333                assert_eq!(k, 5);
334            }
335            _ => panic!("expected VectorSearch, got {:?}", qp),
336        }
337    }
338
339    #[test]
340    fn test_similar_to_produces_vector_search() {
341        let qp = plan_mql(r#"RECALL memories WHERE content ~> "database migration" LIMIT 10"#);
342        match qp {
343            QueryPlan::VectorSearch { k, .. } => {
344                assert_eq!(k, 10);
345            }
346            _ => panic!("expected VectorSearch, got {:?}", qp),
347        }
348    }
349
350    #[test]
351    fn test_tag_filter_produces_tag_scan() {
352        let qp = plan_mql(r#"RECALL memories WHERE tag = "backend" LIMIT 5"#);
353        match qp {
354            QueryPlan::TagScan { tags, limit, .. } => {
355                assert_eq!(tags, vec!["backend".to_string()]);
356                assert_eq!(limit, Some(5));
357            }
358            _ => panic!("expected TagScan, got {:?}", qp),
359        }
360    }
361
362    #[test]
363    fn test_created_range_produces_temporal_scan_with_every_filter() {
364        let qp = plan_mql(
365            "RECALL memories WHERE created > 100 AND created >= 50 AND created < 900 AND type = episodic ORDER BY salience DESC LIMIT 10",
366        );
367        match qp {
368            QueryPlan::TemporalScan {
369                start,
370                end,
371                filters,
372                limit,
373                order_by,
374            } => {
375                assert_eq!((start, end), (100, 900), "tightest bounds");
376                assert_eq!(filters.len(), 4, "range filters kept for the re-check");
377                assert!(filters.iter().any(|f| f.field == Field::Type));
378                assert_eq!(limit, Some(10));
379                assert!(order_by.is_some_and(|ob| ob.descending));
380            }
381            other => panic!("expected TemporalScan, got {:?}", other),
382        }
383        match plan_mql("RECALL memories WHERE created > 5") {
384            QueryPlan::TemporalScan { limit, .. } => assert_eq!(limit, Some(DEFAULT_LIMIT)),
385            other => panic!("expected TemporalScan, got {:?}", other),
386        }
387    }
388
389    #[test]
390    fn test_accessed_range_is_not_a_creation_index_scan() {
391        match plan_mql("RECALL memories WHERE accessed > 100 LIMIT 5") {
392            QueryPlan::TagScan { filters, .. } => assert_eq!(filters.len(), 1),
393            other => panic!("expected TagScan, got {:?}", other),
394        }
395    }
396
397    #[test]
398    fn test_owner_and_score_filters_are_validated() {
399        let id = "550e8400-e29b-41d4-a716-446655440000";
400        for ok in [
401            format!("RECALL memories WHERE agent = {id}"),
402            format!("RECALL memories WHERE agent = \"{id}\""),
403            format!("RECALL memories WHERE space != {id}"),
404            format!("RECALL memories WHERE agent IN [{id}, {id}]"),
405            "RECALL memories WHERE salience > 0.5".to_string(),
406            "RECALL memories WHERE confidence <= 1".to_string(),
407            "RECALL memories WHERE salience IN [0.1, 0.2]".to_string(),
408        ] {
409            let tokens = tokenize(&ok).unwrap();
410            let stmt = Parser::parse(&tokens).unwrap();
411            assert!(plan(&stmt).is_ok(), "{ok}");
412        }
413        for bad in [
414            "RECALL memories WHERE agent = \"alice\"".to_string(),
415            format!("RECALL memories WHERE agent < {id}"),
416            "RECALL memories WHERE space = 1".to_string(),
417            "RECALL memories WHERE salience = \"high\"".to_string(),
418            "RECALL memories WHERE confidence CONTAINS 1".to_string(),
419            "RECALL memories WHERE confidence IN [1, \"x\"]".to_string(),
420            "RECALL memories WHERE NOT (type = episodic OR agent = \"bob\")".to_string(),
421            "CONSOLIDATE WHERE salience < \"low\"".to_string(),
422        ] {
423            let tokens = tokenize(&bad).unwrap();
424            let stmt = Parser::parse(&tokens).unwrap();
425            assert!(
426                matches!(plan(&stmt), Err(MenteError::Query(_))),
427                "{bad}: expected a query error"
428            );
429        }
430    }
431
432    #[test]
433    fn test_forget_produces_delete() {
434        let qp = plan_mql("FORGET 550e8400-e29b-41d4-a716-446655440000");
435        match qp {
436            QueryPlan::Delete { id } => {
437                assert_eq!(
438                    id,
439                    "550e8400-e29b-41d4-a716-446655440000"
440                        .parse::<MemoryId>()
441                        .unwrap()
442                );
443            }
444            _ => panic!("expected Delete, got {:?}", qp),
445        }
446    }
447
448    #[test]
449    fn test_traverse_produces_graph_traversal() {
450        let qp = plan_mql(
451            "TRAVERSE 550e8400-e29b-41d4-a716-446655440000 DEPTH 3 WHERE edge_type = caused",
452        );
453        match qp {
454            QueryPlan::GraphTraversal {
455                depth, edge_types, ..
456            } => {
457                assert_eq!(depth, 3);
458                assert_eq!(edge_types, vec![EdgeType::Caused]);
459            }
460            _ => panic!("expected GraphTraversal, got {:?}", qp),
461        }
462    }
463
464    #[test]
465    fn test_relate_produces_edge_insert() {
466        let qp = plan_mql(
467            "RELATE 550e8400-e29b-41d4-a716-446655440000 -> 660e8400-e29b-41d4-a716-446655440000 AS caused WITH weight = 0.8",
468        );
469        match qp {
470            QueryPlan::EdgeInsert {
471                edge_type, weight, ..
472            } => {
473                assert_eq!(edge_type, EdgeType::Caused);
474                assert!((weight - 0.8).abs() < f32::EPSILON);
475            }
476            _ => panic!("expected EdgeInsert, got {:?}", qp),
477        }
478    }
479}