Skip to main content

core_rules/
index.rs

1use crate::def::Predicate;
2use crate::hnsw::HnswIndex;
3use core_storage::{list_tokens, Value, ValueKey};
4use std::collections::{BTreeMap, BTreeSet};
5
6// ---------------------------------------------------------------------------
7// IVF-Flat constants (Plan 11 T4)
8// ---------------------------------------------------------------------------
9
10/// Minimum number of k-means clusters. Keeps probing meaningful even for
11/// small sides (< 16 vectors).
12pub const IVF_K_MIN: usize = 4;
13
14/// Maximum number of k-means clusters. Bounds centroid memory and fit time.
15pub const IVF_K_MAX: usize = 1024;
16
17/// Fixed number of k-means iterations per fit (deterministic convergence).
18pub const IVF_ITERATIONS: usize = 12;
19
20/// Probe denominator: P = max(1, ceil(k / IVF_PROBE_DENOM)) centroids queried
21/// per lookup.  k=4 → P=1; k=64 → P=4; k=1024 → P=64.
22pub const IVF_PROBE_DENOM: usize = 16;
23
24/// Rebuild an approximate rule when dst-side IVF drift exceeds this count.
25/// Drift is only known after apply, so the WAL path issues `RebuildRule` as a
26/// second commit (not a pre-WAL Batch).
27pub const IVF_DRIFT_REBUILD: u64 = 256;
28
29thread_local! {
30    static IVF_DRIFT_REBUILD_OVERRIDE: std::cell::Cell<Option<u64>> =
31        const { std::cell::Cell::new(None) };
32}
33
34pub(crate) fn ivf_drift_rebuild_threshold() -> u64 {
35    IVF_DRIFT_REBUILD_OVERRIDE.with(|c| c.get().unwrap_or(IVF_DRIFT_REBUILD))
36}
37
38/// Run `f` with a temporary IVF dst-drift rebuild threshold.
39/// Restores the previous override (including across panics).
40pub fn with_ivf_drift_rebuild<R>(threshold: u64, f: impl FnOnce() -> R) -> R {
41    IVF_DRIFT_REBUILD_OVERRIDE.with(|c| {
42        let prev = c.replace(Some(threshold));
43        let out = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
44        c.set(prev);
45        match out {
46            Ok(v) => v,
47            Err(p) => std::panic::resume_unwind(p),
48        }
49    })
50}
51
52/// k = ceil(sqrt(n)) clamped to [IVF_K_MIN, IVF_K_MAX].
53pub fn cluster_k(n: usize) -> usize {
54    if n == 0 {
55        return IVF_K_MIN;
56    }
57    let k = (n as f64).sqrt().ceil() as usize;
58    k.clamp(IVF_K_MIN, IVF_K_MAX)
59}
60
61/// P = max(1, ceil(k / IVF_PROBE_DENOM)).
62pub fn probe_count(k: usize) -> usize {
63    k.div_ceil(IVF_PROBE_DENOM).max(1)
64}
65
66/// L2-normalize `xs`. Returns `None` for the zero vector (skipped, not clustered).
67fn l2_normalize(xs: &[f64]) -> Option<Vec<f64>> {
68    let n = xs.iter().map(|x| x * x).sum::<f64>().sqrt();
69    if n == 0.0 {
70        return None;
71    }
72    Some(xs.iter().map(|x| x / n).collect())
73}
74
75/// Squared Euclidean distance between two equal-length slices.
76/// Returns `f64::MAX` on dimension mismatch so callers always have a valid order.
77fn l2_sq(a: &[f64], b: &[f64]) -> f64 {
78    if a.len() != b.len() {
79        return f64::MAX;
80    }
81    a.iter().zip(b.iter()).map(|(x, y)| (x - y) * (x - y)).sum()
82}
83
84/// Index of the nearest centroid to `xs` by L2 distance (minimum squared).
85/// Returns 0 when `centroids` is empty.
86pub fn nearest_centroid(centroids: &[Vec<f64>], xs: &[f64]) -> usize {
87    centroids
88        .iter()
89        .enumerate()
90        .min_by(|(_, a), (_, b)| {
91            l2_sq(xs, a)
92                .partial_cmp(&l2_sq(xs, b))
93                .unwrap_or(std::cmp::Ordering::Equal)
94        })
95        .map(|(i, _)| i)
96        .unwrap_or(0)
97}
98
99/// FNV-1a 64-bit hash — stable, documented, NOT DefaultHasher.
100/// Used to seed k-means so the same rule name always produces the same
101/// clusters on the same data (WAL replay identity).
102pub fn fnv1a_u64(data: &[u8]) -> u64 {
103    const FNV_OFFSET: u64 = 14_695_981_039_346_656_037;
104    const FNV_PRIME: u64 = 1_099_511_628_211;
105    let mut h = FNV_OFFSET;
106    for &b in data {
107        h ^= b as u64;
108        h = h.wrapping_mul(FNV_PRIME);
109    }
110    h
111}
112
113/// Seeded LCG step — Knuth multiplicative; used for centroid init and empty
114/// cluster reseeding.
115#[inline]
116fn lcg_next(state: u64) -> u64 {
117    state
118        .wrapping_mul(6_364_136_223_846_793_005)
119        .wrapping_add(1_442_695_040_888_963_407)
120}
121
122/// Fit k-means over `vecs` (node_id, vector) pairs.
123///
124/// - Each vector is L2-normalized before clustering (zero vectors skipped).
125/// - `k` is clamped to `min(k, vecs.len())` so we never request more centroids
126///   than vectors.
127/// - Centroids are initialised by seeded LCG selection without replacement.
128/// - 12 iterations; empty clusters are deterministically reseeded from the
129///   full dataset.
130/// - Returns a `Vec<Vec<f64>>` of k centroids (same length as `xs` entries).
131pub fn kmeans_fit(vecs: &[(u32, Vec<f64>)], k: usize, seed: u64) -> Vec<Vec<f64>> {
132    let vecs: Vec<(u32, Vec<f64>)> = vecs
133        .iter()
134        .filter_map(|(id, xs)| l2_normalize(xs).map(|n| (*id, n)))
135        .collect();
136    if vecs.is_empty() || k == 0 {
137        return vec![];
138    }
139    let n = vecs.len();
140    let k = k.min(n);
141    let dim = vecs[0].1.len();
142    if dim == 0 {
143        return vec![];
144    }
145
146    // --- Centroid initialisation: pick k distinct indices via seeded LCG ---
147    let mut state = seed;
148    let mut used = vec![false; n];
149    let mut init_idxs: Vec<usize> = Vec::with_capacity(k);
150    let mut attempts = 0usize;
151    while init_idxs.len() < k && attempts < n * 4 {
152        state = lcg_next(state);
153        let idx = (state >> 33) as usize % n;
154        if !used[idx] {
155            used[idx] = true;
156            init_idxs.push(idx);
157        }
158        attempts += 1;
159    }
160    // If LCG didn't yield k distinct indices (pathological: n very small or
161    // many collisions), fill sequentially.
162    if init_idxs.len() < k {
163        for (i, in_use) in used.iter().enumerate().take(n) {
164            if !in_use {
165                init_idxs.push(i);
166                if init_idxs.len() == k {
167                    break;
168                }
169            }
170        }
171    }
172    let mut centroids: Vec<Vec<f64>> = init_idxs.iter().map(|&i| vecs[i].1.clone()).collect();
173    let mut assignments = vec![0usize; n];
174
175    // --- k-means iterations ---
176    for iter in 0..IVF_ITERATIONS {
177        // Assignment step
178        for (j, (_, xs)) in vecs.iter().enumerate() {
179            assignments[j] = nearest_centroid(&centroids, xs);
180        }
181
182        // Update step: accumulate sums and counts
183        let mut sums = vec![vec![0.0f64; dim]; k];
184        let mut counts = vec![0usize; k];
185        for (j, (_, xs)) in vecs.iter().enumerate() {
186            let c = assignments[j];
187            counts[c] += 1;
188            for d in 0..dim {
189                sums[c][d] += xs[d];
190            }
191        }
192
193        // Compute new centroids; collect empty ones for reseed
194        let mut new_centroids = vec![vec![0.0f64; dim]; k];
195        let mut empty: Vec<usize> = Vec::new();
196        for c in 0..k {
197            if counts[c] == 0 {
198                empty.push(c);
199            } else {
200                for d in 0..dim {
201                    new_centroids[c][d] = sums[c][d] / counts[c] as f64;
202                }
203            }
204        }
205
206        // Deterministic empty-cluster reseed: pick a vector from the dataset
207        // seeded by (original seed XOR iteration XOR empty-cluster-index).
208        for (ei, ec) in empty.into_iter().enumerate() {
209            let reseed =
210                seed ^ (iter as u64).wrapping_mul(0x9E37) ^ (ei as u64).wrapping_mul(0x1234_5679);
211            let mut rs = lcg_next(reseed);
212            rs = lcg_next(rs);
213            let pick = (rs >> 33) as usize % n;
214            new_centroids[ec] = vecs[pick].1.clone();
215        }
216
217        centroids = new_centroids;
218    }
219
220    centroids
221}
222
223#[cfg(test)]
224thread_local! {
225    static VECTOR_DIM_REJECT: std::cell::Cell<bool> = const { std::cell::Cell::new(true) };
226    static VECTOR_EARLY_EXIT: std::cell::Cell<bool> = const { std::cell::Cell::new(true) };
227}
228
229fn vector_dim_reject_enabled() -> bool {
230    #[cfg(test)]
231    {
232        VECTOR_DIM_REJECT.with(|c| c.get())
233    }
234    #[cfg(not(test))]
235    {
236        true
237    }
238}
239
240pub(crate) fn vector_early_exit_enabled() -> bool {
241    #[cfg(test)]
242    {
243        VECTOR_EARLY_EXIT.with(|c| c.get())
244    }
245    #[cfg(not(test))]
246    {
247        true
248    }
249}
250
251/// Force the ScanAll dim fast-reject on or off. Identity-proof hook.
252#[cfg(test)]
253pub fn with_vector_dim_reject<R>(enabled: bool, f: impl FnOnce() -> R) -> R {
254    VECTOR_DIM_REJECT.with(|c| {
255        let prev = c.replace(enabled);
256        let out = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
257        c.set(prev);
258        match out {
259            Ok(v) => v,
260            Err(p) => std::panic::resume_unwind(p),
261        }
262    })
263}
264
265/// Force the checkpointed Cauchy-Schwarz early-exit on or off. Identity-proof hook.
266#[cfg(test)]
267pub fn with_vector_early_exit<R>(enabled: bool, f: impl FnOnce() -> R) -> R {
268    VECTOR_EARLY_EXIT.with(|c| {
269        let prev = c.replace(enabled);
270        let out = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
271        c.set(prev);
272        match out {
273            Ok(v) => v,
274            Err(p) => std::panic::resume_unwind(p),
275        }
276    })
277}
278
279#[derive(Debug, Default)]
280pub struct SideIndex {
281    by_key: BTreeMap<ValueKey, BTreeSet<u32>>,
282    /// Per-node `(dim, L2 norm)` for `ScanAll` members. Maintained by the
283    /// same insert/remove choke-points as `by_key`. Cosine still reads live
284    /// props; `dim` is a fast-reject; `norm` is the primary freshness gate for
285    /// the checkpointed Cauchy-Schwarz early-exit (Plan 11 T3).
286    vec_meta: BTreeMap<u32, (u32, f64)>,
287    /// Per-node checkpointed suffix norms for the Cauchy-Schwarz early-exit.
288    /// `ckpts[i]` = L2 norm of `xs[i * dim / 8 ..]`.
289    /// `ckpts[0]` = full L2 norm; `ckpts[7]` = norm of the last eighth.
290    /// Built at index-insert, torn out at index-remove — maintained in lockstep
291    /// with `vec_meta` by the same choke-points.
292    /// Memory: 8 × 8 = 64 bytes per indexed vector (6.4 MB at 100k vectors).
293    vec_checkpoints: BTreeMap<u32, [f64; 8]>,
294    /// Per-node first element (`xs[0]`) for heuristic permutation detection.
295    /// A permuted vector can share `(dim, norm)` with the indexed one but
296    /// differs at `xs[0]` in virtually all realistic cases, so comparing this
297    /// one extra f64 (8 bytes per vector) breaks same-norm permutation aliasing
298    /// cheaply.  This is heuristic hardening — not a proof — but eliminates
299    /// the energy-distribution construction identified in the Plan 11 T3 review.
300    vec_anchor: BTreeMap<u32, f64>,
301
302    // --- IVF-Flat fields (Plan 11 T4; only populated for VectorClusters specs) ---
303    /// Raw vectors stored for IVF fitting and assignment-on-insert.
304    /// Populated at insert-time, torn out at remove-time.
305    /// Memory: O(n × dim) per indexed side — present only for approximate rules.
306    ivf_raw: BTreeMap<u32, Vec<f64>>,
307    /// Fitted k-means centroids (empty until after first `fit_ivf_clusters` call).
308    ivf_centroids: Vec<Vec<f64>>,
309    /// Per-node cluster assignment post-fit.
310    /// `by_key[ivf_cluster_key(cluster)] → {node_ids}`.
311    ivf_clusters: BTreeMap<u32, usize>,
312    /// Count of vector inserts/removes since last fit.  When dst-side drift
313    /// exceeds [`IVF_DRIFT_REBUILD`] on an approximate rule, apply queues a
314    /// `RebuildRule` second commit (fit resets this to zero).
315    pub ivf_drift: u64,
316
317    // --- HNSW fields (default for approximate: true + VectorSimilar) ---
318    /// HNSW graph; `None` until `init_hnsw` is called.
319    hnsw: Option<HnswIndex>,
320    /// All node ids inserted via `CandidateSpec::Hnsw`.
321    /// Used as a full-scan fallback when `hnsw` is `None` or has no entry point.
322    hnsw_tracked: BTreeSet<u32>,
323}
324
325#[derive(Debug, Default)]
326pub struct RuleIndex {
327    pub src_side: SideIndex,
328    pub dst_side: SideIndex,
329}
330
331#[derive(Debug)]
332pub enum CandidateSpec<'a> {
333    ByKey,
334    Scalar {
335        field: &'a str,
336    },
337    Tokens {
338        field: &'a str,
339    },
340    NumericBucket {
341        field: &'a str,
342        tolerance: f64,
343    },
344    GeoGrid {
345        field: &'a str,
346        km: f64,
347    },
348    ScanAll {
349        field: &'a str,
350    },
351    /// IVF-Flat approximate candidate selection (legacy; still supported as
352    /// direct fallback — no longer the default for `approximate: true`).
353    ///
354    /// k-means fitted over the indexed side's vectors; candidates are members
355    /// of the P = `max(1, ceil(k/16))` nearest centroids to the query vector.
356    /// NOT a superset of true positives — recall floor governs correctness.
357    VectorClusters {
358        field: &'a str,
359        min: f64,
360    },
361    /// HNSW approximate candidate selection (default for `approximate: true`).
362    ///
363    /// Returns the `k` nearest vectors by cosine similarity from the in-tree
364    /// HNSW graph.  Falls back to returning all tracked nodes when the graph
365    /// has no entry point (e.g. before any node is inserted, or when used
366    /// without calling `init_hnsw`).
367    Hnsw {
368        field: &'a str,
369        /// Number of approximate candidates to return; typically
370        /// `max(max_edges, 64)` from the owning `RuleDef`.
371        k: usize,
372    },
373    /// Union of multiple candidate specs, used for `Any` predicates.
374    ///
375    /// Each branch of the `Any` predicate contributes its own candidate set
376    /// (key index, token index, numeric bucket, etc.); the resulting candidate
377    /// set is their union.  Insert and remove recurse into every child spec so
378    /// the index stays coherent for all branches simultaneously.
379    Union(Vec<CandidateSpec<'a>>),
380    /// Intersection of multiple candidate specs, used for `All` predicates.
381    ///
382    /// Each conjunct contributes its own candidate set; the result is their
383    /// intersection (empty child → empty). `ScanAll` children are skipped at
384    /// probe time (they are the universe); if every child is `ScanAll`, the
385    /// spec stays a full scan. Insert/remove recurse into every child.
386    Intersect(Vec<CandidateSpec<'a>>),
387}
388
389/// Returns the exact candidate strategy derived from `p`.
390///
391/// `All(parts)` returns `Intersect` of each part's spec. A leading
392/// `VectorSimilar` is `ScanAll` and is skipped at probe time when another
393/// conjunct has an index; candidates stay a superset of true matches.
394///
395/// `Any(parts)` returns `Union` of each branch's candidate spec — the correct
396/// superset for OR semantics.
397///
398/// # Panics
399///
400/// Panics on `All([])` or `Any([])`. Predicates must pass `RuleDef::validate()` first.
401pub fn candidate_spec(p: &Predicate) -> CandidateSpec<'_> {
402    match p {
403        Predicate::KeyMatch { .. } => CandidateSpec::ByKey,
404        Predicate::FieldEqual { field } => CandidateSpec::Scalar { field },
405        Predicate::Overlap { field, .. } => CandidateSpec::Tokens { field },
406        Predicate::NumericWithin { field, tolerance } => CandidateSpec::NumericBucket {
407            field,
408            tolerance: *tolerance,
409        },
410        Predicate::GeoRadius { field, km } => CandidateSpec::GeoGrid { field, km: *km },
411        Predicate::VectorSimilar { field, .. } => CandidateSpec::ScanAll { field },
412        Predicate::All(parts) => {
413            debug_assert!(
414                !parts.is_empty(),
415                "candidate_spec requires a validated predicate"
416            );
417            CandidateSpec::Intersect(parts.iter().map(candidate_spec).collect())
418        }
419        Predicate::Any(parts) => {
420            debug_assert!(
421                !parts.is_empty(),
422                "candidate_spec requires a validated predicate"
423            );
424            CandidateSpec::Union(parts.iter().map(candidate_spec).collect())
425        }
426    }
427}
428
429/// Approximate candidate strategy: like `candidate_spec` but replaces
430/// `ScanAll` with `CandidateSpec::Hnsw` for `VectorSimilar`-rooted predicates.
431///
432/// Used when `RuleDef::approximate == true`.  `All` is `Intersect` of each
433/// child's approx spec (not `parts[0]`), so `FieldEqual` / `NumericWithin`
434/// conjuncts still probe their indexes.
435///
436/// `k` is the number of HNSW candidates to return; callers should use
437/// `max(max_edges, 64)` from the owning `RuleDef`.  Use the public
438/// zero-argument wrapper (`candidate_spec_approx`) for tests that don't
439/// need a specific k (defaults to 64).
440///
441/// `Any` predicates cannot be `approximate=true` (validate() rejects them),
442/// so `Any` falls through to `candidate_spec` (exact Union path).
443///
444/// # Panics
445///
446/// Panics on `All([])` or `Any([])`. Predicates must pass `RuleDef::validate()` first.
447pub fn candidate_spec_approx(p: &Predicate) -> CandidateSpec<'_> {
448    candidate_spec_approx_with_k(p, 64)
449}
450
451/// Like `candidate_spec_approx` but with an explicit HNSW candidate count `k`.
452pub fn candidate_spec_approx_with_k(p: &Predicate, k: usize) -> CandidateSpec<'_> {
453    match p {
454        Predicate::VectorSimilar { field, .. } => CandidateSpec::Hnsw { field, k },
455        Predicate::All(parts) => {
456            debug_assert!(
457                !parts.is_empty(),
458                "candidate_spec_approx requires a validated predicate"
459            );
460            CandidateSpec::Intersect(
461                parts
462                    .iter()
463                    .map(|p| candidate_spec_approx_with_k(p, k))
464                    .collect(),
465            )
466        }
467        other => candidate_spec(other),
468    }
469}
470
471pub(crate) fn as_finite_f64(v: &Value) -> Option<f64> {
472    match v {
473        Value::Int(i) => Some(*i as f64),
474        Value::Float(f) if f.is_finite() => Some(*f),
475        _ => None,
476    }
477}
478
479fn as_latlon(v: &Value) -> Option<(f64, f64)> {
480    let Value::List(items) = v else {
481        return None;
482    };
483    if items.len() != 2 {
484        return None;
485    }
486    let lat = as_finite_f64(&items[0])?;
487    let lon = as_finite_f64(&items[1])?;
488    if (-90.0..=90.0).contains(&lat) && (-180.0..=180.0).contains(&lon) {
489        Some((lat, lon))
490    } else {
491        None
492    }
493}
494
495pub(crate) fn as_numeric_list(v: &Value) -> Option<Vec<f64>> {
496    let Value::List(items) = v else {
497        return None;
498    };
499    if items.is_empty() {
500        return None;
501    }
502    items.iter().map(as_finite_f64).collect()
503}
504
505fn vec_dim_norm(v: &Value) -> Option<(u32, f64)> {
506    let xs = as_numeric_list(v)?;
507    let mut n2 = 0.0;
508    for x in &xs {
509        n2 += *x * *x;
510    }
511    Some((xs.len() as u32, n2.sqrt()))
512}
513
514/// Checkpointed suffix norms for Cauchy-Schwarz early exit.
515///
516/// `ckpts[i]` = L2 norm of `xs[boundary(i)..]` where `boundary(i) = i * dim / 8`.
517/// `ckpts[0]` equals the full L2 norm; `ckpts[7]` is the last eighth's norm.
518/// Multiple checkpoints may share the same boundary for dim < 8 (correct but no-op).
519fn compute_ckpts(xs: &[f64]) -> [f64; 8] {
520    let dim = xs.len();
521    let mut ckpts = [0.0f64; 8];
522    if dim == 0 {
523        return ckpts;
524    }
525    // boundaries[i] = i * dim / 8 (integer division).
526    let boundaries: [usize; 8] = std::array::from_fn(|i| i * dim / 8);
527    let mut suffix_sq = 0.0f64;
528    // Walk right-to-left; ci is the highest checkpoint not yet recorded.
529    let mut ci = 7i32;
530    for j in (0..dim).rev() {
531        suffix_sq += xs[j] * xs[j];
532        // Assign all checkpoints whose boundary equals j.
533        while ci >= 0 && boundaries[ci as usize] == j {
534            ckpts[ci as usize] = suffix_sq.sqrt();
535            ci -= 1;
536        }
537    }
538    ckpts
539}
540
541fn floor_to_i64(x: f64) -> i64 {
542    let floored = x.floor();
543    if !floored.is_finite() {
544        return 0;
545    }
546    if floored >= i64::MAX as f64 {
547        i64::MAX
548    } else if floored <= i64::MIN as f64 {
549        i64::MIN
550    } else {
551        floored as i64
552    }
553}
554
555/// Two values within `tolerance` always land in adjacent buckets
556/// (`|floor(a/tol) − floor(b/tol)| ≤ 1`), so probing `{b−1, b, b+1}` is a
557/// superset of every evaluate-match.
558fn numeric_index_key(v: f64, tolerance: f64) -> Option<ValueKey> {
559    if !tolerance.is_finite() || tolerance < 0.0 {
560        return None;
561    }
562    if tolerance == 0.0 {
563        let v = if v == 0.0 { 0.0_f64 } else { v };
564        return Some(ValueKey::FloatBits(v.to_bits()));
565    }
566    Some(ValueKey::Int(floor_to_i64(v / tolerance)))
567}
568
569fn numeric_probe_keys(v: f64, tolerance: f64) -> BTreeSet<ValueKey> {
570    match numeric_index_key(v, tolerance) {
571        None => BTreeSet::new(),
572        Some(k @ ValueKey::FloatBits(_)) => BTreeSet::from([k]),
573        Some(ValueKey::Int(b)) => BTreeSet::from([
574            ValueKey::Int(b.saturating_sub(1)),
575            ValueKey::Int(b),
576            ValueKey::Int(b.saturating_add(1)),
577        ]),
578        Some(other) => BTreeSet::from([other]),
579    }
580}
581
582fn geo_cell(lat: f64, lon: f64, km: f64) -> Option<(i64, i64, f64, i64)> {
583    if !km.is_finite() || km <= 0.0 {
584        return None;
585    }
586    let cell_deg = (km / 111.0).max(1e-6);
587    let gx = floor_to_i64(lat / cell_deg);
588    // Longitude wraps; lat does not (validated range, no pole crossing
589    // within the supported |lat|≲87 envelope — see cos clamp below).
590    let lon_cells = (360.0 / cell_deg).ceil() as i64;
591    let lon_cells = lon_cells.max(1);
592    let gy = floor_to_i64(lon / cell_deg).rem_euclid(lon_cells);
593    Some((gx, gy, cell_deg, lon_cells))
594}
595
596fn geo_index_key(lat: f64, lon: f64, km: f64) -> Option<ValueKey> {
597    let (gx, gy, _, _) = geo_cell(lat, lon, km)?;
598    Some(ValueKey::Str(format!("{gx}|{gy}")))
599}
600
601fn geo_probe_keys(lat: f64, lon: f64, km: f64) -> BTreeSet<ValueKey> {
602    let Some((gx, gy, cell_deg, lon_cells)) = geo_cell(lat, lon, km) else {
603        return BTreeSet::new();
604    };
605    // Cos clamp keeps the probe a superset up to |lat| ≈ 87.
606    let cos_lat = lat.to_radians().cos().max(0.05);
607    let n = ((km / (111.0 * cos_lat)) / cell_deg).ceil();
608    let n = if n.is_finite() {
609        floor_to_i64(n).max(0)
610    } else {
611        0
612    };
613    let mut out = BTreeSet::new();
614    for dx in -1..=1 {
615        for dy in -n..=n {
616            let cx = gx.saturating_add(dx);
617            let cy = gy.saturating_add(dy).rem_euclid(lon_cells);
618            out.insert(ValueKey::Str(format!("{cx}|{cy}")));
619        }
620    }
621    out
622}
623
624/// Vector candidates are a deliberate full scan of opposite-side
625/// vector-bearing nodes; ANN is Plan 8+.
626const SCAN_ALL_SENTINEL: ValueKey = ValueKey::Bool(true);
627
628/// IVF cluster buckets in `by_key`. SOH prefix keeps them off the Int space
629/// used by `NumericBucket` / integer `FieldEqual` and off token/geo Str keys.
630fn ivf_cluster_key(cluster: usize) -> ValueKey {
631    ValueKey::Str(format!("\u{1}ivf:{cluster}"))
632}
633
634/// `ScanAll` is the universe in an `Intersect`: skip it when another child
635/// has an index. Nested `Intersect` of only `ScanAll` is itself a universe.
636fn spec_is_scan_all_universe(spec: &CandidateSpec<'_>) -> bool {
637    match spec {
638        CandidateSpec::ScanAll { .. } => true,
639        CandidateSpec::Intersect(parts) => {
640            !parts.is_empty() && parts.iter().all(spec_is_scan_all_universe)
641        }
642        _ => false,
643    }
644}
645
646/// `ByKey` is resolved by `compute_desired` (FK id lookup), not `by_key`.
647/// Nested `Intersect` of only `ByKey` is likewise external.
648fn spec_is_bykey_external(spec: &CandidateSpec<'_>) -> bool {
649    match spec {
650        CandidateSpec::ByKey => true,
651        CandidateSpec::Intersect(parts) => {
652            !parts.is_empty() && parts.iter().all(spec_is_bykey_external)
653        }
654        _ => false,
655    }
656}
657
658impl SideIndex {
659    fn index_keys(spec: &CandidateSpec, get: &dyn Fn(&str) -> Option<Value>) -> BTreeSet<ValueKey> {
660        match spec {
661            CandidateSpec::ByKey => BTreeSet::new(),
662            CandidateSpec::Scalar { field } => get(field)
663                .as_ref()
664                .and_then(ValueKey::from_value)
665                .into_iter()
666                .collect(),
667            CandidateSpec::Tokens { field } => get(field)
668                .as_ref()
669                .and_then(list_tokens)
670                .unwrap_or_default(),
671            CandidateSpec::NumericBucket { field, tolerance } => get(field)
672                .as_ref()
673                .and_then(as_finite_f64)
674                .and_then(|v| numeric_index_key(v, *tolerance))
675                .into_iter()
676                .collect(),
677            CandidateSpec::GeoGrid { field, km } => get(field)
678                .as_ref()
679                .and_then(as_latlon)
680                .and_then(|(lat, lon)| geo_index_key(lat, lon, *km))
681                .into_iter()
682                .collect(),
683            CandidateSpec::ScanAll { field } => get(field)
684                .as_ref()
685                .and_then(as_numeric_list)
686                .map(|_| SCAN_ALL_SENTINEL)
687                .into_iter()
688                .collect(),
689            // VectorClusters uses ivf_raw / ivf_clusters, not by_key. The
690            // insert() path returns early before reaching index_keys for this
691            // variant, so this arm is unreachable at runtime; it must be
692            // present to satisfy exhaustiveness.
693            CandidateSpec::VectorClusters { .. } => BTreeSet::new(),
694            // Hnsw uses the separate hnsw / hnsw_tracked fields, not by_key.
695            CandidateSpec::Hnsw { .. } => BTreeSet::new(),
696            // Union: each branch contributes its own index keys; the result is
697            // their union.  VectorClusters/Hnsw children are handled by the
698            // early-return in insert()/remove().
699            CandidateSpec::Union(specs) | CandidateSpec::Intersect(specs) => {
700                let mut out = BTreeSet::new();
701                for s in specs {
702                    out.extend(Self::index_keys(s, get));
703                }
704                out
705            }
706        }
707    }
708
709    fn probe_keys(spec: &CandidateSpec, get: &dyn Fn(&str) -> Option<Value>) -> BTreeSet<ValueKey> {
710        match spec {
711            CandidateSpec::ByKey | CandidateSpec::Scalar { .. } | CandidateSpec::Tokens { .. } => {
712                Self::index_keys(spec, get)
713            }
714            CandidateSpec::NumericBucket { field, tolerance } => get(field)
715                .as_ref()
716                .and_then(as_finite_f64)
717                .map(|v| numeric_probe_keys(v, *tolerance))
718                .unwrap_or_default(),
719            CandidateSpec::GeoGrid { field, km } => get(field)
720                .as_ref()
721                .and_then(as_latlon)
722                .map(|(lat, lon)| geo_probe_keys(lat, lon, *km))
723                .unwrap_or_default(),
724            CandidateSpec::ScanAll { field } => get(field)
725                .as_ref()
726                .and_then(as_numeric_list)
727                .map(|_| SCAN_ALL_SENTINEL)
728                .into_iter()
729                .collect(),
730            // VectorClusters probing is handled by ivf_candidates(), not probe_keys().
731            CandidateSpec::VectorClusters { .. } => BTreeSet::new(),
732            // Hnsw probing is handled by hnsw_candidates(), not probe_keys().
733            CandidateSpec::Hnsw { .. } => BTreeSet::new(),
734            // Union / Intersect: probe each child. `candidates()` intersects
735            // Intersect node-sets; mixing keys here is only for insert/remove.
736            CandidateSpec::Union(specs) | CandidateSpec::Intersect(specs) => {
737                let mut out = BTreeSet::new();
738                for s in specs {
739                    out.extend(Self::probe_keys(s, get));
740                }
741                out
742            }
743        }
744    }
745
746    pub fn insert(&mut self, spec: &CandidateSpec, node: u32, get: &dyn Fn(&str) -> Option<Value>) {
747        // Union / Intersect: recurse into each child spec. insert() is
748        // idempotent for ScanAll metadata (same-value overwrite).
749        if let CandidateSpec::Union(specs) | CandidateSpec::Intersect(specs) = spec {
750            for s in specs {
751                self.insert(s, node, get);
752            }
753            return;
754        }
755        // Hnsw: maintain hnsw_tracked for fallback, and hnsw graph if initialized.
756        if let CandidateSpec::Hnsw { field, .. } = spec {
757            if let Some(xs) = get(field).as_ref().and_then(as_numeric_list) {
758                self.hnsw_tracked.insert(node);
759                if let Some(h) = &mut self.hnsw {
760                    h.insert(node, &xs);
761                }
762            }
763            return;
764        }
765        // VectorClusters: IVF path — separate from the by_key / ScanAll path.
766        if let CandidateSpec::VectorClusters { field, .. } = spec {
767            if let Some(xs) = get(field).as_ref().and_then(as_numeric_list) {
768                self.ivf_raw.insert(node, xs.clone());
769                if !self.ivf_centroids.is_empty() {
770                    // Assign in cosine space (centroids are unit-norm). Skip zeros.
771                    if let Some(unit) = l2_normalize(&xs) {
772                        let c = nearest_centroid(&self.ivf_centroids, &unit);
773                        self.ivf_clusters.insert(node, c);
774                        self.by_key
775                            .entry(ivf_cluster_key(c))
776                            .or_default()
777                            .insert(node);
778                    }
779                    self.ivf_drift = self.ivf_drift.saturating_add(1);
780                }
781            }
782            return;
783        }
784
785        for k in Self::index_keys(spec, get) {
786            self.by_key.entry(k).or_default().insert(node);
787        }
788        if let CandidateSpec::ScanAll { field } = spec {
789            if let Some(xs) = get(field).as_ref().and_then(as_numeric_list) {
790                let mut n2 = 0.0f64;
791                for x in &xs {
792                    n2 += x * x;
793                }
794                let norm = n2.sqrt();
795                self.vec_meta.insert(node, (xs.len() as u32, norm));
796                self.vec_checkpoints.insert(node, compute_ckpts(&xs));
797                // xs is non-empty (as_numeric_list rejects empty lists).
798                self.vec_anchor.insert(node, xs[0]);
799            }
800        }
801    }
802
803    pub fn remove(&mut self, spec: &CandidateSpec, node: u32, get: &dyn Fn(&str) -> Option<Value>) {
804        // Union / Intersect: recurse into each child spec.
805        if let CandidateSpec::Union(specs) | CandidateSpec::Intersect(specs) = spec {
806            for s in specs {
807                self.remove(s, node, get);
808            }
809            return;
810        }
811        // Hnsw: remove from hnsw_tracked and hnsw graph.  Increment ivf_drift
812        // as a deletion counter so maybe_queue_ivf_rebuild fires at the same
813        // cadence it did for IVF rules; the resulting rebuild re-scans all nodes
814        // and optionally compacts the HNSW graph.
815        if let CandidateSpec::Hnsw { field, .. } = spec {
816            if get(field).as_ref().and_then(as_numeric_list).is_some() {
817                self.hnsw_tracked.remove(&node);
818                if let Some(h) = &mut self.hnsw {
819                    h.remove(node);
820                }
821                self.ivf_drift = self.ivf_drift.saturating_add(1);
822            }
823            return;
824        }
825        // VectorClusters: remove from ivf_raw and by_key cluster bucket.
826        // Removal shifts cluster membership (the centroid stays but its member set
827        // shrinks), which is a form of drift; increment the counter so callers can
828        // decide when to trigger a rebuild.
829        if let CandidateSpec::VectorClusters { .. } = spec {
830            if self.ivf_raw.remove(&node).is_some() {
831                self.ivf_drift = self.ivf_drift.saturating_add(1);
832                if let Some(c) = self.ivf_clusters.remove(&node) {
833                    let key = ivf_cluster_key(c);
834                    if let Some(s) = self.by_key.get_mut(&key) {
835                        s.remove(&node);
836                        if s.is_empty() {
837                            self.by_key.remove(&key);
838                        }
839                    }
840                }
841            }
842            return;
843        }
844
845        for k in Self::index_keys(spec, get) {
846            if let Some(set) = self.by_key.get_mut(&k) {
847                set.remove(&node);
848                if set.is_empty() {
849                    self.by_key.remove(&k);
850                }
851            }
852        }
853        if let CandidateSpec::ScanAll { field } = spec {
854            if get(field).as_ref().and_then(as_numeric_list).is_some() {
855                self.vec_meta.remove(&node);
856                self.vec_checkpoints.remove(&node);
857                self.vec_anchor.remove(&node);
858            }
859        }
860    }
861
862    /// Cached vector dimension for a `ScanAll` member, if present.
863    pub fn vec_dim(&self, node: u32) -> Option<u32> {
864        self.vec_meta.get(&node).map(|(d, _)| *d)
865    }
866
867    /// Cached `(dim, L2 norm)` for tests / debug.
868    pub fn vec_meta(&self, node: u32) -> Option<(u32, f64)> {
869        self.vec_meta.get(&node).copied()
870    }
871
872    /// Cached checkpoints for tests / debug.
873    pub fn vec_ckpts(&self, node: u32) -> Option<&[f64; 8]> {
874        self.vec_checkpoints.get(&node)
875    }
876
877    /// Returns `(cached_norm, &checkpoints)` if the cached state matches the
878    /// live vector under all three freshness checks.
879    ///
880    /// # Stale-cache gate
881    ///
882    /// Stale checkpoints (from a vector that differs from `live`) can produce
883    /// **false rejects** — the Cauchy-Schwarz suffix bound may be under-tight
884    /// for the live vector's actual energy distribution.  Three guards defend
885    /// against this in ascending selectivity order:
886    ///
887    /// 1. **Dim check** — `cached_dim == live.len()`.  Different lengths →
888    ///    immediate fallback.
889    /// 2. **Norm check** — recomputes L2 norm with the same sequential
890    ///    accumulation used at insert time so bits are identical for an unchanged
891    ///    vector.  Changed norm → fallback.
892    /// 3. **Anchor check** — compares `xs[0]` against the cached first element.
893    ///    A permuted vector can share `(dim, norm)` with the indexed one but
894    ///    differ at `xs[0]`, breaking the most realistic same-norm aliasing
895    ///    attack.  This is **heuristic hardening**, not a proof: a permutation
896    ///    that preserves `xs[0]` would still pass, but is vanishingly unlikely
897    ///    in practice.
898    ///
899    /// The real coherence guarantee is structural: checkpoint rebuilds flow
900    /// through the same insert/remove choke-points as `vec_meta`, so in
901    /// normal single-writer operation the cache is always coherent.  These
902    /// gates are belt-and-suspenders against bugs in those choke-points.
903    pub(crate) fn fresh_ckpts_for<'a>(
904        &'a self,
905        node: u32,
906        live: &[f64],
907    ) -> Option<(f64, &'a [f64; 8])> {
908        let &(dim, norm) = self.vec_meta.get(&node)?;
909        if dim != live.len() as u32 {
910            return None;
911        }
912        // Compute the live norm with the same sequential accumulation used at
913        // insert time so the bits are identical when the vector is unchanged.
914        let live_norm = {
915            let mut n2 = 0.0f64;
916            for x in live {
917                n2 += x * x;
918            }
919            n2.sqrt()
920        };
921        if norm != live_norm {
922            return None; // stale — fall back to brute-force evaluate()
923        }
924        // Heuristic anchor check: first element breaks same-norm permutation
925        // aliasing in virtually all realistic cases.  dim > 0 guaranteed (dim
926        // was stored from non-empty xs; live.len() == dim > 0).
927        let live_anchor = live[0];
928        let &cached_anchor = self.vec_anchor.get(&node)?;
929        if live_anchor != cached_anchor {
930            return None;
931        }
932        let ckpts = self.vec_checkpoints.get(&node)?;
933        Some((norm, ckpts))
934    }
935
936    pub fn candidates(
937        &self,
938        spec: &CandidateSpec,
939        get: &dyn Fn(&str) -> Option<Value>,
940    ) -> BTreeSet<u32> {
941        // Hnsw: approximate nearest-neighbor search.
942        if let CandidateSpec::Hnsw { field, k } = spec {
943            return self.hnsw_candidates(field, *k, get);
944        }
945        // VectorClusters: probe the P nearest centroids.
946        if let CandidateSpec::VectorClusters { field, .. } = spec {
947            return self.ivf_candidates(field, get);
948        }
949        // Union: take the union of candidates from each child spec.
950        if let CandidateSpec::Union(specs) = spec {
951            return specs.iter().flat_map(|s| self.candidates(s, get)).collect();
952        }
953        if let CandidateSpec::Intersect(specs) = spec {
954            return self.intersect_candidates(specs, get);
955        }
956
957        let mut out = BTreeSet::new();
958        for k in Self::probe_keys(spec, get) {
959            if let Some(set) = self.by_key.get(&k) {
960                out.extend(set.iter().copied());
961            }
962        }
963        // Exact: VectorSimilar evaluate is None when dims differ.
964        if vector_dim_reject_enabled() {
965            if let CandidateSpec::ScanAll { field } = spec {
966                if let Some((dim, _)) = get(field).as_ref().and_then(vec_dim_norm) {
967                    out.retain(|id| self.vec_meta.get(id).is_none_or(|(d, _)| *d == dim));
968                }
969            }
970        }
971        out
972    }
973
974    /// Intersect child candidate sets. `ScanAll` is the universe (skipped);
975    /// if every child is `ScanAll`, fall back to `ScanAll`. `ByKey` is resolved
976    /// outside the index. Empty child → empty.
977    fn intersect_candidates(
978        &self,
979        specs: &[CandidateSpec<'_>],
980        get: &dyn Fn(&str) -> Option<Value>,
981    ) -> BTreeSet<u32> {
982        let mut restrictive = Vec::new();
983        let mut scan_alls = Vec::new();
984        for s in specs {
985            if spec_is_scan_all_universe(s) {
986                scan_alls.push(s);
987            } else if spec_is_bykey_external(s) {
988                continue;
989            } else {
990                restrictive.push(s);
991            }
992        }
993        let to_intersect: &[&CandidateSpec<'_>] = if !restrictive.is_empty() {
994            &restrictive
995        } else if !scan_alls.is_empty() {
996            &scan_alls
997        } else {
998            return BTreeSet::new();
999        };
1000        let mut iter = to_intersect.iter();
1001        let Some(first) = iter.next() else {
1002            return BTreeSet::new();
1003        };
1004        let mut acc = self.candidates(first, get);
1005        if acc.is_empty() {
1006            return acc;
1007        }
1008        for s in iter {
1009            let other = self.candidates(s, get);
1010            if other.is_empty() {
1011                return BTreeSet::new();
1012            }
1013            acc = acc.intersection(&other).copied().collect();
1014            if acc.is_empty() {
1015                return acc;
1016            }
1017        }
1018        acc
1019    }
1020
1021    /// IVF candidate lookup: find the P nearest centroids to the query vector,
1022    /// return the union of their cluster members.
1023    fn ivf_candidates(&self, field: &str, get: &dyn Fn(&str) -> Option<Value>) -> BTreeSet<u32> {
1024        let Some(xs) = get(field).as_ref().and_then(as_numeric_list) else {
1025            return BTreeSet::new();
1026        };
1027        if self.ivf_centroids.is_empty() {
1028            // Not yet fitted (e.g. empty side at create time, or no data).
1029            // Fall back to full scan so early crash-recovery states don't drop recall
1030            // to zero when too few vectors were inserted for IVF to be meaningful.
1031            return self.ivf_raw.keys().copied().collect();
1032        }
1033        // When n ≤ k (actual centroid count), every node is its own centroid;
1034        // P probes only return the src's own cluster (which excludes itself),
1035        // yielding zero candidates. Full scan is correct and O(n) for these
1036        // tiny sets — this covers n < IVF_K_MIN and the exact n == k edge case.
1037        if self.ivf_raw.len() <= self.ivf_centroids.len() {
1038            return self.ivf_raw.keys().copied().collect();
1039        }
1040        let k = self.ivf_centroids.len();
1041        let p = probe_count(k);
1042
1043        // Probe in cosine space (same as centroid fit). Zero query → no candidates
1044        // (cosine with a zero vector is undefined; exact evaluate also returns None).
1045        let Some(xs) = l2_normalize(&xs) else {
1046            return BTreeSet::new();
1047        };
1048
1049        // Rank centroids by L2 distance to the unit query; take top-P.
1050        let mut dists: Vec<(usize, f64)> = self
1051            .ivf_centroids
1052            .iter()
1053            .enumerate()
1054            .map(|(i, c)| (i, l2_sq(&xs, c)))
1055            .collect();
1056        dists.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
1057
1058        let mut out = BTreeSet::new();
1059        for (ci, _) in dists.iter().take(p) {
1060            let key = ivf_cluster_key(*ci);
1061            if let Some(nodes) = self.by_key.get(&key) {
1062                out.extend(nodes.iter().copied());
1063            }
1064        }
1065        out
1066    }
1067
1068    /// Fit (or re-fit) the IVF k-means index for this side using all currently
1069    /// stored raw vectors.  Called by the engine after reindexing all nodes in
1070    /// `create_rule` and `rebuild`.
1071    ///
1072    /// `rule_name` is hashed via FNV-1a to produce a stable seed, ensuring the
1073    /// same rule+data always yields the same clusters (WAL replay identity).
1074    ///
1075    /// Clears all existing cluster assignments and by_key cluster entries, then
1076    /// assigns every non-zero vector (L2-normalized) to its nearest new centroid.
1077    /// Resets `ivf_drift` to zero.
1078    pub fn fit_ivf_clusters(&mut self, rule_name: &str) {
1079        if self.ivf_raw.is_empty() {
1080            self.ivf_centroids.clear();
1081            self.ivf_clusters.clear();
1082            self.ivf_drift = 0;
1083            return;
1084        }
1085
1086        // Clear old cluster → node mappings from by_key (namespaced IVF keys).
1087        for c in self.ivf_clusters.values() {
1088            self.by_key.remove(&ivf_cluster_key(*c));
1089        }
1090        self.ivf_clusters.clear();
1091
1092        // Gather vectors in deterministic order (BTreeMap → sorted by node id).
1093        let vecs: Vec<(u32, Vec<f64>)> = self
1094            .ivf_raw
1095            .iter()
1096            .map(|(&id, xs)| (id, xs.clone()))
1097            .collect();
1098
1099        let n = vecs.len();
1100        let k = cluster_k(n);
1101        let seed = fnv1a_u64(rule_name.as_bytes());
1102
1103        self.ivf_centroids = kmeans_fit(&vecs, k, seed);
1104
1105        // Assign in cosine space (skip zeros; they stay in ivf_raw but unclustered).
1106        for (node, xs) in &vecs {
1107            let Some(unit) = l2_normalize(xs) else {
1108                continue;
1109            };
1110            let c = nearest_centroid(&self.ivf_centroids, &unit);
1111            self.ivf_clusters.insert(*node, c);
1112            self.by_key
1113                .entry(ivf_cluster_key(c))
1114                .or_default()
1115                .insert(*node);
1116        }
1117        self.ivf_drift = 0;
1118    }
1119
1120    /// Number of fitted centroids (0 = not yet fitted).
1121    pub fn ivf_k(&self) -> usize {
1122        self.ivf_centroids.len()
1123    }
1124
1125    /// Cluster assignment for a node (None if not fitted or node not in index).
1126    pub fn ivf_cluster_of(&self, node: u32) -> Option<usize> {
1127        self.ivf_clusters.get(&node).copied()
1128    }
1129
1130    /// Export IVF state for snapshot persistence: (centroids, clusters, drift).
1131    ///
1132    /// The caller stores this in the V4 snapshot and passes it back to
1133    /// `load_ivf_state` on the next open, avoiding a full k-means re-fit.
1134    pub fn export_ivf_state(&self) -> (Vec<Vec<f64>>, BTreeMap<u32, usize>, u64) {
1135        (
1136            self.ivf_centroids.clone(),
1137            self.ivf_clusters.clone(),
1138            self.ivf_drift,
1139        )
1140    }
1141
1142    /// Restore IVF state from a V4 snapshot.
1143    ///
1144    /// This must be called AFTER the normal `insert()` pass (which populates
1145    /// `ivf_raw`) but INSTEAD OF `fit_ivf_clusters`.  It:
1146    ///   1. Removes any stale cluster-key entries from `by_key`.
1147    ///   2. Installs the persisted centroids and drift counter.
1148    ///   3. Rebuilds `by_key` cluster buckets from the persisted assignments.
1149    ///
1150    /// Nodes present in `ivf_raw` but absent from `clusters` (e.g. inserted
1151    /// post-snapshot via WAL replay before this is called) are left unassigned;
1152    /// `on_node_changed` will assign them to the nearest centroid incrementally.
1153    pub fn load_ivf_state(
1154        &mut self,
1155        centroids: Vec<Vec<f64>>,
1156        clusters: BTreeMap<u32, usize>,
1157        drift: u64,
1158    ) {
1159        // Precondition: ivf_clusters is empty when called from reindex_all_load_ivf (indexes reset to default); loop is defensive for any future direct-call path.
1160        // Remove old cluster bucket entries from by_key.
1161        for c in self.ivf_clusters.values() {
1162            self.by_key.remove(&ivf_cluster_key(*c));
1163        }
1164        self.ivf_clusters.clear();
1165
1166        self.ivf_centroids = centroids;
1167        self.ivf_drift = drift;
1168
1169        // Rebuild by_key from persisted assignments (only for nodes still in ivf_raw).
1170        for (&node, &c) in &clusters {
1171            if !self.ivf_raw.contains_key(&node) {
1172                // Node was removed post-snapshot (WAL replay deleted it).  Skip.
1173                continue;
1174            }
1175            self.ivf_clusters.insert(node, c);
1176            self.by_key
1177                .entry(ivf_cluster_key(c))
1178                .or_default()
1179                .insert(node);
1180        }
1181    }
1182
1183    // -----------------------------------------------------------------------
1184    // HNSW methods
1185    // -----------------------------------------------------------------------
1186
1187    /// Initialise the HNSW graph for this side, seeding it with `FNV-1a(rule_name)`.
1188    ///
1189    /// Must be called before inserting nodes via `CandidateSpec::Hnsw`.
1190    /// Idempotent: calling again with the same name replaces the existing graph.
1191    pub fn init_hnsw(&mut self, rule_name: &str) {
1192        let seed = fnv1a_u64(rule_name.as_bytes());
1193        self.hnsw = Some(HnswIndex::new(seed));
1194    }
1195
1196    /// HNSW candidate lookup: `k`-nearest-neighbor search using the built graph.
1197    ///
1198    /// Falls back to returning all tracked nodes when the HNSW is absent or
1199    /// empty (e.g. before any insert or when used without `init_hnsw`).
1200    fn hnsw_candidates(
1201        &self,
1202        field: &str,
1203        k: usize,
1204        get: &dyn Fn(&str) -> Option<Value>,
1205    ) -> BTreeSet<u32> {
1206        let Some(xs) = get(field).as_ref().and_then(as_numeric_list) else {
1207            return BTreeSet::new();
1208        };
1209        if let Some(h) = &self.hnsw {
1210            if !h.is_empty() {
1211                return h.search(&xs, k).into_iter().map(|(id, _)| id).collect();
1212            }
1213        }
1214        // Fallback: full scan of all tracked nodes (superset of true positives).
1215        self.hnsw_tracked.clone()
1216    }
1217
1218    /// Export the HNSW graph as an opaque bincoded blob.
1219    ///
1220    /// Returns an empty `Vec` when the HNSW is not initialized.
1221    pub fn export_hnsw_blob(&self) -> Vec<u8> {
1222        self.hnsw
1223            .as_ref()
1224            .and_then(|h| bincode::serialize(h).ok())
1225            .unwrap_or_default()
1226    }
1227
1228    /// Restore the HNSW graph from a previously exported blob.
1229    ///
1230    /// The `hnsw_tracked` set is populated from the restored graph's node ids
1231    /// so candidates/remove work correctly after restore.
1232    /// Silently ignores empty or corrupt blobs (HNSW stays uninitialized).
1233    pub fn load_hnsw_blob(&mut self, blob: &[u8]) {
1234        if blob.is_empty() {
1235            return;
1236        }
1237        if let Ok(h) = bincode::deserialize::<HnswIndex>(blob) {
1238            // Repopulate hnsw_tracked from the loaded graph.
1239            self.hnsw_tracked = h.node_ids();
1240            self.hnsw = Some(h);
1241        }
1242    }
1243
1244    /// True when the HNSW graph has been initialized and contains at least one node.
1245    pub fn has_hnsw(&self) -> bool {
1246        self.hnsw.as_ref().is_some_and(|h| !h.is_empty())
1247    }
1248
1249    /// Borrow the HNSW index, if initialized.
1250    pub fn hnsw_ref(&self) -> Option<&HnswIndex> {
1251        self.hnsw.as_ref()
1252    }
1253}
1254
1255#[cfg(test)]
1256mod tests {
1257    use super::*;
1258    use crate::def::Predicate;
1259    use core_storage::Value;
1260    use std::collections::{BTreeMap, HashMap};
1261
1262    fn getter(map: &HashMap<String, Value>) -> impl Fn(&str) -> Option<Value> + '_ {
1263        move |f: &str| map.get(f).cloned()
1264    }
1265
1266    #[test]
1267    fn kmeans_centroids_are_unit_norm() {
1268        let vecs = vec![(0, vec![3.0, 0.0, 0.0]), (1, vec![0.0, 4.0, 0.0])];
1269        let cents = kmeans_fit(&vecs, 2, 1);
1270        for c in cents {
1271            let n = c.iter().map(|x| x * x).sum::<f64>().sqrt();
1272            assert!((n - 1.0).abs() < 1e-9, "{n}");
1273        }
1274    }
1275
1276    /// Raw L2 would put `[3,0,0]` on a nearby large centroid while cosine (and
1277    /// the unit vector `[1,0,0]`) prefer the x-axis centroid. Assignment must
1278    /// L2-normalize first so scale-equivalent vectors share a cluster.
1279    ///
1280    /// Tests IVF directly (via `CandidateSpec::VectorClusters`) since
1281    /// `candidate_spec_approx` now returns `CandidateSpec::Hnsw`.
1282    #[test]
1283    fn scaled_vector_joins_same_ivf_cluster_as_unit() {
1284        // Use VectorClusters directly to test IVF cluster assignment.
1285        let spec = CandidateSpec::VectorClusters {
1286            field: "emb",
1287            min: 0.5,
1288        };
1289        let mut idx = SideIndex::default();
1290        idx.load_ivf_state(
1291            vec![vec![1.0, 0.0, 0.0], vec![2.5, 0.1, 0.0]],
1292            BTreeMap::new(),
1293            0,
1294        );
1295        idx.insert(&spec, 1, &getter(&emb(&[1.0, 0.0, 0.0])));
1296        idx.insert(&spec, 2, &getter(&emb(&[3.0, 0.0, 0.0])));
1297        assert_eq!(
1298            idx.ivf_cluster_of(1),
1299            idx.ivf_cluster_of(2),
1300            "scale-equivalent vectors must share an IVF cluster; got {:?} vs {:?}",
1301            idx.ivf_cluster_of(1),
1302            idx.ivf_cluster_of(2)
1303        );
1304        assert_eq!(idx.ivf_cluster_of(1), Some(0));
1305    }
1306
1307    #[test]
1308    fn scalar_index_buckets_by_value() {
1309        let pred = Predicate::FieldEqual {
1310            field: "ind".into(),
1311        };
1312        let spec = candidate_spec(&pred);
1313        let mut idx = SideIndex::default();
1314        let a: HashMap<_, _> = [("ind".to_string(), Value::Str("arch".into()))].into();
1315        let b: HashMap<_, _> = [("ind".to_string(), Value::Str("law".into()))].into();
1316        idx.insert(&spec, 1, &getter(&a));
1317        idx.insert(&spec, 2, &getter(&b));
1318        idx.insert(&spec, 3, &getter(&a));
1319        let c = idx.candidates(&spec, &getter(&a));
1320        assert_eq!(c.into_iter().collect::<Vec<_>>(), vec![1, 3]);
1321        idx.remove(&spec, 3, &getter(&a));
1322        assert_eq!(idx.candidates(&spec, &getter(&a)).len(), 1);
1323        // node without the field indexes nothing and matches nothing
1324        let empty: HashMap<String, Value> = HashMap::new();
1325        idx.insert(&spec, 9, &getter(&empty));
1326        assert!(idx.candidates(&spec, &getter(&empty)).is_empty());
1327    }
1328
1329    #[test]
1330    fn token_index_unions_buckets() {
1331        let mk =
1332            |items: &[&str]| Value::List(items.iter().map(|s| Value::Str((*s).into())).collect());
1333        let pred = Predicate::Overlap {
1334            field: "tags".into(),
1335            min: 0.5,
1336        };
1337        let spec = candidate_spec(&pred);
1338        let mut idx = SideIndex::default();
1339        let a: HashMap<_, _> = [("tags".to_string(), mk(&["x", "y"]))].into();
1340        let b: HashMap<_, _> = [("tags".to_string(), mk(&["y", "z"]))].into();
1341        let c: HashMap<_, _> = [("tags".to_string(), mk(&["q"]))].into();
1342        idx.insert(&spec, 1, &getter(&a));
1343        idx.insert(&spec, 2, &getter(&b));
1344        idx.insert(&spec, 3, &getter(&c));
1345        let probe: HashMap<_, _> = [("tags".to_string(), mk(&["y"]))].into();
1346        assert_eq!(
1347            idx.candidates(&spec, &getter(&probe))
1348                .into_iter()
1349                .collect::<Vec<_>>(),
1350            vec![1, 2]
1351        );
1352        idx.remove(&spec, 2, &getter(&b));
1353        assert_eq!(
1354            idx.candidates(&spec, &getter(&probe))
1355                .into_iter()
1356                .collect::<Vec<_>>(),
1357            vec![1]
1358        );
1359    }
1360
1361    #[test]
1362    fn all_intersects_parts_and_bykey_indexes_nothing() {
1363        let all = Predicate::All(vec![
1364            Predicate::FieldEqual {
1365                field: "ind".into(),
1366            },
1367            Predicate::Overlap {
1368                field: "tags".into(),
1369                min: 0.5,
1370            },
1371        ]);
1372        match candidate_spec(&all) {
1373            CandidateSpec::Intersect(v) => assert_eq!(v.len(), 2),
1374            other => panic!("{other:?}"),
1375        }
1376        let km = Predicate::KeyMatch { field: "fk".into() };
1377        assert!(matches!(candidate_spec(&km), CandidateSpec::ByKey));
1378        let mut idx = SideIndex::default();
1379        let a: HashMap<_, _> = [("fk".to_string(), Value::Str("c1".into()))].into();
1380        idx.insert(&candidate_spec(&km), 1, &getter(&a));
1381        assert!(idx.candidates(&candidate_spec(&km), &getter(&a)).is_empty());
1382    }
1383
1384    fn year(v: Value) -> HashMap<String, Value> {
1385        [("year".to_string(), v)].into()
1386    }
1387
1388    fn loc(lat: f64, lon: f64) -> HashMap<String, Value> {
1389        [(
1390            "loc".to_string(),
1391            Value::List(vec![Value::Float(lat), Value::Float(lon)]),
1392        )]
1393        .into()
1394    }
1395
1396    fn emb(vals: &[f64]) -> HashMap<String, Value> {
1397        [(
1398            "emb".to_string(),
1399            Value::List(vals.iter().copied().map(Value::Float).collect()),
1400        )]
1401        .into()
1402    }
1403
1404    fn bucket_int(spec: &CandidateSpec, map: &HashMap<String, Value>) -> Option<i64> {
1405        match SideIndex::index_keys(spec, &getter(map)).into_iter().next() {
1406            Some(ValueKey::Int(b)) => Some(b),
1407            _ => None,
1408        }
1409    }
1410
1411    #[test]
1412    fn numeric_bucket_adjacency_and_far_value() {
1413        let pred = Predicate::NumericWithin {
1414            field: "year".into(),
1415            tolerance: 2.0,
1416        };
1417        let spec = candidate_spec(&pred);
1418        assert!(matches!(
1419            spec,
1420            CandidateSpec::NumericBucket {
1421                field: "year",
1422                tolerance
1423            } if tolerance == 2.0
1424        ));
1425
1426        let v10 = year(Value::Float(10.0));
1427        let v119 = year(Value::Float(11.9));
1428        let v99 = year(Value::Float(9.9));
1429        let v141 = year(Value::Float(14.1));
1430
1431        let b10 = bucket_int(&spec, &v10).unwrap();
1432        let b119 = bucket_int(&spec, &v119).unwrap();
1433        let b99 = bucket_int(&spec, &v99).unwrap();
1434        // 10.0 and 11.9 share a bucket; 9.9 is adjacent (forces ±1 probe).
1435        assert!((b10 - b119).abs() <= 1);
1436        assert!((b10 - b99).abs() <= 1);
1437
1438        let mut idx = SideIndex::default();
1439        idx.insert(&spec, 1, &getter(&v10));
1440        idx.insert(&spec, 2, &getter(&v119));
1441        idx.insert(&spec, 3, &getter(&v141));
1442        idx.insert(&spec, 4, &getter(&v99));
1443        let hits = idx.candidates(&spec, &getter(&v10));
1444        assert_eq!(hits.into_iter().collect::<Vec<_>>(), vec![1, 2, 4]);
1445    }
1446
1447    #[test]
1448    fn numeric_tol_zero_int_float_collide() {
1449        let pred = Predicate::NumericWithin {
1450            field: "year".into(),
1451            tolerance: 0.0,
1452        };
1453        let spec = candidate_spec(&pred);
1454        let mut idx = SideIndex::default();
1455        idx.insert(&spec, 1, &getter(&year(Value::Int(2))));
1456        assert_eq!(
1457            idx.candidates(&spec, &getter(&year(Value::Float(2.0))))
1458                .into_iter()
1459                .collect::<Vec<_>>(),
1460            vec![1]
1461        );
1462        assert!(idx
1463            .candidates(&spec, &getter(&year(Value::Float(2.1))))
1464            .is_empty());
1465    }
1466
1467    #[test]
1468    fn numeric_tol_zero_signed_zero_collides() {
1469        let pred = Predicate::NumericWithin {
1470            field: "year".into(),
1471            tolerance: 0.0,
1472        };
1473        let spec = candidate_spec(&pred);
1474        let neg = year(Value::Float(-0.0));
1475        let pos = year(Value::Float(0.0));
1476        let mut idx = SideIndex::default();
1477        idx.insert(&spec, 1, &getter(&neg));
1478        assert_eq!(
1479            idx.candidates(&spec, &getter(&pos))
1480                .into_iter()
1481                .collect::<Vec<_>>(),
1482            vec![1]
1483        );
1484        let mut idx2 = SideIndex::default();
1485        idx2.insert(&spec, 2, &getter(&pos));
1486        assert_eq!(
1487            idx2.candidates(&spec, &getter(&neg))
1488                .into_iter()
1489                .collect::<Vec<_>>(),
1490            vec![2]
1491        );
1492    }
1493
1494    #[test]
1495    fn geo_grid_same_cell_cross_cell_and_far_city() {
1496        let pred = Predicate::GeoRadius {
1497            field: "loc".into(),
1498            km: 400.0,
1499        };
1500        let spec = candidate_spec(&pred);
1501        assert!(matches!(
1502            spec,
1503            CandidateSpec::GeoGrid {
1504                field: "loc",
1505                km
1506            } if km == 400.0
1507        ));
1508
1509        let paris = loc(48.8566, 2.3522);
1510        let london = loc(51.5074, -0.1278);
1511        let nearby = loc(48.9, 2.4); // same cell as Paris at km=400
1512        let ny = loc(40.7128, -74.0060);
1513
1514        let mut idx = SideIndex::default();
1515        idx.insert(&spec, 1, &getter(&paris));
1516        idx.insert(&spec, 2, &getter(&london));
1517        idx.insert(&spec, 3, &getter(&nearby));
1518        idx.insert(&spec, 4, &getter(&ny));
1519
1520        let from_paris = idx.candidates(&spec, &getter(&paris));
1521        assert!(from_paris.contains(&1), "same-cell self");
1522        assert!(from_paris.contains(&3), "same-cell neighbor");
1523        assert!(from_paris.contains(&2), "cross-cell Paris↔London ~343.5 km");
1524        assert!(!from_paris.contains(&4), "New York not in 400 km probe");
1525    }
1526
1527    #[test]
1528    fn geo_grid_high_latitude_probe_is_superset() {
1529        let pred = Predicate::GeoRadius {
1530            field: "loc".into(),
1531            km: 340.0,
1532        };
1533        let spec = candidate_spec(&pred);
1534        let reyk = loc(64.1466, -21.9426);
1535        let lat = 64.0_f64;
1536        let dlon = 300.0 / (111.0 * lat.to_radians().cos());
1537        let east = loc(lat, -21.9426 + dlon);
1538
1539        let mut idx = SideIndex::default();
1540        idx.insert(&spec, 1, &getter(&reyk));
1541        idx.insert(&spec, 2, &getter(&east));
1542        let hits = idx.candidates(&spec, &getter(&reyk));
1543        assert!(
1544            hits.contains(&2),
1545            "300 km east of Reykjavik must stay in the high-lat probe"
1546        );
1547    }
1548
1549    #[test]
1550    fn geo_grid_antimeridian_wrap_and_evaluate_agree() {
1551        let pred = Predicate::GeoRadius {
1552            field: "loc".into(),
1553            km: 400.0,
1554        };
1555        let spec = candidate_spec(&pred);
1556        let east = loc(70.0, 179.9);
1557        let west = loc(70.0, -179.9);
1558
1559        let mut idx = SideIndex::default();
1560        idx.insert(&spec, 1, &getter(&east));
1561        assert!(
1562            idx.candidates(&spec, &getter(&west)).contains(&1),
1563            "±180 pair at lat 70 must land in the wrapped probe"
1564        );
1565
1566        let sp = |f: &str| east.get(f).cloned();
1567        let dp = |f: &str| west.get(f).cloned();
1568        let score = crate::def::evaluate(
1569            &pred,
1570            &crate::def::NodeView {
1571                key: "e",
1572                props: &sp,
1573            },
1574            &crate::def::NodeView {
1575                key: "w",
1576                props: &dp,
1577            },
1578        );
1579        assert!(
1580            score.is_some(),
1581            "haversine must match across the antimeridian"
1582        );
1583
1584        // Wrap must not alias distant longitudes into the Paris probe.
1585        let paris = loc(48.8566, 2.3522);
1586        let ny = loc(40.7128, -74.0060);
1587        let mut idx2 = SideIndex::default();
1588        idx2.insert(&spec, 4, &getter(&ny));
1589        assert!(
1590            !idx2.candidates(&spec, &getter(&paris)).contains(&4),
1591            "New York still not in the Paris probe after wrap"
1592        );
1593    }
1594
1595    #[test]
1596    fn scan_all_returns_vector_nodes_skips_malformed() {
1597        let pred = Predicate::VectorSimilar {
1598            field: "emb".into(),
1599            min: 0.5,
1600        };
1601        let spec = candidate_spec(&pred);
1602        assert!(matches!(spec, CandidateSpec::ScanAll { field: "emb" }));
1603
1604        let mut idx = SideIndex::default();
1605        idx.insert(&spec, 1, &getter(&emb(&[1.0, 0.0])));
1606        idx.insert(&spec, 2, &getter(&emb(&[0.0, 1.0])));
1607        idx.insert(&spec, 3, &getter(&emb(&[1.0, 2.0, 3.0])));
1608        let empty: HashMap<_, _> = [("emb".to_string(), Value::List(vec![]))].into();
1609        let text: HashMap<_, _> =
1610            [("emb".to_string(), Value::List(vec![Value::Str("x".into())]))].into();
1611        let missing: HashMap<String, Value> = HashMap::new();
1612        idx.insert(&spec, 4, &getter(&empty));
1613        idx.insert(&spec, 5, &getter(&text));
1614        idx.insert(&spec, 6, &getter(&missing));
1615
1616        let hits = idx.candidates(&spec, &getter(&emb(&[1.0, 0.0])));
1617        assert_eq!(
1618            hits.into_iter().collect::<Vec<_>>(),
1619            vec![1, 2],
1620            "dim-2 probe must drop the dim-3 member"
1621        );
1622        assert_eq!(
1623            idx.candidates(&spec, &getter(&emb(&[1.0, 2.0, 3.0])))
1624                .into_iter()
1625                .collect::<Vec<_>>(),
1626            vec![3]
1627        );
1628        with_vector_dim_reject(false, || {
1629            assert_eq!(
1630                idx.candidates(&spec, &getter(&emb(&[1.0, 0.0])))
1631                    .into_iter()
1632                    .collect::<Vec<_>>(),
1633                vec![1, 2, 3],
1634                "unfiltered ScanAll still returns every vector node"
1635            );
1636        });
1637        assert_eq!(idx.vec_dim(1), Some(2));
1638        assert_eq!(idx.vec_dim(3), Some(3));
1639        assert!(idx.vec_meta(1).is_some());
1640        assert!(idx.vec_dim(4).is_none());
1641        assert!(idx.candidates(&spec, &getter(&empty)).is_empty());
1642        assert!(idx.candidates(&spec, &getter(&text)).is_empty());
1643        assert!(idx.candidates(&spec, &getter(&missing)).is_empty());
1644        idx.remove(&spec, 1, &getter(&emb(&[1.0, 0.0])));
1645        assert!(idx.vec_dim(1).is_none());
1646    }
1647
1648    #[test]
1649    fn legacy_specs_probe_keys_equal_index_keys() {
1650        let a: HashMap<_, _> = [
1651            ("ind".to_string(), Value::Str("arch".into())),
1652            (
1653                "tags".to_string(),
1654                Value::List(vec![Value::Str("x".into()), Value::Str("y".into())]),
1655            ),
1656            ("fk".to_string(), Value::Str("c1".into())),
1657        ]
1658        .into();
1659        let get = getter(&a);
1660        for pred in [
1661            Predicate::KeyMatch { field: "fk".into() },
1662            Predicate::FieldEqual {
1663                field: "ind".into(),
1664            },
1665            Predicate::Overlap {
1666                field: "tags".into(),
1667                min: 0.5,
1668            },
1669        ] {
1670            let spec = candidate_spec(&pred);
1671            assert_eq!(
1672                SideIndex::index_keys(&spec, &get),
1673                SideIndex::probe_keys(&spec, &get)
1674            );
1675        }
1676    }
1677
1678    #[test]
1679    fn all_vector_then_field_equal_does_not_scan_all() {
1680        let p = Predicate::All(vec![
1681            Predicate::VectorSimilar {
1682                field: "e".into(),
1683                min: 0.8,
1684            },
1685            Predicate::FieldEqual {
1686                field: "industry".into(),
1687            },
1688        ]);
1689        match candidate_spec(&p) {
1690            CandidateSpec::Intersect(v) => assert_eq!(v.len(), 2),
1691            other => panic!("{other:?}"),
1692        }
1693
1694        let spec = candidate_spec(&p);
1695        let mut idx = SideIndex::default();
1696        let mk = |industry: &str, e: &[f64]| {
1697            [
1698                ("industry".to_string(), Value::Str(industry.into())),
1699                (
1700                    "e".to_string(),
1701                    Value::List(e.iter().copied().map(Value::Float).collect()),
1702                ),
1703            ]
1704            .into()
1705        };
1706        let same: HashMap<_, _> = mk("tech", &[1.0, 0.0]);
1707        let other_ind: HashMap<_, _> = mk("law", &[1.0, 0.0]);
1708        let no_vec: HashMap<_, _> = [("industry".to_string(), Value::Str("tech".into()))].into();
1709        idx.insert(&spec, 1, &getter(&same));
1710        idx.insert(&spec, 2, &getter(&other_ind));
1711        idx.insert(&spec, 3, &getter(&no_vec));
1712
1713        let hits = idx.candidates(&spec, &getter(&same));
1714        assert!(hits.contains(&1), "matching industry must stay a candidate");
1715        assert!(
1716            !hits.contains(&2),
1717            "different industry must not be scanned in via VectorSimilar"
1718        );
1719        assert!(
1720            hits.contains(&3),
1721            "ScanAll is universe: extra Scalar-only candidates are allowed"
1722        );
1723
1724        let empty_ind: HashMap<_, _> = mk("finance", &[1.0, 0.0]);
1725        assert!(
1726            idx.candidates(&spec, &getter(&empty_ind)).is_empty(),
1727            "empty Scalar child → empty intersect"
1728        );
1729    }
1730
1731    #[test]
1732    fn all_approx_vector_then_field_equal_is_intersect() {
1733        let p = Predicate::All(vec![
1734            Predicate::VectorSimilar {
1735                field: "e".into(),
1736                min: 0.8,
1737            },
1738            Predicate::FieldEqual {
1739                field: "industry".into(),
1740            },
1741        ]);
1742        match candidate_spec_approx(&p) {
1743            CandidateSpec::Intersect(v) => assert_eq!(v.len(), 2),
1744            other => panic!("{other:?}"),
1745        }
1746
1747        let spec = candidate_spec_approx(&p);
1748        let mut idx = SideIndex::default();
1749        // Initialize HNSW so insertions populate the graph.
1750        idx.init_hnsw("test-rule");
1751        let mk = |industry: &str, e: &[f64]| {
1752            [
1753                ("industry".to_string(), Value::Str(industry.into())),
1754                (
1755                    "e".to_string(),
1756                    Value::List(e.iter().copied().map(Value::Float).collect()),
1757                ),
1758            ]
1759            .into()
1760        };
1761        let same: HashMap<_, _> = mk("tech", &[1.0, 0.0]);
1762        let other_ind: HashMap<_, _> = mk("law", &[1.0, 0.0]);
1763        idx.insert(&spec, 1, &getter(&same));
1764        idx.insert(&spec, 2, &getter(&other_ind));
1765        let hits = idx.candidates(&spec, &getter(&same));
1766        assert!(hits.contains(&1), "matching industry must stay a candidate");
1767        assert!(
1768            !hits.contains(&2),
1769            "FieldEqual must be probed on the approximate All path"
1770        );
1771    }
1772
1773    #[test]
1774    fn all_of_scan_all_stays_scan_all() {
1775        let p = Predicate::All(vec![
1776            Predicate::VectorSimilar {
1777                field: "emb".into(),
1778                min: 0.5,
1779            },
1780            Predicate::VectorSimilar {
1781                field: "emb".into(),
1782                min: 0.9,
1783            },
1784        ]);
1785        match candidate_spec(&p) {
1786            CandidateSpec::Intersect(v) => assert_eq!(v.len(), 2),
1787            other => panic!("{other:?}"),
1788        }
1789        let spec = candidate_spec(&p);
1790        let mut idx = SideIndex::default();
1791        idx.insert(&spec, 1, &getter(&emb(&[1.0, 0.0])));
1792        idx.insert(&spec, 2, &getter(&emb(&[0.0, 1.0])));
1793        let hits = idx.candidates(&spec, &getter(&emb(&[1.0, 0.0])));
1794        assert_eq!(hits.into_iter().collect::<Vec<_>>(), vec![1, 2]);
1795    }
1796
1797    #[test]
1798    fn any_stays_union() {
1799        let p = Predicate::Any(vec![
1800            Predicate::FieldEqual {
1801                field: "industry".into(),
1802            },
1803            Predicate::Overlap {
1804                field: "tags".into(),
1805                min: 0.5,
1806            },
1807        ]);
1808        match candidate_spec(&p) {
1809            CandidateSpec::Union(v) => assert_eq!(v.len(), 2),
1810            other => panic!("{other:?}"),
1811        }
1812    }
1813
1814    /// Checkpoints are populated at insert, torn out at remove,
1815    /// and ckpts[0] must equal the full L2 norm.
1816    #[test]
1817    fn checkpoint_populated_and_consistent_with_norm() {
1818        let pred = Predicate::VectorSimilar {
1819            field: "emb".into(),
1820            min: 0.8,
1821        };
1822        let spec = candidate_spec(&pred);
1823        let xs = [3.0f64, 4.0]; // norm = 5.0
1824        let mut idx = SideIndex::default();
1825        idx.insert(&spec, 1, &getter(&emb(&xs)));
1826
1827        let ckpts = idx
1828            .vec_ckpts(1)
1829            .expect("checkpoints must exist after insert");
1830        let (_, norm) = idx.vec_meta(1).unwrap();
1831        assert!(
1832            (ckpts[0] - norm).abs() < 1e-12,
1833            "ckpts[0] must equal the full L2 norm; got {} vs {}",
1834            ckpts[0],
1835            norm
1836        );
1837        assert!(
1838            (norm - 5.0).abs() < 1e-12,
1839            "norm of [3,4] must be 5.0, got {norm}"
1840        );
1841
1842        // Remove must tear out checkpoints.
1843        idx.remove(&spec, 1, &getter(&emb(&xs)));
1844        assert!(
1845            idx.vec_ckpts(1).is_none(),
1846            "checkpoints must be removed after remove()"
1847        );
1848    }
1849
1850    /// fresh_ckpts_for returns None when the live vector's norm differs
1851    /// (freshness gate) and Some when it matches.
1852    #[test]
1853    fn fresh_ckpts_for_freshness_gate() {
1854        let pred = Predicate::VectorSimilar {
1855            field: "emb".into(),
1856            min: 0.8,
1857        };
1858        let spec = candidate_spec(&pred);
1859        let xs = [1.0f64, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
1860        let mut idx = SideIndex::default();
1861        idx.insert(&spec, 7, &getter(&emb(&xs)));
1862
1863        // Correct live vector → gate passes.
1864        let result = idx.fresh_ckpts_for(7, &xs);
1865        assert!(
1866            result.is_some(),
1867            "fresh_ckpts_for must succeed with matching live vector"
1868        );
1869        let (norm, ckpts) = result.unwrap();
1870        assert!((norm - 1.0).abs() < 1e-12);
1871        assert!((ckpts[0] - 1.0).abs() < 1e-12);
1872
1873        // Wrong norm → gate rejects.
1874        let wrong = [2.0f64, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]; // norm = 2.0
1875        assert!(
1876            idx.fresh_ckpts_for(7, &wrong).is_none(),
1877            "freshness gate must reject mismatched norm"
1878        );
1879
1880        // Wrong dim → gate rejects.
1881        let short = [1.0f64, 0.0];
1882        assert!(
1883            idx.fresh_ckpts_for(7, &short).is_none(),
1884            "freshness gate must reject mismatched dim"
1885        );
1886
1887        // Missing node → returns None.
1888        assert!(idx.fresh_ckpts_for(99, &xs).is_none());
1889    }
1890
1891    /// Checkpoints for a dim-16 vector: ckpts[i] must be non-increasing
1892    /// (suffix norms decrease as the suffix shrinks).
1893    #[test]
1894    fn checkpoint_suffix_norms_non_increasing() {
1895        let pred = Predicate::VectorSimilar {
1896            field: "emb".into(),
1897            min: 0.5,
1898        };
1899        let spec = candidate_spec(&pred);
1900        let xs: Vec<f64> = (1..=16).map(|i| i as f64).collect();
1901        let mut idx = SideIndex::default();
1902        idx.insert(&spec, 42, &getter(&emb(&xs)));
1903
1904        let ckpts = *idx.vec_ckpts(42).unwrap();
1905        for c in 0..7 {
1906            assert!(
1907                ckpts[c] >= ckpts[c + 1] - 1e-12,
1908                "suffix norm must be non-increasing: ckpts[{c}]={} < ckpts[{}]={}",
1909                ckpts[c],
1910                c + 1,
1911                ckpts[c + 1]
1912            );
1913        }
1914        // ckpts[7] = suffix norm of the last 2 elements (14..=16).
1915        let expected_last = (15.0f64 * 15.0 + 16.0 * 16.0).sqrt();
1916        assert!(
1917            (ckpts[7] - expected_last).abs() < 1e-9,
1918            "ckpts[7] should be norm of last segment; got {} vs {}",
1919            ckpts[7],
1920            expected_last
1921        );
1922    }
1923}