Rindle docs and package mapSkip to main content

rindle/
push_index.rs

1//! Guarded push fan-out: a reverse predicate index over source connections
2//! (`designs/205-GUARDED-PUSH-FANOUT-DESIGN.md`).
3//!
4//! Every source write today fans out to **every** connection on the table and runs
5//! its full compiled predicate against the changed row(s) — O(N) predicate
6//! evaluations per write, N = connection slots, almost all concluding "drop" for
7//! the `where col = ?` shapes that dominate `rindled`. This module is the
8//! *conservative index* that prunes that fan-out to the connections that could
9//! actually match.
10//!
11//! ## Why skipping is exact, not heuristic
12//!
13//! The index is only ever a **superset** filter — it may over-approximate (visit a
14//! connection whose predicate then rejects the row), never under-approximate. Two
15//! facts (design §"safety argument") make that sound:
16//!
17//! 1. `filter_push` remains the exact gate: a
18//!    connection contributes nothing iff its predicate rejects **both** the old and
19//!    the new row. So the index need only never *miss* a possibly-matching
20//!    connection; a false positive re-runs the exact predicate, unchanged.
21//! 2. Skipping is observationally equivalent for the reentrancy machinery — a
22//!    skipped connection's stale `last_pushed_epoch` hides the overlay exactly as
23//!    the connection's own predicate would have narrowed that overlay to nothing.
24//!
25//! Corollary: **every approximation here errs only toward visiting.** A guard-less
26//! or un-guardable connection lands in the always-visited `scan` list; a
27//! coarser-than-identity `GuardKey` bucket merely re-checks the exact predicate.
28//! The one thing that would be a bug is a *finer* key.
29//!
30//! This module is intentionally standalone and free of the push orchestration: it
31//! is pure data (`PushGuard`), a comparator newtype (`GuardKey`), and the index
32//! (`PushIndex`). The extraction of a `PushGuard` from a query's `where` tree
33//! lives in `crate::builder`; the wiring of a `PushIndex` into `ConnTable`'s
34//! push path lives in [`crate::source_common`].
35
36use std::cmp::Ordering;
37use std::collections::{BTreeMap, BTreeSet};
38
39use crate::change::SourceChange;
40use crate::predicate::compare_predicate_values;
41use crate::value::{ColId, OwnedValue, Value};
42
43/// `predicate(row) ⇒ row[col] ∈ values` — a finite single-column implication of a
44/// connection's pushed-down **equality-shaped** filter, extracted (in
45/// `crate::builder`) from the same stripped condition the connection's
46/// [`RowPredicate`](crate::source_common::RowPredicate) was compiled from.
47/// Backend-neutral pure data (like [`SqlCondition`](crate::source_common::SqlCondition)),
48/// populated for both the memory and SQLite leaves.
49///
50/// `values` is deduplicated by extraction and is **non-empty in practice** — an
51/// empty set is the legitimate encoding of a *never-matching* predicate (an empty
52/// `IN`, or `col = NULL` which SQL folds to false); such a guard indexes its slot
53/// **nowhere** (not even the `scan` list), which is exact because the
54/// connection can receive no pushes.
55/// (No derived `PartialEq`: [`OwnedValue`] deliberately has none — the engine's
56/// dual-comparator convention forces callers to pick `values_identical` vs
57/// `values_equal` explicitly. Compare guards field-wise in tests.)
58#[derive(Clone, Debug)]
59pub struct PushGuard {
60    pub col: ColId,
61    pub values: Vec<OwnedValue>,
62}
63
64/// A [`BTreeMap`] key over an [`OwnedValue`](crate::value::OwnedValue) whose ordering is
65/// [`compare_predicate_values`] — the SAME total order the `=` / `IN` predicate
66/// identity ([`values_identical`](crate::value::values_identical)) collapses under
67/// (`Null == Null`; `Int`/`Float` widen through `total_cmp`-over-`f64`).
68///
69/// **Coarsening direction is load-bearing.** The map's equality must never
70/// *separate* two values that `values_identical` equates — else a row cell that the
71/// predicate treats as identical to a guard literal could miss that literal's
72/// bucket and wrongly skip a matching connection (a dropped delta). Coarser is
73/// safe: two distinct large `Int`s that widen to one `f64` share a bucket, a false
74/// positive the exact predicate re-rejects. `compare_predicate_values` is exactly
75/// as coarse as `values_identical` on every equal pair and strictly finer nowhere
76/// identity holds (checked pairwise by the property test below), so it is the right
77/// key.
78#[derive(Clone, Debug)]
79struct GuardKey(OwnedValue);
80
81impl Ord for GuardKey {
82    #[inline]
83    fn cmp(&self, other: &Self) -> Ordering {
84        compare_predicate_values(self.0.as_ref(), other.0.as_ref())
85    }
86}
87impl PartialOrd for GuardKey {
88    #[inline]
89    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
90        Some(self.cmp(other))
91    }
92}
93impl Eq for GuardKey {}
94impl PartialEq for GuardKey {
95    #[inline]
96    fn eq(&self, other: &Self) -> bool {
97        self.cmp(other) == Ordering::Equal
98    }
99}
100
101/// The reverse index: guard value → connection slots, plus the always-visited scan
102/// set and a refcounted union of split-edit keys.
103///
104/// A connection is registered in **exactly one place**: under its guard's column
105/// (once per guard value) in the `eq` map, or in the `scan` set when it
106/// has no extractable guard — *unless* its guard has empty `values` (never-matching),
107/// in which case it is registered nowhere. [`insert`](Self::insert) /
108/// [`remove`](Self::remove) are symmetric so the invariant survives slot recycling.
109#[derive(Default, Debug)]
110#[cfg_attr(test, derive(Clone))]
111pub struct PushIndex {
112    /// col → guard value → slots guarded on `(col, value)`.
113    eq: BTreeMap<ColId, BTreeMap<GuardKey, Vec<u32>>>,
114    /// Slots with no extractable guard (no filter, presence-only, un-guardable
115    /// shape). Always a push candidate — these keep today's behavior verbatim.
116    scan: BTreeSet<u32>,
117    /// Refcounted union of every registered connection's `split_edit_keys`: a key
118    /// is present iff some live slot lists it. Replaces the per-Edit O(N·keys) scan
119    /// with an O(distinct keys) check.
120    split_keys: BTreeMap<ColId, u32>,
121    /// Slots whose guard values change at **runtime** — a parameterized query family's
122    /// root connection (design 310 §4.1, the 205 extension): slot → (col, the values
123    /// currently added, one entry per [`add_guard_value`](Self::add_guard_value) call,
124    /// so two bindings sharing a first-column value refcount naturally). The static
125    /// [`PushGuard`] of such a slot has EMPTY values (indexed nowhere at `insert`), so
126    /// [`remove`](Self::remove) — driven by that static guard — drains this map too and
127    /// leaves no stale bucket behind at `destroy`.
128    dynamic: BTreeMap<u32, (ColId, Vec<OwnedValue>)>,
129}
130
131impl PushIndex {
132    pub fn new() -> PushIndex {
133        PushIndex::default()
134    }
135
136    /// Register `slot` under `guard` (or the scan set when `None`), and refcount its
137    /// `split_edit_keys` into the union. Idempotent per (slot, guard) pair only in
138    /// the sense the caller enforces: `ConnTable` inserts once per `connect`.
139    pub fn insert(&mut self, slot: u32, guard: Option<&PushGuard>, split_edit_keys: &[ColId]) {
140        match guard {
141            // Never-matching guard (empty `IN`, `col = NULL`): index nowhere. The
142            // connection can receive no pushes, so skipping it always is exact.
143            Some(g) if g.values.is_empty() => {}
144            Some(g) => {
145                let col_map = self.eq.entry(g.col).or_default();
146                for v in &g.values {
147                    col_map.entry(GuardKey(v.clone())).or_default().push(slot);
148                }
149            }
150            None => {
151                self.scan.insert(slot);
152            }
153        }
154        for &k in split_edit_keys {
155            *self.split_keys.entry(k).or_insert(0) += 1;
156        }
157    }
158
159    /// Un-register `slot` — the exact inverse of [`insert`](Self::insert), driven by
160    /// the connection's own still-present `guard` (so it names precisely which
161    /// buckets to remove it from). Empty buckets and column maps are pruned so the
162    /// per-push column iteration stays proportional to *live* guarded columns. Any
163    /// **dynamic** guard values the slot accumulated are drained as well.
164    pub fn remove(&mut self, slot: u32, guard: Option<&PushGuard>, split_edit_keys: &[ColId]) {
165        match guard {
166            Some(g) if g.values.is_empty() => {}
167            Some(g) => {
168                for v in &g.values {
169                    self.unbucket(slot, g.col, v);
170                }
171            }
172            None => {
173                self.scan.remove(&slot);
174            }
175        }
176        if let Some((col, values)) = self.dynamic.remove(&slot) {
177            for v in &values {
178                self.unbucket(slot, col, v);
179            }
180        }
181        for &k in split_edit_keys {
182            if let Some(rc) = self.split_keys.get_mut(&k) {
183                *rc -= 1;
184                if *rc == 0 {
185                    self.split_keys.remove(&k);
186                }
187            }
188        }
189    }
190
191    /// Add one **dynamic** guard value for `slot` on `col` (design 310 §4.1, the 205
192    /// extension for a family root's growing binding set): the slot becomes a push
193    /// candidate for writes whose `col` cell is identical to `v`. Repeated values
194    /// stack (one bucket entry each) and un-stack one at a time, so two bindings that
195    /// share a first-column value keep the slot indexed until both are gone.
196    ///
197    /// The 205 safety argument is unchanged: the index stays a superset filter and the
198    /// exact predicate re-checks every visit — provided the caller adds the guard value
199    /// **before** the predicate starts accepting the value, and removes it **after** the
200    /// predicate stops (the ordering discipline `Graph::bind_family_partition` follows),
201    /// and only ever between pushes.
202    pub fn add_guard_value(&mut self, slot: u32, col: ColId, v: OwnedValue) {
203        self.eq
204            .entry(col)
205            .or_default()
206            .entry(GuardKey(v.clone()))
207            .or_default()
208            .push(slot);
209        self.dynamic
210            .entry(slot)
211            .or_insert_with(|| (col, Vec::new()))
212            .1
213            .push(v);
214    }
215
216    /// Remove one dynamic guard value previously added with
217    /// [`add_guard_value`](Self::add_guard_value) (one stacked entry). A value that was
218    /// never added is a no-op.
219    pub fn remove_guard_value(&mut self, slot: u32, col: ColId, v: &OwnedValue) {
220        let Some((dcol, values)) = self.dynamic.get_mut(&slot) else {
221            return;
222        };
223        debug_assert_eq!(
224            *dcol, col,
225            "a slot's dynamic guard values live on one column"
226        );
227        let Some(pos) = values
228            .iter()
229            .position(|x| GuardKey(x.clone()) == GuardKey(v.clone()))
230        else {
231            return;
232        };
233        values.swap_remove(pos);
234        if values.is_empty() {
235            self.dynamic.remove(&slot);
236        }
237        self.unbucket(slot, col, v);
238    }
239
240    /// Remove ONE appearance of `slot` from the `(col, v)` bucket, pruning the bucket
241    /// and the column map when they empty.
242    fn unbucket(&mut self, slot: u32, col: ColId, v: &OwnedValue) {
243        if let Some(col_map) = self.eq.get_mut(&col) {
244            let key = GuardKey(v.clone());
245            if let Some(bucket) = col_map.get_mut(&key) {
246                if let Some(pos) = bucket.iter().position(|&s| s == slot) {
247                    // Order within a bucket is irrelevant (candidates are sorted
248                    // before the fan-out); swap_remove is O(1).
249                    bucket.swap_remove(pos);
250                }
251                if bucket.is_empty() {
252                    col_map.remove(&key);
253                }
254            }
255            if col_map.is_empty() {
256                self.eq.remove(&col);
257            }
258        }
259    }
260
261    /// The number of entries the index holds — bucket memberships, scan slots, and
262    /// dynamic values (a dynamic value counts twice: its bucket membership and its
263    /// dynamic-map entry) — a churn/leak probe's size signal.
264    pub fn size(&self) -> usize {
265        let buckets: usize = self
266            .eq
267            .values()
268            .flat_map(|m| m.values())
269            .map(|b| b.len())
270            .sum();
271        let dynamic: usize = self.dynamic.values().map(|(_, v)| v.len()).sum();
272        buckets + self.scan.len() + dynamic
273    }
274
275    /// Slots that may satisfy `predicate(old) || predicate(new)` for `change` — a
276    /// **superset** by construction (module docs). Sorted ascending and deduped, so
277    /// the fan-out visits in slot order exactly as the full-scan loop does today.
278    ///
279    /// Starts from every `scan` slot, then for each guarded column looks up the
280    /// change's relevant cell(s): `Add`/`Remove` contribute the one row's cell;
281    /// `Edit` contributes both `row[col]` and `old[col]` (an edit can leave one
282    /// guard set and enter another).
283    pub fn push_candidates(&self, change: &SourceChange) -> Vec<u32> {
284        let mut out: Vec<u32> = self.scan.iter().copied().collect();
285        for (&col, buckets) in &self.eq {
286            match change {
287                SourceChange::Add(r) | SourceChange::Remove(r) => {
288                    Self::collect_bucket(buckets, r.col(col), &mut out);
289                }
290                SourceChange::Edit { row, old } => {
291                    Self::collect_bucket(buckets, row.col(col), &mut out);
292                    Self::collect_bucket(buckets, old.col(col), &mut out);
293                }
294            }
295        }
296        out.sort_unstable();
297        out.dedup();
298        out
299    }
300
301    /// Look up `cell`'s bucket in one column's map and append its slots.
302    ///
303    /// `to_owned` materializes the lookup key: a no-op copy for scalar cells; one
304    /// `Arc` allocation for `Str`/`Json`. (An `Absent` cell — partial union row —
305    /// finds no bucket, which is correct: guard literals are never `Absent`, and
306    /// every guard-eligible leaf rejects an absent cell.)
307    #[inline]
308    fn collect_bucket(buckets: &BTreeMap<GuardKey, Vec<u32>>, cell: Value<'_>, out: &mut Vec<u32>) {
309        if let Some(slots) = buckets.get(&GuardKey(cell.to_owned())) {
310            out.extend_from_slice(slots);
311        }
312    }
313
314    /// The distinct split-edit keys across all registered connections. `should_split`
315    /// is `split_keys().any(|k| !values_equal(row[k], old[k]))` — exactly equivalent
316    /// to today's `any`-of-`any` over per-connection key lists, at O(distinct keys).
317    pub fn split_keys(&self) -> impl Iterator<Item = ColId> + '_ {
318        self.split_keys.keys().copied()
319    }
320
321    /// `true` iff no connection lists any split-edit key — lets the caller skip the
322    /// edit-split check entirely.
323    pub fn has_split_keys(&self) -> bool {
324        !self.split_keys.is_empty()
325    }
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331    use crate::value::{owned_row, values_identical, OwnedRow};
332
333    fn iv(n: i64) -> OwnedValue {
334        OwnedValue::Int(n)
335    }
336    fn add(row: OwnedRow) -> SourceChange {
337        SourceChange::Add(row)
338    }
339    fn guard(col: ColId, values: Vec<OwnedValue>) -> PushGuard {
340        PushGuard { col, values }
341    }
342
343    // -- GuardKey coarseness: the never-finer property (design §Testing.3) --------
344
345    /// The corpus of "identical-under-predicate" pairs and "distinct" values that
346    /// exercise every arm of `values_identical` / `compare_predicate_values`.
347    fn value_corpus() -> Vec<OwnedValue> {
348        vec![
349            OwnedValue::Null,
350            OwnedValue::Bool(false),
351            OwnedValue::Bool(true),
352            OwnedValue::Int(0),
353            OwnedValue::Int(7),
354            OwnedValue::Float(7.0), // identical to Int(7) under the predicate domain
355            OwnedValue::Float(7.5),
356            OwnedValue::Float(f64::NAN),
357            OwnedValue::str("7"),
358            OwnedValue::str("apple"),
359            OwnedValue::Json("{}".into()),
360        ]
361    }
362
363    #[test]
364    fn guardkey_never_finer_than_values_identical() {
365        // The one direction that would be a correctness bug: two values the predicate
366        // treats as identical MUST share a GuardKey bucket. (Coarser is fine.)
367        let corpus = value_corpus();
368        for a in &corpus {
369            for b in &corpus {
370                if values_identical(a.as_ref(), b.as_ref()) {
371                    assert_eq!(
372                        GuardKey(a.clone()),
373                        GuardKey(b.clone()),
374                        "values_identical({a:?}, {b:?}) but GuardKeys differ — would drop a delta",
375                    );
376                }
377            }
378        }
379    }
380
381    #[test]
382    fn guardkey_int_float_widening_shares_bucket() {
383        // The exact false-positive-tolerant coarsening the design relies on.
384        let ki = GuardKey(OwnedValue::Int(7));
385        let kf = GuardKey(OwnedValue::Float(7.0));
386        assert_eq!(ki, kf);
387        let mut m: BTreeMap<GuardKey, u32> = BTreeMap::new();
388        m.insert(GuardKey(OwnedValue::Int(7)), 1);
389        assert_eq!(m.get(&GuardKey(OwnedValue::Float(7.0))), Some(&1));
390    }
391
392    #[test]
393    fn guardkey_null_is_its_own_bucket() {
394        // Predicate identity is null==null (unlike join equality), so a `col IS NULL`
395        // guard and a null row cell must meet in the same bucket.
396        assert_eq!(GuardKey(OwnedValue::Null), GuardKey(OwnedValue::Null));
397        assert_ne!(GuardKey(OwnedValue::Null), GuardKey(OwnedValue::Int(0)));
398    }
399
400    // -- PushIndex maintenance & the exactly-once invariant (design §Testing.2) ----
401
402    /// Count every appearance of `slot` across `eq` buckets + `scan`. The invariant
403    /// is: a live slot appears in exactly one *place* (its guard column, once per
404    /// guard value) or in `scan`; a never-matching guard appears nowhere.
405    fn places(idx: &PushIndex, slot: u32) -> (usize, bool) {
406        let eq_hits: usize = idx
407            .eq
408            .values()
409            .flat_map(|m| m.values())
410            .flat_map(|v| v.iter())
411            .filter(|&&s| s == slot)
412            .count();
413        (eq_hits, idx.scan.contains(&slot))
414    }
415
416    #[test]
417    fn insert_guarded_then_remove_is_empty() {
418        let mut idx = PushIndex::new();
419        let g = guard(0, vec![iv(42)]);
420        idx.insert(3, Some(&g), &[]);
421        assert_eq!(places(&idx, 3), (1, false));
422        idx.remove(3, Some(&g), &[]);
423        assert_eq!(places(&idx, 3), (0, false));
424        // Column map pruned to empty.
425        assert!(idx.eq.is_empty(), "empty column map should be pruned");
426    }
427
428    #[test]
429    fn insert_scan_then_remove() {
430        let mut idx = PushIndex::new();
431        idx.insert(5, None, &[]);
432        assert_eq!(places(&idx, 5), (0, true));
433        idx.remove(5, None, &[]);
434        assert_eq!(places(&idx, 5), (0, false));
435    }
436
437    #[test]
438    fn multi_value_guard_registers_once_per_value() {
439        let mut idx = PushIndex::new();
440        let g = guard(1, vec![iv(1), iv(2), iv(3)]); // col IN (1,2,3)
441        idx.insert(9, Some(&g), &[]);
442        let hits = places(&idx, 9).0;
443        assert_eq!(hits, 3, "one appearance per guard value");
444        idx.remove(9, Some(&g), &[]);
445        assert_eq!(places(&idx, 9), (0, false));
446        assert!(idx.eq.is_empty());
447    }
448
449    #[test]
450    fn empty_values_guard_indexes_nowhere() {
451        // `col = NULL` / `col IN ()` fold to never-matching: registered nowhere.
452        let mut idx = PushIndex::new();
453        let g = guard(0, vec![]);
454        idx.insert(2, Some(&g), &[]);
455        assert_eq!(places(&idx, 2), (0, false));
456        assert!(idx.eq.is_empty() && idx.scan.is_empty());
457        // Remove is a clean no-op.
458        idx.remove(2, Some(&g), &[]);
459        assert_eq!(places(&idx, 2), (0, false));
460    }
461
462    #[test]
463    fn recycle_slot_reassigns_registration() {
464        // destroy un-indexes -> slot recycled by a later connect under a new guard.
465        let mut idx = PushIndex::new();
466        let g_a = guard(0, vec![iv(10)]);
467        idx.insert(4, Some(&g_a), &[]);
468        idx.remove(4, Some(&g_a), &[]); // teardown
469        let g_b = guard(1, vec![iv(20)]); // new tenant, different column
470        idx.insert(4, Some(&g_b), &[]);
471        assert_eq!(places(&idx, 4).0, 1);
472        // It lives under the NEW column/value, not the old.
473        assert!(idx.eq.get(&1).unwrap().contains_key(&GuardKey(iv(20))));
474        assert!(!idx.eq.contains_key(&0), "old bucket fully pruned");
475    }
476
477    #[test]
478    fn split_keys_refcount_drains_to_empty() {
479        let mut idx = PushIndex::new();
480        idx.insert(0, None, &[2, 5]);
481        idx.insert(1, None, &[5]); // 5 now refcount 2
482        let mut keys: Vec<_> = idx.split_keys().collect();
483        keys.sort_unstable();
484        assert_eq!(keys, vec![2, 5]);
485        assert!(idx.has_split_keys());
486        idx.remove(0, None, &[2, 5]); // 2 -> 0 (drop), 5 -> 1
487        let keys: Vec<_> = idx.split_keys().collect();
488        assert_eq!(keys, vec![5]);
489        idx.remove(1, None, &[5]); // 5 -> 0 (drop)
490        assert!(!idx.has_split_keys());
491        assert_eq!(idx.split_keys().count(), 0);
492    }
493
494    // -- push_candidates: superset, sorted, deduped (design §Testing.2) -----------
495
496    #[test]
497    fn candidates_are_sorted_and_deduped() {
498        let mut idx = PushIndex::new();
499        // Two connections guarded on col 0 = 42, plus a scan connection.
500        idx.insert(7, Some(&guard(0, vec![iv(42)])), &[]);
501        idx.insert(3, Some(&guard(0, vec![iv(42)])), &[]);
502        idx.insert(1, None, &[]);
503        let cand = idx.push_candidates(&add(owned_row(vec![iv(42)])));
504        assert_eq!(cand, vec![1, 3, 7], "scan + both guarded, sorted ascending");
505    }
506
507    #[test]
508    fn candidates_exclude_non_matching_guard() {
509        let mut idx = PushIndex::new();
510        idx.insert(7, Some(&guard(0, vec![iv(42)])), &[]);
511        idx.insert(1, None, &[]);
512        // Write to a different value: only the scan connection is a candidate.
513        let cand = idx.push_candidates(&add(owned_row(vec![iv(99)])));
514        assert_eq!(cand, vec![1]);
515    }
516
517    #[test]
518    fn candidates_edit_is_two_sided() {
519        // An edit that moves col 0 from 42 -> 99 must visit BOTH the 42-guarded and
520        // the 99-guarded connection (leaves one guard set, enters another).
521        let mut idx = PushIndex::new();
522        idx.insert(2, Some(&guard(0, vec![iv(42)])), &[]);
523        idx.insert(5, Some(&guard(0, vec![iv(99)])), &[]);
524        let change = SourceChange::Edit {
525            row: owned_row(vec![iv(99)]),
526            old: owned_row(vec![iv(42)]),
527        };
528        assert_eq!(idx.push_candidates(&change), vec![2, 5]);
529    }
530
531    #[test]
532    fn candidates_edit_same_bucket_not_duplicated() {
533        // Both sides of the edit hit the same bucket -> the slot appears once.
534        let mut idx = PushIndex::new();
535        idx.insert(4, Some(&guard(0, vec![iv(42)])), &[]);
536        let change = SourceChange::Edit {
537            row: owned_row(vec![iv(42), iv(1)]),
538            old: owned_row(vec![iv(42), iv(2)]),
539        };
540        assert_eq!(idx.push_candidates(&change), vec![4]);
541    }
542
543    #[test]
544    fn candidates_multi_column_guards() {
545        // Guards on two different columns; a write hits only the matching column.
546        let mut idx = PushIndex::new();
547        idx.insert(1, Some(&guard(0, vec![iv(42)])), &[]); // projectId = 42
548        idx.insert(2, Some(&guard(1, vec![iv(7)])), &[]); // ownerId = 7
549
550        // Row: col0 = 42, col1 = 999 -> matches conn 1's guard only.
551        let cand = idx.push_candidates(&add(owned_row(vec![iv(42), iv(999)])));
552        assert_eq!(cand, vec![1]);
553    }
554
555    #[test]
556    fn candidates_int_float_coarsening_is_a_superset() {
557        // A guard registered under Int(7); a row cell of Float(7.0) must still be a
558        // candidate (the predicate treats them identical).
559        let mut idx = PushIndex::new();
560        idx.insert(6, Some(&guard(0, vec![iv(7)])), &[]);
561        let cand = idx.push_candidates(&add(owned_row(vec![OwnedValue::Float(7.0)])));
562        assert_eq!(cand, vec![6]);
563    }
564
565    #[test]
566    fn candidates_null_guard_matches_null_cell() {
567        let mut idx = PushIndex::new();
568        idx.insert(8, Some(&guard(0, vec![OwnedValue::Null])), &[]); // col IS NULL
569        let cand = idx.push_candidates(&add(owned_row(vec![OwnedValue::Null])));
570        assert_eq!(cand, vec![8]);
571        // A non-null cell does not hit the null bucket.
572        let miss = idx.push_candidates(&add(owned_row(vec![iv(1)])));
573        assert!(miss.is_empty());
574    }
575
576    // -- dynamic guard values (design 310 §4.1 / S2) -------------------------------
577
578    #[test]
579    fn dynamic_values_index_and_unindex_one_at_a_time() {
580        let mut idx = PushIndex::new();
581        // A family root registers with an EMPTY static guard: indexed nowhere.
582        let g = guard(0, vec![]);
583        idx.insert(3, Some(&g), &[]);
584        assert_eq!(places(&idx, 3), (0, false));
585        assert!(idx.push_candidates(&add(owned_row(vec![iv(7)]))).is_empty());
586
587        idx.add_guard_value(3, 0, iv(7));
588        assert_eq!(places(&idx, 3), (1, false));
589        assert_eq!(idx.push_candidates(&add(owned_row(vec![iv(7)]))), vec![3]);
590        assert!(idx.push_candidates(&add(owned_row(vec![iv(8)]))).is_empty());
591        // Int/Float coarsening holds for dynamic values too.
592        assert_eq!(
593            idx.push_candidates(&add(owned_row(vec![OwnedValue::Float(7.0)]))),
594            vec![3]
595        );
596
597        idx.add_guard_value(3, 0, iv(9));
598        assert_eq!(places(&idx, 3), (2, false));
599        idx.remove_guard_value(3, 0, &iv(7));
600        assert!(idx.push_candidates(&add(owned_row(vec![iv(7)]))).is_empty());
601        assert_eq!(idx.push_candidates(&add(owned_row(vec![iv(9)]))), vec![3]);
602        idx.remove_guard_value(3, 0, &iv(9));
603        assert_eq!(places(&idx, 3), (0, false));
604        assert!(
605            idx.eq.is_empty(),
606            "empty buckets and column maps are pruned"
607        );
608        assert!(idx.dynamic.is_empty());
609        assert_eq!(idx.size(), 0);
610        // Removing a value that was never added is a no-op.
611        idx.remove_guard_value(3, 0, &iv(42));
612    }
613
614    #[test]
615    fn repeated_dynamic_values_stack_and_unstack() {
616        // Two bindings sharing a first-column value: the slot stays indexed until
617        // BOTH are removed (a wrong "gone" here would drop a delta).
618        let mut idx = PushIndex::new();
619        idx.insert(5, Some(&guard(1, vec![])), &[]);
620        idx.add_guard_value(5, 1, iv(1));
621        idx.add_guard_value(5, 1, iv(1));
622        assert_eq!(places(&idx, 5).0, 2);
623        idx.remove_guard_value(5, 1, &iv(1));
624        assert_eq!(
625            idx.push_candidates(&add(owned_row(vec![iv(0), iv(1)]))),
626            vec![5],
627            "one binding still shares the value"
628        );
629        idx.remove_guard_value(5, 1, &iv(1));
630        assert!(idx
631            .push_candidates(&add(owned_row(vec![iv(0), iv(1)])))
632            .is_empty());
633    }
634
635    #[test]
636    fn remove_drains_dynamic_values_and_recycles_cleanly() {
637        // `destroy` un-indexes from the STATIC (empty) guard; the dynamic map must be
638        // drained too, or a recycled slot inherits stale buckets.
639        let mut idx = PushIndex::new();
640        let g = guard(0, vec![]);
641        idx.insert(4, Some(&g), &[]);
642        idx.add_guard_value(4, 0, iv(10));
643        idx.add_guard_value(4, 0, iv(11));
644        idx.remove(4, Some(&g), &[]);
645        assert_eq!(places(&idx, 4), (0, false));
646        assert!(idx.eq.is_empty() && idx.dynamic.is_empty());
647        assert_eq!(idx.size(), 0);
648        // The recycled slot starts clean under a new tenant.
649        idx.insert(4, Some(&guard(1, vec![iv(20)])), &[]);
650        assert!(idx
651            .push_candidates(&add(owned_row(vec![iv(10), iv(0)])))
652            .is_empty());
653        assert_eq!(
654            idx.push_candidates(&add(owned_row(vec![iv(0), iv(20)]))),
655            vec![4]
656        );
657    }
658
659    /// Every add/remove sequence up to four deep over three family-root slots and two
660    /// guard values, exhaustively: after each step, for every cell value the push
661    /// candidates of an `Add` (and a `Remove`) carrying it are exactly the slots holding
662    /// at least one live copy of that value — the refcount, not the last add or the first
663    /// remove — an `Edit` between two values is the union, the size probe counts every
664    /// live copy twice, and an index with no live copies is fully pruned. A slot whose last
665    /// copy of a value is removed while a sibling binding still shares it stays a candidate:
666    /// the wrong "gone" there is a dropped delta (design 310 §4.1).
667    ///
668    /// The dynamic guard's bounded model check as a walk of every sequence rather than a
669    /// Kani harness: the domain is finite, the code is safe Rust over B-trees, and a
670    /// harness of a smaller bound could not finish inside the Kani lane's budget.
671    #[test]
672    fn dynamic_guard_candidates_equal_live_values_after_every_bounded_sequence() {
673        const SLOTS: u32 = 3;
674        const VALS: usize = 2;
675        const DEPTH: usize = 4;
676        type Counts = [[u8; VALS]; SLOTS as usize];
677
678        fn live(counts: &Counts, v: usize) -> Vec<u32> {
679            (0..SLOTS).filter(|s| counts[*s as usize][v] > 0).collect()
680        }
681
682        fn check(idx: &PushIndex, counts: &Counts, rows: &[OwnedRow]) {
683            for (v, row) in rows.iter().enumerate() {
684                let expected = live(counts, v);
685                assert_eq!(
686                    idx.push_candidates(&add(row.clone())),
687                    expected,
688                    "{counts:?}"
689                );
690                assert_eq!(
691                    idx.push_candidates(&SourceChange::Remove(row.clone())),
692                    expected,
693                    "{counts:?}"
694                );
695            }
696            for (a, new) in rows.iter().enumerate() {
697                for (b, old) in rows.iter().enumerate() {
698                    let mut expected = live(counts, a);
699                    expected.extend(live(counts, b));
700                    expected.sort_unstable();
701                    expected.dedup();
702                    let edit = SourceChange::Edit {
703                        row: new.clone(),
704                        old: old.clone(),
705                    };
706                    assert_eq!(idx.push_candidates(&edit), expected, "{counts:?}");
707                }
708            }
709            let copies: usize = counts.iter().flatten().map(|c| *c as usize).sum();
710            assert_eq!(idx.size(), 2 * copies, "{counts:?}");
711            if copies == 0 {
712                assert!(idx.eq.is_empty() && idx.dynamic.is_empty(), "{counts:?}");
713            }
714        }
715
716        fn explore(idx: &PushIndex, counts: &mut Counts, depth: usize, rows: &[OwnedRow]) -> usize {
717            check(idx, counts, rows);
718            if depth == 0 {
719                return 1;
720            }
721            let mut visited = 1;
722            for slot in 0..SLOTS {
723                for v in 0..VALS {
724                    let mut next = idx.clone();
725                    next.add_guard_value(slot, 0, iv(v as i64));
726                    counts[slot as usize][v] += 1;
727                    visited += explore(&next, counts, depth - 1, rows);
728                    counts[slot as usize][v] -= 1;
729
730                    let mut next = idx.clone();
731                    next.remove_guard_value(slot, 0, &iv(v as i64));
732                    let had = counts[slot as usize][v];
733                    counts[slot as usize][v] = had.saturating_sub(1);
734                    visited += explore(&next, counts, depth - 1, rows);
735                    counts[slot as usize][v] = had;
736                }
737            }
738            visited
739        }
740
741        let mut idx = PushIndex::new();
742        let root = guard(0, vec![]);
743        for slot in 0..SLOTS {
744            idx.insert(slot, Some(&root), &[]);
745        }
746        let rows: Vec<OwnedRow> = (0..VALS).map(|v| owned_row(vec![iv(v as i64)])).collect();
747        let visited = explore(&idx, &mut [[0; VALS]; SLOTS as usize], DEPTH, &rows);
748        let branching = 2 * SLOTS as usize * VALS;
749        let expected: usize = (0..=DEPTH as u32).map(|d| branching.pow(d)).sum();
750        assert_eq!(visited, expected);
751    }
752
753    #[test]
754    fn candidates_empty_index_is_empty() {
755        let idx = PushIndex::new();
756        assert!(idx.push_candidates(&add(owned_row(vec![iv(1)]))).is_empty());
757    }
758}