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/// Hard safety ceilings shared by every public query surface.
10pub const MAX_FINAL_LIMIT: usize = 10_000;
11pub const MAX_RETRIEVER_K: usize = 100_000;
12pub const MAX_RETRIEVERS: usize = 32;
13pub const MAX_SPARSE_TERMS: usize = 65_536;
14pub const MAX_SET_MEMBERS: usize = 65_536;
15pub const MAX_PROJECTION_COLUMNS: usize = 4_096;
16pub const MAX_HARD_CONDITIONS: usize = 256;
17pub const MAX_RETRIEVER_WEIGHT: f64 = 1_000_000.0;
18pub const MAX_FUSED_CANDIDATES: usize = 1_000_000;
19
20/// Cooperative deadline, cancellation, and work-budget state for expensive
21/// AI queries. Index loops call `checkpoint` and charge work with `consume`.
22#[derive(Debug, Clone)]
23pub struct AiExecutionContext {
24    deadline: Option<std::time::Instant>,
25    remaining_work: std::sync::Arc<std::sync::atomic::AtomicUsize>,
26    cancelled: std::sync::Arc<std::sync::atomic::AtomicBool>,
27}
28
29impl AiExecutionContext {
30    pub fn new(deadline: Option<std::time::Instant>, work_budget: usize) -> Self {
31        Self {
32            deadline,
33            remaining_work: std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(work_budget)),
34            cancelled: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
35        }
36    }
37
38    pub fn with_timeout(timeout: std::time::Duration, work_budget: usize) -> Self {
39        Self::new(Some(std::time::Instant::now() + timeout), work_budget)
40    }
41
42    pub fn cancel(&self) {
43        self.cancelled
44            .store(true, std::sync::atomic::Ordering::Release);
45    }
46
47    pub fn checkpoint(&self) -> crate::Result<()> {
48        if self.cancelled.load(std::sync::atomic::Ordering::Acquire) {
49            return Err(crate::MongrelError::Cancelled);
50        }
51        if self
52            .deadline
53            .is_some_and(|deadline| std::time::Instant::now() >= deadline)
54        {
55            return Err(crate::MongrelError::DeadlineExceeded);
56        }
57        Ok(())
58    }
59
60    pub fn consume(&self, work: usize) -> crate::Result<()> {
61        self.checkpoint()?;
62        let result = self.remaining_work.fetch_update(
63            std::sync::atomic::Ordering::AcqRel,
64            std::sync::atomic::Ordering::Acquire,
65            |remaining| remaining.checked_sub(work),
66        );
67        if result.is_err() {
68            return Err(crate::MongrelError::WorkBudgetExceeded);
69        }
70        self.checkpoint()
71    }
72}
73
74/// One predicate over the row-id space.
75#[derive(Debug, Clone)]
76pub enum Condition {
77    /// Primary-key exact match (encoded key bytes).
78    Pk(Vec<u8>),
79    /// Low-cardinality equality via the roaring bitmap index.
80    BitmapEq { column_id: u16, value: Vec<u8> },
81    /// Multi-value equality via the roaring bitmap index (Phase 13.5). Resolves
82    /// to the **union** of `bitmap[col].get(v)` for each value — the index-
83    /// accelerated equivalent of `col IN (v1, v2, …)` or a semi-join's runtime
84    /// value set.
85    BitmapIn {
86        column_id: u16,
87        values: Vec<Vec<u8>>,
88    },
89    /// Prefix match on a Bytes column with a bitmap index: all row-ids whose
90    /// indexed value starts with `prefix`. Exact (no residual needed) — the
91    /// bitmap's distinct keys are enumerated and filtered by prefix. Tighter
92    /// than `FmContains` for anchored `LIKE 'prefix%'`. (§5.6)
93    BytesPrefix { column_id: u16, prefix: Vec<u8> },
94    /// Semantic search via the binary-quantized ANN index.
95    Ann {
96        column_id: u16,
97        query: Vec<f32>,
98        k: usize,
99    },
100    /// Arbitrary substring via the FM index (no tokenization).
101    FmContains { column_id: u16, pattern: Vec<u8> },
102    /// Multi-segment FM intersection for `LIKE '%seg1%seg2%...'` (Priority 12).
103    /// Resolves to the **intersection** of FM lookups for each segment — a much
104    /// tighter superset than the single longest segment. DataFusion still
105    /// re-applies the real wildcard semantics (`Inexact` pushdown).
106    FmContainsAll {
107        column_id: u16,
108        patterns: Vec<Vec<u8>>,
109    },
110    /// Inclusive integer range (served by scanning the int column, later by the
111    /// learned PGM index / page-index pruning). Exclusive bounds (`>`,`<`) are
112    /// expressed exactly via ±1 in the translator.
113    Range { column_id: u16, lo: i64, hi: i64 },
114    /// Floating-point range with per-bound inclusivity (exact for `>`/`<`/`>=`/
115    /// `<=`/`BETWEEN`), served the same way as [`Condition::Range`].
116    RangeF64 {
117        column_id: u16,
118        lo: f64,
119        lo_inclusive: bool,
120        hi: f64,
121        hi_inclusive: bool,
122    },
123    /// SPLADE-style sparse retrieval: top-k row ids by sparse dot product over
124    /// shared tokens. `query` is a sparse vector `(token id → weight)`.
125    SparseMatch {
126        column_id: u16,
127        query: Vec<(u32, f32)>,
128        k: usize,
129    },
130    /// MinHash/LSH set-similarity: candidate row ids whose set is similar to
131    /// `query` (a set of 64-bit token hashes), ranked by estimated Jaccard,
132    /// truncated to `k`. Approximate (LSH recall) — the caller re-verifies.
133    MinHashSimilar {
134        column_id: u16,
135        query: Vec<u64>,
136        k: usize,
137    },
138    /// Rows where `column_id` is NULL. Resolved by decoding the column and
139    /// collecting null positions — a column scan, but no row materialization.
140    /// Page-stat aware: pages with `null_count == 0` are skipped.
141    IsNull { column_id: u16 },
142    /// Rows where `column_id` is NOT NULL. The complement of [`Self::IsNull`].
143    /// Page-stat aware: pages with `null_count == row_count` are skipped.
144    IsNotNull { column_id: u16 },
145}
146
147/// Ordered candidate generator. Unlike [`Condition`], retrieval preserves the
148/// index score and rank.
149#[derive(Debug, Clone)]
150pub enum Retriever {
151    Ann {
152        column_id: u16,
153        query: Vec<f32>,
154        k: usize,
155    },
156    Sparse {
157        column_id: u16,
158        query: Vec<(u32, f32)>,
159        k: usize,
160    },
161    MinHash {
162        column_id: u16,
163        members: Vec<SetMember>,
164        k: usize,
165    },
166}
167
168impl Retriever {
169    pub fn column_id(&self) -> u16 {
170        match self {
171            Self::Ann { column_id, .. }
172            | Self::Sparse { column_id, .. }
173            | Self::MinHash { column_id, .. } => *column_id,
174        }
175    }
176}
177
178pub fn encode_sparse_vector(terms: &[(u32, f32)]) -> crate::Result<Vec<u8>> {
179    Ok(bincode::serialize(terms)?)
180}
181
182#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
183#[serde(untagged)]
184pub enum SetMember {
185    String(String),
186    Number(serde_json::Number),
187    Boolean(bool),
188}
189
190impl SetMember {
191    pub fn hash_v1(&self) -> u64 {
192        let value = match self {
193            Self::String(value) => serde_json::Value::String(value.clone()),
194            Self::Number(value) => serde_json::Value::Number(value.clone()),
195            Self::Boolean(value) => serde_json::Value::Bool(*value),
196        };
197        crate::index::minhash_member_hash_v1(&value).expect("SetMember is always scalar")
198    }
199}
200
201#[derive(Debug, Clone, Copy, PartialEq)]
202pub enum RetrieverScore {
203    AnnHammingDistance(u32),
204    SparseDotProduct(f64),
205    MinHashEstimatedJaccard(f32),
206}
207
208#[derive(Debug, Clone, Copy, PartialEq, Eq)]
209pub enum VectorMetric {
210    Cosine,
211    DotProduct,
212    Euclidean,
213}
214
215#[derive(Debug, Clone)]
216pub struct AnnRerankRequest {
217    pub column_id: u16,
218    pub query: Vec<f32>,
219    pub candidate_k: usize,
220    pub limit: usize,
221    pub metric: VectorMetric,
222}
223
224#[derive(Debug, Clone, Copy, PartialEq)]
225pub struct AnnRerankHit {
226    pub row_id: crate::RowId,
227    pub hamming_distance: u32,
228    pub exact_score: f32,
229}
230
231#[derive(Debug, Clone, PartialEq)]
232pub struct RetrieverHit {
233    pub row_id: crate::RowId,
234    /// One-based rank.
235    pub rank: usize,
236    pub score: RetrieverScore,
237}
238
239#[derive(Debug, Clone)]
240pub struct SetSimilarityRequest {
241    pub column_id: u16,
242    pub members: Vec<SetMember>,
243    pub candidate_k: usize,
244    pub min_jaccard: f32,
245    pub limit: usize,
246}
247
248#[derive(Debug, Clone, PartialEq)]
249pub struct SetSimilarityHit {
250    pub row_id: crate::RowId,
251    pub estimated_jaccard: f32,
252    pub exact_jaccard: f32,
253}
254
255#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
256pub struct SetSimilarityTrace {
257    pub candidate_generation_us: u64,
258    pub gather_us: u64,
259    pub parse_us: u64,
260    pub score_us: u64,
261    pub candidate_count: usize,
262    pub verified_count: usize,
263}
264
265#[derive(Debug, Clone)]
266pub struct SearchRequest {
267    pub must: Vec<Condition>,
268    pub retrievers: Vec<NamedRetriever>,
269    pub fusion: Fusion,
270    pub limit: usize,
271    pub projection: Option<Vec<u16>>,
272}
273
274#[derive(Debug, Clone)]
275pub struct NamedRetriever {
276    pub name: String,
277    pub weight: f64,
278    pub retriever: Retriever,
279}
280
281#[derive(Debug, Clone, Copy)]
282pub enum Fusion {
283    ReciprocalRank { constant: u32 },
284}
285
286#[derive(Debug, Clone, PartialEq)]
287pub struct ComponentScore {
288    pub retriever_name: String,
289    pub rank: usize,
290    pub raw_score: RetrieverScore,
291    pub contribution: f64,
292}
293
294#[derive(Debug, Clone, PartialEq)]
295pub struct SearchHit {
296    pub row_id: crate::RowId,
297    pub cells: Vec<(u16, crate::Value)>,
298    pub components: Vec<ComponentScore>,
299    pub fused_score: f64,
300}
301
302/// A conjunctive query. Empty ⇒ all rows.
303#[derive(Debug, Clone, Default)]
304pub struct Query {
305    pub conditions: Vec<Condition>,
306    pub limit: Option<usize>,
307    pub offset: usize,
308}
309
310impl Query {
311    pub fn new() -> Self {
312        Self::default()
313    }
314    pub fn and(mut self, c: Condition) -> Self {
315        self.conditions.push(c);
316        self
317    }
318    pub fn with_limit(mut self, limit: usize) -> Self {
319        self.limit = Some(limit);
320        self
321    }
322    pub fn with_offset(mut self, offset: usize) -> Self {
323        self.offset = offset;
324        self
325    }
326    pub fn pk(key: Vec<u8>) -> Self {
327        Self::new().and(Condition::Pk(key))
328    }
329}
330
331/// Canonical 64-bit cache key for a conjunctive native query + optional
332/// projection at `epoch` (Phase 19.1 / 19.6). Conditions are commutative (they
333/// are ANDed), so each condition is hashed into its own 64-bit digest, the
334/// digests are sorted, then folded together — two queries with the same
335/// semantics in a different order produce the same key. Within a condition,
336/// `BitmapIn` values are deduped+sorted and the `SparseMatch` query is sorted
337/// by token id. `epoch` is folded in so a `commit()` (which bumps it) orphans
338/// every prior entry without an explicit sweep.
339pub fn canonical_query_key(
340    conditions: &[Condition],
341    projection: Option<&[u16]>,
342    epoch: u64,
343) -> u64 {
344    canonical_query_key_with_version(QUERY_CACHE_KEY_VERSION, conditions, projection, epoch)
345}
346
347const QUERY_CACHE_KEY_VERSION: u64 = 2;
348
349fn canonical_query_key_with_version(
350    version: u64,
351    conditions: &[Condition],
352    projection: Option<&[u16]>,
353    epoch: u64,
354) -> u64 {
355    let fold = |seed: u64, b: u64| -> u64 { seed.wrapping_mul(0x9E3779B97F4A7C15).wrapping_add(b) };
356    let mut acc = fold(0xA5A5_A5A5_A5A5_A5A5, version);
357    acc = fold(acc, epoch);
358    // Order-independent: per-condition digests, sorted, then folded.
359    let mut digests: Vec<u64> = conditions.iter().map(hash_condition).collect();
360    digests.sort_unstable();
361    let n = digests.len() as u64;
362    acc = fold(acc, n);
363    for d in digests {
364        acc = fold(acc, d);
365    }
366    // Projection: sorted column ids (None ⇒ "all columns", distinct from any
367    // explicit projection incl. one listing every column, by intent).
368    match projection {
369        Some(p) => {
370            let mut p = p.to_vec();
371            p.sort_unstable();
372            p.dedup();
373            acc = fold(acc, 0x5E);
374            acc = fold(acc, p.len() as u64);
375            for id in p {
376                acc = fold(acc, id as u64);
377            }
378        }
379        None => {
380            acc = fold(acc, 0xA5);
381        }
382    }
383    acc
384}
385
386/// Hash a single condition into a 64-bit digest (order-independent w.r.t. its
387/// siblings; see [`canonical_query_key`]). Floats are hashed via `to_bits` for
388/// determinism.
389fn hash_condition(c: &Condition) -> u64 {
390    use std::hash::{Hash, Hasher};
391    let mut h = std::collections::hash_map::DefaultHasher::new();
392    match c {
393        Condition::Pk(k) => {
394            0u8.hash(&mut h);
395            k.hash(&mut h);
396        }
397        Condition::BitmapEq { column_id, value } => {
398            1u8.hash(&mut h);
399            column_id.hash(&mut h);
400            value.hash(&mut h);
401        }
402        Condition::BitmapIn { column_id, values } => {
403            2u8.hash(&mut h);
404            column_id.hash(&mut h);
405            let mut v: Vec<&Vec<u8>> = values.iter().collect();
406            v.sort();
407            v.dedup();
408            v.len().hash(&mut h);
409            for b in v {
410                b.hash(&mut h);
411            }
412        }
413        Condition::Ann {
414            column_id,
415            query,
416            k,
417        } => {
418            3u8.hash(&mut h);
419            column_id.hash(&mut h);
420            k.hash(&mut h);
421            for f in query {
422                f.to_bits().hash(&mut h);
423            }
424        }
425        Condition::FmContains { column_id, pattern } => {
426            4u8.hash(&mut h);
427            column_id.hash(&mut h);
428            pattern.hash(&mut h);
429        }
430        Condition::FmContainsAll {
431            column_id,
432            patterns,
433        } => {
434            5u8.hash(&mut h);
435            column_id.hash(&mut h);
436            let mut sorted: Vec<&[u8]> = patterns.iter().map(|p| p.as_slice()).collect();
437            sorted.sort();
438            sorted.dedup();
439            sorted.len().hash(&mut h);
440            for p in sorted {
441                p.hash(&mut h);
442            }
443        }
444        Condition::Range { column_id, lo, hi } => {
445            6u8.hash(&mut h);
446            column_id.hash(&mut h);
447            lo.hash(&mut h);
448            hi.hash(&mut h);
449        }
450        Condition::RangeF64 {
451            column_id,
452            lo,
453            lo_inclusive,
454            hi,
455            hi_inclusive,
456        } => {
457            7u8.hash(&mut h);
458            column_id.hash(&mut h);
459            lo.to_bits().hash(&mut h);
460            lo_inclusive.hash(&mut h);
461            hi.to_bits().hash(&mut h);
462            hi_inclusive.hash(&mut h);
463        }
464        Condition::SparseMatch {
465            column_id,
466            query,
467            k,
468        } => {
469            8u8.hash(&mut h);
470            column_id.hash(&mut h);
471            k.hash(&mut h);
472            let mut q: Vec<(u32, u32)> = query.iter().map(|(t, w)| (*t, w.to_bits())).collect();
473            q.sort_by_key(|(t, _)| *t);
474            for (t, wb) in q {
475                t.hash(&mut h);
476                wb.hash(&mut h);
477            }
478        }
479        Condition::MinHashSimilar {
480            column_id,
481            query,
482            k,
483        } => {
484            9u8.hash(&mut h);
485            column_id.hash(&mut h);
486            k.hash(&mut h);
487            let mut q = query.clone();
488            q.sort_unstable();
489            q.dedup();
490            for t in q {
491                t.hash(&mut h);
492            }
493        }
494        Condition::IsNull { column_id } => {
495            10u8.hash(&mut h);
496            column_id.hash(&mut h);
497        }
498        Condition::IsNotNull { column_id } => {
499            11u8.hash(&mut h);
500            column_id.hash(&mut h);
501        }
502        Condition::BytesPrefix { column_id, prefix } => {
503            12u8.hash(&mut h);
504            column_id.hash(&mut h);
505            prefix.hash(&mut h);
506        }
507    }
508    h.finish()
509}
510
511/// Extract the column IDs referenced by a slice of conditions (Phase 19.1
512/// hardening (c)). `Pk` references no user column (it's a row-id lookup) so it
513/// contributes nothing. Used for conservative column-based cache invalidation:
514/// a commit touching any of these columns may change the result.
515pub fn condition_columns(conditions: &[Condition]) -> Vec<u16> {
516    let mut cols: Vec<u16> = conditions
517        .iter()
518        .filter_map(|c| match c {
519            Condition::Pk(_) => None,
520            Condition::BitmapEq { column_id, .. }
521            | Condition::BitmapIn { column_id, .. }
522            | Condition::BytesPrefix { column_id, .. }
523            | Condition::Ann { column_id, .. }
524            | Condition::FmContains { column_id, .. }
525            | Condition::FmContainsAll { column_id, .. }
526            | Condition::Range { column_id, .. }
527            | Condition::RangeF64 { column_id, .. }
528            | Condition::SparseMatch { column_id, .. }
529            | Condition::MinHashSimilar { column_id, .. }
530            | Condition::IsNull { column_id }
531            | Condition::IsNotNull { column_id } => Some(*column_id),
532        })
533        .collect();
534    cols.sort_unstable();
535    cols.dedup();
536    cols
537}
538
539#[cfg(test)]
540mod tests {
541    use super::*;
542
543    #[test]
544    fn builder_chains() {
545        let q = Query::pk(b"k".to_vec()).and(Condition::Range {
546            column_id: 1,
547            lo: 0,
548            hi: 10,
549        });
550        assert_eq!(q.conditions.len(), 2);
551    }
552
553    /// Phase 19.6: order-independent canonicalization — the same conditions in a
554    /// different order, and a `BitmapIn` with shuffled/duplicate values, all
555    /// produce the same key.
556    #[test]
557    fn canonical_key_is_order_independent() {
558        let e = 7u64;
559        let a = Query::new()
560            .and(Condition::Range {
561                column_id: 1,
562                lo: 0,
563                hi: 10,
564            })
565            .and(Condition::BitmapEq {
566                column_id: 2,
567                value: b"x".to_vec(),
568            });
569        let b = Query::new()
570            .and(Condition::BitmapEq {
571                column_id: 2,
572                value: b"x".to_vec(),
573            })
574            .and(Condition::Range {
575                column_id: 1,
576                lo: 0,
577                hi: 10,
578            });
579        assert_eq!(
580            canonical_query_key(&a.conditions, None, e),
581            canonical_query_key(&b.conditions, None, e),
582            "condition order must not affect the key"
583        );
584
585        let minhash = Condition::MinHashSimilar {
586            column_id: 4,
587            query: vec![3, 1, 1, 2],
588            k: 5,
589        };
590        let minhash_canonical = Condition::MinHashSimilar {
591            column_id: 4,
592            query: vec![1, 2, 3],
593            k: 5,
594        };
595        assert_eq!(
596            canonical_query_key(std::slice::from_ref(&minhash), None, e),
597            canonical_query_key(&[minhash_canonical], None, e),
598        );
599
600        let fm = Condition::FmContainsAll {
601            column_id: 4,
602            patterns: vec![b"b".to_vec(), b"a".to_vec(), b"a".to_vec()],
603        };
604        let fm_canonical = Condition::FmContainsAll {
605            column_id: 4,
606            patterns: vec![b"a".to_vec(), b"b".to_vec()],
607        };
608        assert_eq!(
609            canonical_query_key(std::slice::from_ref(&fm), None, e),
610            canonical_query_key(&[fm_canonical], None, e),
611        );
612        assert_ne!(
613            canonical_query_key(std::slice::from_ref(&fm), None, e),
614            canonical_query_key(std::slice::from_ref(&minhash), None, e),
615        );
616        assert_ne!(
617            canonical_query_key_with_version(1, &a.conditions, None, e),
618            canonical_query_key_with_version(2, &a.conditions, None, e),
619        );
620
621        // BitmapIn dedup + sort.
622        let ordered = Condition::BitmapIn {
623            column_id: 3,
624            values: vec![b"a".to_vec(), b"b".to_vec(), b"c".to_vec()],
625        };
626        let shuffled = Condition::BitmapIn {
627            column_id: 3,
628            values: vec![b"c".to_vec(), b"a".to_vec(), b"a".to_vec(), b"b".to_vec()],
629        };
630        assert_eq!(
631            canonical_query_key(std::slice::from_ref(&ordered), None, e),
632            canonical_query_key(&[shuffled], None, e),
633            "BitmapIn values must dedup+sort"
634        );
635
636        // Epoch changes the key (invalidation).
637        assert_ne!(
638            canonical_query_key(&a.conditions, None, e),
639            canonical_query_key(&a.conditions, None, e + 1),
640            "epoch must fold into the key"
641        );
642
643        // Projection None vs explicit differs (by intent).
644        let proj = vec![1u16, 2];
645        assert_ne!(
646            canonical_query_key(&a.conditions, None, e),
647            canonical_query_key(&a.conditions, Some(&proj), e),
648            "None projection must differ from an explicit projection"
649        );
650        // Projection order-independence.
651        let proj_rev = vec![2u16, 1];
652        assert_eq!(
653            canonical_query_key(&a.conditions, Some(&proj), e),
654            canonical_query_key(&a.conditions, Some(&proj_rev), e),
655        );
656
657        let budget = AiExecutionContext::new(None, 2);
658        budget.consume(1).unwrap();
659        assert!(matches!(
660            budget.consume(2),
661            Err(crate::MongrelError::WorkBudgetExceeded)
662        ));
663        budget.cancel();
664        assert!(matches!(
665            budget.checkpoint(),
666            Err(crate::MongrelError::Cancelled)
667        ));
668
669        let expired = AiExecutionContext::new(
670            Some(std::time::Instant::now() - std::time::Duration::from_millis(1)),
671            1,
672        );
673        assert!(matches!(
674            expired.checkpoint(),
675            Err(crate::MongrelError::DeadlineExceeded)
676        ));
677    }
678}