Skip to main content

mongreldb_core/
query.rs

1//! Tool-call-native query surface.
2//!
3//! A [`Query`] is a conjunction of [`Condition`]s. Each condition resolves to a
4//! set of row ids in the shared [`crate::rowid::RowId`] space (PK exact, bitmap
5//! equality, ANN semantic, FM substring, or a column range). [`crate::Table`]
6//! intersects the sets and materializes the survivors — letting an agent express
7//! `semsearch ∩ fm_contains ∩ cat_in`, which no SQL FTS pipeline can.
8
9/// One predicate over the row-id space.
10#[derive(Debug, Clone)]
11pub enum Condition {
12    /// Primary-key exact match (encoded key bytes).
13    Pk(Vec<u8>),
14    /// Low-cardinality equality via the roaring bitmap index.
15    BitmapEq { column_id: u16, value: Vec<u8> },
16    /// Multi-value equality via the roaring bitmap index (Phase 13.5). Resolves
17    /// to the **union** of `bitmap[col].get(v)` for each value — the index-
18    /// accelerated equivalent of `col IN (v1, v2, …)` or a semi-join's runtime
19    /// value set.
20    BitmapIn {
21        column_id: u16,
22        values: Vec<Vec<u8>>,
23    },
24    /// Semantic search via the binary-quantized ANN index.
25    Ann {
26        column_id: u16,
27        query: Vec<f32>,
28        k: usize,
29    },
30    /// Arbitrary substring via the FM index (no tokenization).
31    FmContains { column_id: u16, pattern: Vec<u8> },
32    /// Multi-segment FM intersection for `LIKE '%seg1%seg2%...'` (Priority 12).
33    /// Resolves to the **intersection** of FM lookups for each segment — a much
34    /// tighter superset than the single longest segment. DataFusion still
35    /// re-applies the real wildcard semantics (`Inexact` pushdown).
36    FmContainsAll {
37        column_id: u16,
38        patterns: Vec<Vec<u8>>,
39    },
40    /// Inclusive integer range (served by scanning the int column, later by the
41    /// learned PGM index / page-index pruning). Exclusive bounds (`>`,`<`) are
42    /// expressed exactly via ±1 in the translator.
43    Range { column_id: u16, lo: i64, hi: i64 },
44    /// Floating-point range with per-bound inclusivity (exact for `>`/`<`/`>=`/
45    /// `<=`/`BETWEEN`), served the same way as [`Condition::Range`].
46    RangeF64 {
47        column_id: u16,
48        lo: f64,
49        lo_inclusive: bool,
50        hi: f64,
51        hi_inclusive: bool,
52    },
53    /// SPLADE-style sparse retrieval: top-k row ids by sparse dot product over
54    /// shared tokens. `query` is a sparse vector `(token id → weight)`.
55    SparseMatch {
56        column_id: u16,
57        query: Vec<(u32, f32)>,
58        k: usize,
59    },
60    /// MinHash/LSH set-similarity: candidate row ids whose set is similar to
61    /// `query` (a set of 64-bit token hashes), ranked by estimated Jaccard,
62    /// truncated to `k`. Approximate (LSH recall) — the caller re-verifies.
63    MinHashSimilar {
64        column_id: u16,
65        query: Vec<u64>,
66        k: usize,
67    },
68    /// Rows where `column_id` is NULL. Resolved by decoding the column and
69    /// collecting null positions — a column scan, but no row materialization.
70    /// Page-stat aware: pages with `null_count == 0` are skipped.
71    IsNull { column_id: u16 },
72    /// Rows where `column_id` is NOT NULL. The complement of [`Self::IsNull`].
73    /// Page-stat aware: pages with `null_count == row_count` are skipped.
74    IsNotNull { column_id: u16 },
75}
76
77/// A conjunctive query. Empty ⇒ all rows.
78#[derive(Debug, Default, Clone)]
79pub struct Query {
80    pub conditions: Vec<Condition>,
81}
82
83impl Query {
84    pub fn new() -> Self {
85        Self::default()
86    }
87    pub fn and(mut self, c: Condition) -> Self {
88        self.conditions.push(c);
89        self
90    }
91    pub fn pk(key: Vec<u8>) -> Self {
92        Self::new().and(Condition::Pk(key))
93    }
94}
95
96/// Canonical 64-bit cache key for a conjunctive native query + optional
97/// projection at `epoch` (Phase 19.1 / 19.6). Conditions are commutative (they
98/// are ANDed), so each condition is hashed into its own 64-bit digest, the
99/// digests are sorted, then folded together — two queries with the same
100/// semantics in a different order produce the same key. Within a condition,
101/// `BitmapIn` values are deduped+sorted and the `SparseMatch` query is sorted
102/// by token id. `epoch` is folded in so a `commit()` (which bumps it) orphans
103/// every prior entry without an explicit sweep.
104pub fn canonical_query_key(
105    conditions: &[Condition],
106    projection: Option<&[u16]>,
107    epoch: u64,
108) -> u64 {
109    let fold = |seed: u64, b: u64| -> u64 { seed.wrapping_mul(0x9E3779B97F4A7C15).wrapping_add(b) };
110    let mut acc = fold(0xA5A5_A5A5_A5A5_A5A5, epoch);
111    // Order-independent: per-condition digests, sorted, then folded.
112    let mut digests: Vec<u64> = conditions.iter().map(hash_condition).collect();
113    digests.sort_unstable();
114    let n = digests.len() as u64;
115    acc = fold(acc, n);
116    for d in digests {
117        acc = fold(acc, d);
118    }
119    // Projection: sorted column ids (None ⇒ "all columns", distinct from any
120    // explicit projection incl. one listing every column, by intent).
121    match projection {
122        Some(p) => {
123            let mut p = p.to_vec();
124            p.sort_unstable();
125            p.dedup();
126            acc = fold(acc, 0x5E);
127            acc = fold(acc, p.len() as u64);
128            for id in p {
129                acc = fold(acc, id as u64);
130            }
131        }
132        None => {
133            acc = fold(acc, 0xA5);
134        }
135    }
136    acc
137}
138
139/// Hash a single condition into a 64-bit digest (order-independent w.r.t. its
140/// siblings; see [`canonical_query_key`]). Floats are hashed via `to_bits` for
141/// determinism.
142fn hash_condition(c: &Condition) -> u64 {
143    use std::hash::{Hash, Hasher};
144    let mut h = std::collections::hash_map::DefaultHasher::new();
145    match c {
146        Condition::Pk(k) => {
147            0u8.hash(&mut h);
148            k.hash(&mut h);
149        }
150        Condition::BitmapEq { column_id, value } => {
151            1u8.hash(&mut h);
152            column_id.hash(&mut h);
153            value.hash(&mut h);
154        }
155        Condition::BitmapIn { column_id, values } => {
156            2u8.hash(&mut h);
157            column_id.hash(&mut h);
158            let mut v: Vec<&Vec<u8>> = values.iter().collect();
159            v.sort();
160            v.dedup();
161            v.len().hash(&mut h);
162            for b in v {
163                b.hash(&mut h);
164            }
165        }
166        Condition::Ann {
167            column_id,
168            query,
169            k,
170        } => {
171            3u8.hash(&mut h);
172            column_id.hash(&mut h);
173            k.hash(&mut h);
174            for f in query {
175                f.to_bits().hash(&mut h);
176            }
177        }
178        Condition::FmContains { column_id, pattern } => {
179            4u8.hash(&mut h);
180            column_id.hash(&mut h);
181            pattern.hash(&mut h);
182        }
183        Condition::FmContainsAll {
184            column_id,
185            patterns,
186        } => {
187            10u8.hash(&mut h);
188            column_id.hash(&mut h);
189            let mut sorted: Vec<&[u8]> = patterns.iter().map(|p| p.as_slice()).collect();
190            sorted.sort();
191            sorted.len().hash(&mut h);
192            for p in sorted {
193                p.hash(&mut h);
194            }
195        }
196        Condition::Range { column_id, lo, hi } => {
197            5u8.hash(&mut h);
198            column_id.hash(&mut h);
199            lo.hash(&mut h);
200            hi.hash(&mut h);
201        }
202        Condition::RangeF64 {
203            column_id,
204            lo,
205            lo_inclusive,
206            hi,
207            hi_inclusive,
208        } => {
209            6u8.hash(&mut h);
210            column_id.hash(&mut h);
211            lo.to_bits().hash(&mut h);
212            lo_inclusive.hash(&mut h);
213            hi.to_bits().hash(&mut h);
214            hi_inclusive.hash(&mut h);
215        }
216        Condition::SparseMatch {
217            column_id,
218            query,
219            k,
220        } => {
221            7u8.hash(&mut h);
222            column_id.hash(&mut h);
223            k.hash(&mut h);
224            let mut q: Vec<(u32, u32)> = query.iter().map(|(t, w)| (*t, w.to_bits())).collect();
225            q.sort_by_key(|(t, _)| *t);
226            for (t, wb) in q {
227                t.hash(&mut h);
228                wb.hash(&mut h);
229            }
230        }
231        Condition::MinHashSimilar {
232            column_id,
233            query,
234            k,
235        } => {
236            10u8.hash(&mut h);
237            column_id.hash(&mut h);
238            k.hash(&mut h);
239            let mut q = query.clone();
240            q.sort_unstable();
241            for t in q {
242                t.hash(&mut h);
243            }
244        }
245        Condition::IsNull { column_id } => {
246            8u8.hash(&mut h);
247            column_id.hash(&mut h);
248        }
249        Condition::IsNotNull { column_id } => {
250            9u8.hash(&mut h);
251            column_id.hash(&mut h);
252        }
253    }
254    h.finish()
255}
256
257/// Extract the column IDs referenced by a slice of conditions (Phase 19.1
258/// hardening (c)). `Pk` references no user column (it's a row-id lookup) so it
259/// contributes nothing. Used for conservative column-based cache invalidation:
260/// a commit touching any of these columns may change the result.
261pub fn condition_columns(conditions: &[Condition]) -> Vec<u16> {
262    let mut cols: Vec<u16> = conditions
263        .iter()
264        .filter_map(|c| match c {
265            Condition::Pk(_) => None,
266            Condition::BitmapEq { column_id, .. }
267            | Condition::BitmapIn { column_id, .. }
268            | Condition::Ann { column_id, .. }
269            | Condition::FmContains { column_id, .. }
270            | Condition::FmContainsAll { column_id, .. }
271            | Condition::Range { column_id, .. }
272            | Condition::RangeF64 { column_id, .. }
273            | Condition::SparseMatch { column_id, .. }
274            | Condition::MinHashSimilar { column_id, .. }
275            | Condition::IsNull { column_id }
276            | Condition::IsNotNull { column_id } => Some(*column_id),
277        })
278        .collect();
279    cols.sort_unstable();
280    cols.dedup();
281    cols
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287
288    #[test]
289    fn builder_chains() {
290        let q = Query::pk(b"k".to_vec()).and(Condition::Range {
291            column_id: 1,
292            lo: 0,
293            hi: 10,
294        });
295        assert_eq!(q.conditions.len(), 2);
296    }
297
298    /// Phase 19.6: order-independent canonicalization — the same conditions in a
299    /// different order, and a `BitmapIn` with shuffled/duplicate values, all
300    /// produce the same key.
301    #[test]
302    fn canonical_key_is_order_independent() {
303        let e = 7u64;
304        let a = Query::new()
305            .and(Condition::Range {
306                column_id: 1,
307                lo: 0,
308                hi: 10,
309            })
310            .and(Condition::BitmapEq {
311                column_id: 2,
312                value: b"x".to_vec(),
313            });
314        let b = Query::new()
315            .and(Condition::BitmapEq {
316                column_id: 2,
317                value: b"x".to_vec(),
318            })
319            .and(Condition::Range {
320                column_id: 1,
321                lo: 0,
322                hi: 10,
323            });
324        assert_eq!(
325            canonical_query_key(&a.conditions, None, e),
326            canonical_query_key(&b.conditions, None, e),
327            "condition order must not affect the key"
328        );
329
330        // BitmapIn dedup + sort.
331        let ordered = Condition::BitmapIn {
332            column_id: 3,
333            values: vec![b"a".to_vec(), b"b".to_vec(), b"c".to_vec()],
334        };
335        let shuffled = Condition::BitmapIn {
336            column_id: 3,
337            values: vec![b"c".to_vec(), b"a".to_vec(), b"a".to_vec(), b"b".to_vec()],
338        };
339        assert_eq!(
340            canonical_query_key(std::slice::from_ref(&ordered), None, e),
341            canonical_query_key(&[shuffled], None, e),
342            "BitmapIn values must dedup+sort"
343        );
344
345        // Epoch changes the key (invalidation).
346        assert_ne!(
347            canonical_query_key(&a.conditions, None, e),
348            canonical_query_key(&a.conditions, None, e + 1),
349            "epoch must fold into the key"
350        );
351
352        // Projection None vs explicit differs (by intent).
353        let proj = vec![1u16, 2];
354        assert_ne!(
355            canonical_query_key(&a.conditions, None, e),
356            canonical_query_key(&a.conditions, Some(&proj), e),
357            "None projection must differ from an explicit projection"
358        );
359        // Projection order-independence.
360        let proj_rev = vec![2u16, 1];
361        assert_eq!(
362            canonical_query_key(&a.conditions, Some(&proj), e),
363            canonical_query_key(&a.conditions, Some(&proj_rev), e),
364        );
365    }
366}