Skip to main content

core_rules/
index.rs

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