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