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