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 [...]`.
240///
241/// Other fields keep their existing handling.
242pub fn validate_filter(f: &Filter) -> MenteResult<()> {
243    let (name, id_field) = match f.field {
244        Field::Agent => ("agent", true),
245        Field::Space => ("space", true),
246        Field::Salience => ("salience", false),
247        Field::Confidence => ("confidence", false),
248        _ => return Ok(()),
249    };
250    let op_ok = if id_field {
251        matches!(f.op, Operator::Eq | Operator::Neq | Operator::In)
252    } else {
253        matches!(
254            f.op,
255            Operator::Eq
256                | Operator::Neq
257                | Operator::Lt
258                | Operator::Lte
259                | Operator::Gt
260                | Operator::Gte
261                | Operator::In
262        )
263    };
264    if !op_ok {
265        let allowed = if id_field {
266            "=, != or IN"
267        } else {
268            "=, !=, <, <=, >, >= or IN"
269        };
270        return Err(MenteError::Query(format!(
271            "{name} supports only {allowed}, found {:?}",
272            f.op
273        )));
274    }
275    let value_ok = |v: &Value| {
276        if id_field {
277            matches!(v, Value::Uuid(_))
278        } else {
279            matches!(v, Value::Number(_) | Value::Integer(_))
280        }
281    };
282    let ok = match &f.value {
283        Value::List(items) => f.op == Operator::In && items.iter().all(value_ok),
284        v => f.op != Operator::In && value_ok(v),
285    };
286    if ok {
287        Ok(())
288    } else {
289        let expected = if id_field { "a UUID" } else { "a number" };
290        Err(MenteError::Query(format!(
291            "{name} must be compared with {expected}, found {:?}",
292            f.value
293        )))
294    }
295}
296
297fn plan_relate(relate: &RelateStatement) -> MenteResult<QueryPlan> {
298    Ok(QueryPlan::EdgeInsert {
299        source: relate.source,
300        target: relate.target,
301        edge_type: relate.edge_type,
302        weight: relate.weight.unwrap_or(1.0),
303    })
304}
305
306fn plan_traverse(trav: &TraverseStatement) -> MenteResult<QueryPlan> {
307    Ok(QueryPlan::GraphTraversal {
308        start: trav.start,
309        depth: trav.depth,
310        edge_types: trav.edge_filter.clone().unwrap_or_default(),
311    })
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317    use crate::lexer::tokenize;
318    use crate::parser::Parser;
319
320    fn plan_mql(input: &str) -> QueryPlan {
321        let tokens = tokenize(input).unwrap();
322        let stmt = Parser::parse(&tokens).unwrap();
323        plan(&stmt).unwrap()
324    }
325
326    #[test]
327    fn test_near_produces_vector_search() {
328        let qp = plan_mql("RECALL memories NEAR [0.1, 0.2, 0.3] LIMIT 5");
329        match qp {
330            QueryPlan::VectorSearch { query, k, .. } => {
331                assert_eq!(query, vec![0.1, 0.2, 0.3]);
332                assert_eq!(k, 5);
333            }
334            _ => panic!("expected VectorSearch, got {:?}", qp),
335        }
336    }
337
338    #[test]
339    fn test_similar_to_produces_vector_search() {
340        let qp = plan_mql(r#"RECALL memories WHERE content ~> "database migration" LIMIT 10"#);
341        match qp {
342            QueryPlan::VectorSearch { k, .. } => {
343                assert_eq!(k, 10);
344            }
345            _ => panic!("expected VectorSearch, got {:?}", qp),
346        }
347    }
348
349    #[test]
350    fn test_tag_filter_produces_tag_scan() {
351        let qp = plan_mql(r#"RECALL memories WHERE tag = "backend" LIMIT 5"#);
352        match qp {
353            QueryPlan::TagScan { tags, limit, .. } => {
354                assert_eq!(tags, vec!["backend".to_string()]);
355                assert_eq!(limit, Some(5));
356            }
357            _ => panic!("expected TagScan, got {:?}", qp),
358        }
359    }
360
361    #[test]
362    fn test_created_range_produces_temporal_scan_with_every_filter() {
363        let qp = plan_mql(
364            "RECALL memories WHERE created > 100 AND created >= 50 AND created < 900 AND type = episodic ORDER BY salience DESC LIMIT 10",
365        );
366        match qp {
367            QueryPlan::TemporalScan {
368                start,
369                end,
370                filters,
371                limit,
372                order_by,
373            } => {
374                assert_eq!((start, end), (100, 900), "tightest bounds");
375                assert_eq!(filters.len(), 4, "range filters kept for the re-check");
376                assert!(filters.iter().any(|f| f.field == Field::Type));
377                assert_eq!(limit, Some(10));
378                assert!(order_by.is_some_and(|ob| ob.descending));
379            }
380            other => panic!("expected TemporalScan, got {:?}", other),
381        }
382        match plan_mql("RECALL memories WHERE created > 5") {
383            QueryPlan::TemporalScan { limit, .. } => assert_eq!(limit, Some(DEFAULT_LIMIT)),
384            other => panic!("expected TemporalScan, got {:?}", other),
385        }
386    }
387
388    #[test]
389    fn test_accessed_range_is_not_a_creation_index_scan() {
390        match plan_mql("RECALL memories WHERE accessed > 100 LIMIT 5") {
391            QueryPlan::TagScan { filters, .. } => assert_eq!(filters.len(), 1),
392            other => panic!("expected TagScan, got {:?}", other),
393        }
394    }
395
396    #[test]
397    fn test_owner_and_score_filters_are_validated() {
398        let id = "550e8400-e29b-41d4-a716-446655440000";
399        for ok in [
400            format!("RECALL memories WHERE agent = {id}"),
401            format!("RECALL memories WHERE agent = \"{id}\""),
402            format!("RECALL memories WHERE space != {id}"),
403            format!("RECALL memories WHERE agent IN [{id}, {id}]"),
404            "RECALL memories WHERE salience > 0.5".to_string(),
405            "RECALL memories WHERE confidence <= 1".to_string(),
406            "RECALL memories WHERE salience IN [0.1, 0.2]".to_string(),
407        ] {
408            let tokens = tokenize(&ok).unwrap();
409            let stmt = Parser::parse(&tokens).unwrap();
410            assert!(plan(&stmt).is_ok(), "{ok}");
411        }
412        for bad in [
413            "RECALL memories WHERE agent = \"alice\"".to_string(),
414            format!("RECALL memories WHERE agent < {id}"),
415            "RECALL memories WHERE space = 1".to_string(),
416            "RECALL memories WHERE salience = \"high\"".to_string(),
417            "RECALL memories WHERE confidence CONTAINS 1".to_string(),
418            "RECALL memories WHERE confidence IN [1, \"x\"]".to_string(),
419            "RECALL memories WHERE NOT (type = episodic OR agent = \"bob\")".to_string(),
420            "CONSOLIDATE WHERE salience < \"low\"".to_string(),
421        ] {
422            let tokens = tokenize(&bad).unwrap();
423            let stmt = Parser::parse(&tokens).unwrap();
424            assert!(
425                matches!(plan(&stmt), Err(MenteError::Query(_))),
426                "{bad}: expected a query error"
427            );
428        }
429    }
430
431    #[test]
432    fn test_forget_produces_delete() {
433        let qp = plan_mql("FORGET 550e8400-e29b-41d4-a716-446655440000");
434        match qp {
435            QueryPlan::Delete { id } => {
436                assert_eq!(
437                    id,
438                    "550e8400-e29b-41d4-a716-446655440000"
439                        .parse::<MemoryId>()
440                        .unwrap()
441                );
442            }
443            _ => panic!("expected Delete, got {:?}", qp),
444        }
445    }
446
447    #[test]
448    fn test_traverse_produces_graph_traversal() {
449        let qp = plan_mql(
450            "TRAVERSE 550e8400-e29b-41d4-a716-446655440000 DEPTH 3 WHERE edge_type = caused",
451        );
452        match qp {
453            QueryPlan::GraphTraversal {
454                depth, edge_types, ..
455            } => {
456                assert_eq!(depth, 3);
457                assert_eq!(edge_types, vec![EdgeType::Caused]);
458            }
459            _ => panic!("expected GraphTraversal, got {:?}", qp),
460        }
461    }
462
463    #[test]
464    fn test_relate_produces_edge_insert() {
465        let qp = plan_mql(
466            "RELATE 550e8400-e29b-41d4-a716-446655440000 -> 660e8400-e29b-41d4-a716-446655440000 AS caused WITH weight = 0.8",
467        );
468        match qp {
469            QueryPlan::EdgeInsert {
470                edge_type, weight, ..
471            } => {
472                assert_eq!(edge_type, EdgeType::Caused);
473                assert!((weight - 0.8).abs() < f32::EPSILON);
474            }
475            _ => panic!("expected EdgeInsert, got {:?}", qp),
476        }
477    }
478}