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}