Skip to main content

core_rules/
engine.rs

1use crate::def::{evaluate, is_keymatch_rooted, NodeView, Predicate, RuleDef};
2use crate::hnsw::HnswIndex;
3use crate::index::{
4    candidate_spec, candidate_spec_approx_with_k, ivf_drift_rebuild_threshold, CandidateSpec,
5    RuleIndex,
6};
7use core_storage::v8::encode::{decode_ivf_bytes, decode_provenance_bytes};
8use core_storage::v8::seam::ColumnsView;
9use core_storage::{EdgeProps, IdMap, Interner, Topology, Value};
10
11/// Decode raw IVF section bytes into the `RuleIvfExport` format consumed by
12/// `reindex_all_load_ivf`.  Returns an empty map when `bytes` is empty.
13fn decode_ivf_bytes_to_export(bytes: &[u8]) -> BTreeMap<String, RuleIvfExport> {
14    decode_ivf_bytes(bytes)
15        .into_iter()
16        .map(|(name, ps)| {
17            (
18                name,
19                (
20                    (ps.src.centroids, ps.src.clusters, ps.src.drift),
21                    (ps.dst.centroids, ps.dst.clusters, ps.dst.drift),
22                ),
23            )
24        })
25        .collect()
26}
27use std::collections::{BTreeMap, BTreeSet};
28use std::sync::{Mutex, OnceLock};
29
30/// A single derived-edge fire or retract captured during a commit.
31///
32/// Populated inside [`ProvSets::insert`] / [`ProvSets::remove`] while graph
33/// state is fully intact (before any tombstone step in `DeleteNode`).
34/// String keys are resolved at capture time so they remain valid even after
35/// node deletion.
36#[derive(Debug, Clone)]
37pub struct EngineEdgeDelta {
38    pub rule: String,
39    /// User-facing source node key.
40    pub src_key: String,
41    /// User-facing destination node key.
42    pub dst_key: String,
43    /// Edge-type string.
44    pub edge_type: String,
45    /// Internal edge-type symbol (for edge_props weight lookup in db.rs).
46    pub etype_sym: u32,
47    /// Internal source node id (for edge_props weight lookup in db.rs).
48    pub src_id: u32,
49    /// Internal destination node id (for edge_props weight lookup in db.rs).
50    pub dst_id: u32,
51    /// `true` = edge was fired (added to provenance); `false` = retracted.
52    pub fired: bool,
53}
54
55#[cfg(test)]
56pub use crate::index::{with_ivf_drift_rebuild, with_vector_dim_reject, with_vector_early_exit};
57
58/// Test-only: the largest number of desired (src,dst) pairs held in memory at
59/// once during a backfill sweep. Lets scale tests assert bounded materialization.
60#[cfg(test)]
61pub(crate) static PEAK_DESIRED_PAIRS: std::sync::atomic::AtomicUsize =
62    std::sync::atomic::AtomicUsize::new(0);
63
64#[cfg(test)]
65pub(crate) fn record_desired_len(n: usize) {
66    use std::sync::atomic::Ordering;
67    let mut cur = PEAK_DESIRED_PAIRS.load(Ordering::Relaxed);
68    while n > cur {
69        match PEAK_DESIRED_PAIRS.compare_exchange_weak(cur, n, Ordering::Relaxed, Ordering::Relaxed)
70        {
71            Ok(_) => break,
72            Err(actual) => cur = actual,
73        }
74    }
75}
76
77/// Borrowed mutable view of graph state the engine writes derived edges into.
78pub struct GraphMut<'a> {
79    pub ids: &'a IdMap,
80    pub syms: &'a mut Interner,
81    pub labels: &'a [u32],
82    pub props: ColumnsView<'a>,
83    pub topo: &'a mut Topology,
84    pub edge_props: &'a mut EdgeProps,
85}
86
87/// Used when `RuleDef.max_edges` is `None`.
88pub const DEFAULT_MAX_EDGES: u64 = 1_000_000;
89
90/// `(etype, src, dst)` as stored in `provenance` / `owned`.
91type Triple = (u32, u32, u32);
92/// Reverse-index entry: `(rule_id, etype, src, dst)`. `rule_id` is interned.
93type Touch = (u32, u32, u32, u32);
94
95/// IVF state for one index side, exported for V4 snapshot persistence:
96/// `(centroids, node→cluster assignments, drift_counter)`.
97pub type SideIvfExport = (Vec<Vec<f64>>, BTreeMap<u32, usize>, u64);
98/// IVF state for both sides (src, dst) of one approximate rule.
99pub type RuleIvfExport = (SideIvfExport, SideIvfExport);
100
101/// Raw (src-graph, dst-graph) HNSW blobs retained from the last snapshot.
102type HnswBlobMap = BTreeMap<String, (Vec<u8>, Vec<u8>)>;
103/// Lazily-decoded HNSW graph pair (src-side, dst-side) keyed by rule name.
104type LazyHnswMap = BTreeMap<String, (Option<HnswIndex>, Option<HnswIndex>)>;
105
106/// Lazily-decoded provenance state used by `&self` read paths
107/// (`stats()`, `explain()`, `provenance_touching`).
108///
109/// Populated once from the retained section-7 bytes via `ensure_provenance_loaded`.
110/// After the first `&mut self` mutation (which calls `ensure_provenance_loaded_mut`
111/// and installs into the live `RuleEngine` fields), read paths switch to the live
112/// fields and this struct is no longer consulted.
113#[derive(Debug, Default)]
114struct LazyProvenance {
115    provenance: BTreeMap<String, BTreeSet<Triple>>,
116    by_node: BTreeMap<u32, BTreeSet<Touch>>,
117    intern_rule: Vec<String>,
118}
119
120#[derive(Debug, Default)]
121pub struct RuleEngine {
122    rules: BTreeMap<String, RuleDef>,
123    indexes: BTreeMap<String, RuleIndex>,
124    provenance: BTreeMap<String, BTreeSet<Triple>>,
125    owned: BTreeSet<Triple>,
126    /// Derived reverse index: node → provenance triples that touch it.
127    /// Never serialized; rebuilt from `provenance` on persist-restore.
128    by_node: BTreeMap<u32, BTreeSet<Touch>>,
129    /// Intern table for rule names used as `Touch` rule_ids. Derived.
130    /// Additive-only: ids are never reused. `by_node` stores these ids, so
131    /// pruning-and-reusing a slot would alias leftover touches to a new name.
132    /// Bound: one slot per distinct rule name ever created in this process.
133    rule_intern: BTreeMap<String, u32>,
134    intern_rule: Vec<String>,
135    tripped: BTreeMap<String, bool>,
136    fires: BTreeMap<String, u64>,
137    /// Staging buffer for post-commit [`EngineEdgeDelta`] events.
138    ///
139    /// Populated by [`ProvSets::insert`] / [`ProvSets::remove`] during
140    /// `apply` (live writes AND WAL replay). Callers must drain via
141    /// [`RuleEngine::drain_deltas`] immediately after apply to consume live
142    /// events or discard replay noise.
143    pending_deltas: Vec<EngineEdgeDelta>,
144    /// Gate: whether to accumulate [`EngineEdgeDelta`] items during rule
145    /// application.
146    ///
147    /// **Safety invariant:** events are fire-and-forget live streams — a
148    /// subscriber that attaches *later* never receives past events by design.
149    /// Similarly, views call `backfill_view` at creation time (reading directly
150    /// from `topo`, not from pending deltas), so deltas accumulated before a
151    /// view is defined are not needed. Accumulation can therefore be skipped
152    /// whenever no subscriber and no view exists; the observable behaviour is
153    /// identical. Set to `true` by `set_emit_deltas` before the first
154    /// subscribe, create_view, or any operation that needs events; cleared when
155    /// the last listener is removed.
156    emit_deltas: bool,
157    /// Approximate rule names whose dst-side IVF drift exceeded
158    /// [`crate::IVF_DRIFT_REBUILD`] during the last index maintenance.
159    /// Drained by [`RuleEngine::take_rebuild_needed`] after apply.
160    rebuild_needed: BTreeSet<String>,
161    /// Whether candidate indexes have been populated.  Starts `false` after a
162    /// snapshot restore that defers index building.  Set to `true` by
163    /// `reindex_all`, `reindex_all_load_ivf`, and `create_rule`.  On the first
164    /// mutation call when this is `false`, the full O(n) scan runs (first-write
165    /// cost), consuming and replacing `retained_hnsw_blobs`.
166    indexes_populated: bool,
167    /// Raw HNSW blobs retained from the last snapshot, not yet deserialized.
168    ///
169    /// Populated by `store_snapshot_state`.  Consumed (deserialized into each
170    /// rule's `RuleIndex`) either eagerly in `consume_retained_state_eager` (WAL-
171    /// present open) or lazily on the first mutation or first ANN query
172    /// (`ensure_hnsw_loaded` / the lazy-init guard in the mutation hooks).
173    /// Wrapped in Mutex so `ensure_hnsw_loaded` can be called from `&self`
174    /// (shared-read ANN path in `find_similar_vector` / `search_hybrid`).
175    retained_hnsw_blobs: Mutex<HnswBlobMap>,
176    /// Raw bincode bytes of the IVF cluster state retained from the last snapshot.
177    ///
178    /// Same lifecycle as `retained_hnsw_blobs` (mutation path only; consumed
179    /// in `consume_retained_state_eager` or the lazy-init guard in
180    /// `on_node_changed`).  Decoded via `decode_ivf_bytes` on first use
181    /// instead of at open time to avoid the ~544 MiB bincode overhead.
182    /// Wrapped in Mutex so `store_snapshot_state` can take `&self`.
183    retained_ivf_bytes: Mutex<Option<Vec<u8>>>,
184    /// Raw rkyv bytes of the provenance section retained from the last snapshot.
185    ///
186    /// Wrapped in `Mutex` so the `&self` read path (`ensure_provenance_loaded`)
187    /// can access it without `&mut self`.  The bytes are NOT cleared by
188    /// `ensure_provenance_loaded`; they are consumed (set to `None`) only by
189    /// `ensure_provenance_loaded_mut` on the first mutation.  This mirrors the
190    /// `retained_hnsw_blobs` lifetime so the write path can still consume the
191    /// raw bytes to install into the live mutable fields.
192    retained_provenance_bytes: Mutex<Option<Vec<u8>>>,
193    /// Lazily-decoded provenance for `&self` read paths (clean-open, no-WAL).
194    ///
195    /// Populated at most once via `OnceLock::get_or_init` inside
196    /// `ensure_provenance_loaded`.  After the first mutation
197    /// `ensure_provenance_loaded_mut` installs provenance into the live struct
198    /// fields and clears `retained_provenance_bytes`; subsequent reads detect
199    /// `retained_provenance_bytes.is_none()` and go directly to the live fields.
200    lazy_provenance: OnceLock<LazyProvenance>,
201    /// Lazily-loaded HNSW graphs for the clean-open (no-WAL) ANN read path.
202    ///
203    /// Populated once by `ensure_hnsw_loaded` on the first ANN query after a
204    /// snapshot open with no WAL.  `OnceLock` guarantees exactly-once init
205    /// even under concurrent shared-read access.  After the first mutation the
206    /// mutation-path HNSW in `indexes` takes over; this field is never cleared.
207    lazy_hnsw: OnceLock<LazyHnswMap>,
208}
209
210// ---------------------------------------------------------------------------
211// Private helpers (free functions, not methods, to avoid whole-struct borrows)
212// ---------------------------------------------------------------------------
213
214/// Rule-aware candidate spec: exact `ScanAll` for `approximate=false`, IVF
215/// `VectorClusters` for `approximate=true` (VectorSimilar-rooted predicates).
216fn candidate_spec_for(def: &RuleDef) -> CandidateSpec<'_> {
217    if def.approximate {
218        // k = max(max_edges, 128): return at least 128 candidates so the HNSW
219        // beam's expanded reach (M₀=128 layer-0 edges) is not truncated before
220        // evaluation; bounded by max_edges when set by the caller.
221        let k = def.max_edges.map(|me| me.max(128)).unwrap_or(128) as usize;
222        candidate_spec_approx_with_k(&def.predicate, k)
223    } else {
224        candidate_spec(&def.predicate)
225    }
226}
227
228/// Rule-aware src-side lookup spec. KeyMatch is still exact on the src side
229/// regardless of `approximate` (the approximation is on the dst candidate set).
230/// For KeyMatch-rooted predicates, src side is indexed as Scalar (FK field
231/// value → node bucket) so reverse lookup uses the dst key. Non-KeyMatch
232/// `All` uses the full [`candidate_spec_for`] Intersect (not `parts[0]`).
233fn src_lookup_spec_for(def: &RuleDef) -> CandidateSpec<'_> {
234    if is_keymatch_rooted(&def.predicate) {
235        let field =
236            keymatch_field(&def.predicate).expect("keymatch-rooted predicate has a KeyMatch field");
237        CandidateSpec::Scalar { field }
238    } else {
239        candidate_spec_for(def)
240    }
241}
242
243/// True when `p` contains a `VectorSimilar { field: f }` where `f == field`.
244fn predicate_covers_field(p: &Predicate, field: &str) -> bool {
245    match p {
246        Predicate::VectorSimilar { field: f, .. } => f == field,
247        Predicate::All(parts) | Predicate::Any(parts) => {
248            parts.iter().any(|q| predicate_covers_field(q, field))
249        }
250        _ => false,
251    }
252}
253
254/// Extract the FK field name from a KeyMatch (or All-leading-KeyMatch) predicate.
255fn keymatch_field(p: &Predicate) -> Option<&str> {
256    match p {
257        Predicate::KeyMatch { field } => Some(field),
258        Predicate::All(parts) => parts.first().and_then(keymatch_field),
259        Predicate::Any(_) => None,
260        _ => None,
261    }
262}
263
264/// Compute the set of desired (src, dst) → score edges involving node `n` on
265/// the given side.  Returns an empty map if `n`'s label doesn't match the rule.
266fn compute_desired(
267    def: &RuleDef,
268    index: &RuleIndex,
269    n: u32,
270    on_src_side: bool,
271    g: &GraphMut<'_>,
272) -> BTreeMap<(u32, u32), f64> {
273    let (my_label, other_label) = if on_src_side {
274        (&def.src_label, &def.dst_label)
275    } else {
276        (&def.dst_label, &def.src_label)
277    };
278
279    let Some(my_sym) = g.syms.get(my_label) else {
280        return BTreeMap::new();
281    };
282    if g.labels.get(n as usize).copied() != Some(my_sym) {
283        return BTreeMap::new();
284    }
285    let other_sym = g.syms.get(other_label);
286
287    let n_key = match g.ids.key_of(n) {
288        Some(k) => k,
289        None => return BTreeMap::new(),
290    };
291    let n_get = |f: &str| g.props.get(n, f).map(|vr| vr.into_value());
292
293    let spec = candidate_spec_for(def);
294    let candidates: BTreeSet<u32> = if on_src_side {
295        if is_keymatch_rooted(&def.predicate) {
296            // KeyMatch src→dst: look up the dst node directly by FK field value.
297            // Covers `ByKey` and `All` whose first conjunct is KeyMatch
298            // (`Intersect([ByKey, …])`); evaluate() filters remaining conjuncts.
299            let field = keymatch_field(&def.predicate).expect("ByKey always comes from KeyMatch");
300            match n_get(field) {
301                Some(Value::Str(ref target_key)) => match g.ids.get(target_key) {
302                    Some(dst_id) => std::iter::once(dst_id).collect(),
303                    None => BTreeSet::new(),
304                },
305                _ => BTreeSet::new(),
306            }
307        } else {
308            index.dst_side.candidates(&spec, &n_get)
309        }
310    } else {
311        // n is dst: probe src_side to find src candidates.
312        let src_spec = src_lookup_spec_for(def);
313        if is_keymatch_rooted(&def.predicate) {
314            // Synthetic getter: returns n's key for the FK field so we find
315            // src nodes whose FK value points to n.
316            let key_getter = |_: &str| Some(Value::Str(n_key.to_string()));
317            index.src_side.candidates(&src_spec, &key_getter)
318        } else {
319            index.src_side.candidates(&src_spec, &n_get)
320        }
321    };
322
323    // Fast path: Cauchy-Schwarz suffix-norm early exit for exact VectorSimilar.
324    //
325    // Skipped for approximate rules (`def.approximate == true`): the IVF
326    // pre-filter already eliminates non-candidate nodes, and ScanAll metadata
327    // (vec_meta / vec_checkpoints) is not maintained for VectorClusters specs.
328    //
329    // Pre-fetch n's live vector ONCE outside the candidate loop so it is
330    // allocated only once per compute_desired call (not per candidate pair).
331    // m's vector is still fetched per pair — unavoidable without caching full
332    // vectors (which is the O(n·dim) trade-off the brief rules out).
333    //
334    // Freshness gate: `SideIndex::fresh_ckpts_for` returns `None` when the
335    // cached norm differs from the live norm, preventing stale checkpoints from
336    // producing a false reject (see doc comment on `fresh_ckpts_for`).
337    let n_early_exit_hint: Option<(Vec<f64>, f64, [f64; 8])> = if !def.approximate {
338        if let Predicate::VectorSimilar { field, .. } = &def.predicate {
339            if crate::index::vector_early_exit_enabled() {
340                let n_side = if on_src_side {
341                    &index.src_side
342                } else {
343                    &index.dst_side
344                };
345                if let Some(vn_v) = n_get(field) {
346                    if let Some(vn) = crate::index::as_numeric_list(&vn_v) {
347                        if let Some((norm_n, ckpts_n)) = n_side.fresh_ckpts_for(n, &vn) {
348                            Some((vn, norm_n, *ckpts_n))
349                        } else {
350                            None
351                        }
352                    } else {
353                        None
354                    }
355                } else {
356                    None
357                }
358            } else {
359                None
360            }
361        } else {
362            None
363        }
364    } else {
365        None
366    };
367
368    let mut out = BTreeMap::new();
369    for m in candidates {
370        if m == n {
371            continue; // never self-edges
372        }
373        if g.labels.get(m as usize).copied() != other_sym {
374            continue; // label filter
375        }
376        let m_key = match g.ids.key_of(m) {
377            Some(k) => k,
378            None => continue,
379        };
380        let m_get = |f: &str| g.props.get(m, f).map(|vr| vr.into_value());
381        let (s_view, d_view, s_id, d_id) = if on_src_side {
382            (
383                NodeView {
384                    key: n_key,
385                    props: &n_get,
386                },
387                NodeView {
388                    key: m_key,
389                    props: &m_get,
390                },
391                n,
392                m,
393            )
394        } else {
395            (
396                NodeView {
397                    key: m_key,
398                    props: &m_get,
399                },
400                NodeView {
401                    key: n_key,
402                    props: &n_get,
403                },
404                m,
405                n,
406            )
407        };
408
409        // Use the pre-fetched n hint if available; fetch m per-pair.
410        if let (Some((ref vn, norm_n, ckpts_n)), Predicate::VectorSimilar { field, min }) =
411            (&n_early_exit_hint, &def.predicate)
412        {
413            let m_side = if on_src_side {
414                &index.dst_side
415            } else {
416                &index.src_side
417            };
418            if let Some(vm_v) = m_get(field) {
419                if let Some(vm) = crate::index::as_numeric_list(&vm_v) {
420                    if let Some((norm_m, ckpts_m)) = m_side.fresh_ckpts_for(m, &vm) {
421                        let (va, ckpts_a, na, vb, ckpts_b, nb) = if on_src_side {
422                            (
423                                vn.as_slice(),
424                                ckpts_n,
425                                *norm_n,
426                                vm.as_slice(),
427                                ckpts_m,
428                                norm_m,
429                            )
430                        } else {
431                            (
432                                vm.as_slice(),
433                                ckpts_m,
434                                norm_m,
435                                vn.as_slice(),
436                                ckpts_n,
437                                *norm_n,
438                            )
439                        };
440                        match crate::def::cosine_early_exit(va, vb, ckpts_a, ckpts_b, na, nb, *min)
441                        {
442                            None => continue, // exact reject
443                            Some(score) => {
444                                out.insert((s_id, d_id), score);
445                                continue; // full cosine already computed
446                            }
447                        }
448                    }
449                }
450            }
451        }
452
453        if let Some(score) = evaluate(&def.predicate, &s_view, &d_view) {
454            out.insert((s_id, d_id), score);
455        }
456    }
457    #[cfg(test)]
458    record_desired_len(out.len());
459    out
460}
461
462/// Compute the desired `(src, dst) → score` map for a **via-hop rule** where
463/// `def.via_label.is_some()`.
464///
465/// Semantics: src -[via_edge/via_dir]→ via(via_label), evaluate predicate
466/// between via and dst; fire src→dst if any via satisfies; score = max over via.
467///
468/// `anchor` selects which side of the computation to anchor:
469/// - `ViaAnchor::Src(src_id)`: expand from one specific src node.
470/// - `ViaAnchor::Dst(dst_id)`: n is a dst node; scan all src nodes and check
471///   if any via hop to them evaluates with n.
472///
473/// Always returns `(src, dst)` keyed pairs regardless of anchor.
474fn compute_desired_via(
475    def: &RuleDef,
476    anchor: ViaAnchor,
477    g: &GraphMut<'_>,
478) -> BTreeMap<(u32, u32), f64> {
479    let via_label = def.via_label.as_deref().unwrap();
480    let via_edge_str = def.via_edge.as_deref().unwrap();
481    let via_dir = def.via_dir.unwrap_or(core_storage::Direction::Out);
482
483    let src_sym = match g.syms.get(&def.src_label) {
484        Some(s) => s,
485        None => return BTreeMap::new(),
486    };
487    let via_sym = match g.syms.get(via_label) {
488        Some(s) => s,
489        None => return BTreeMap::new(),
490    };
491    let dst_sym = match g.syms.get(&def.dst_label) {
492        Some(s) => s,
493        None => return BTreeMap::new(),
494    };
495    let via_etype = match g.syms.get(via_edge_str) {
496        Some(e) => e,
497        None => return BTreeMap::new(),
498    };
499
500    // Determine which src ids to iterate over.
501    let srcs: Vec<u32> = match anchor {
502        ViaAnchor::Src(src_id) => {
503            if g.labels.get(src_id as usize).copied() == Some(src_sym) {
504                vec![src_id]
505            } else {
506                return BTreeMap::new();
507            }
508        }
509        ViaAnchor::Dst(_) => {
510            // Scan all src-label nodes.
511            (0..g.ids.len() as u32)
512                .filter(|&id| {
513                    matches!(
514                        g.labels.get(id as usize).copied(),
515                        Some(s) if s != u32::MAX && s == src_sym
516                    )
517                })
518                .collect()
519        }
520    };
521
522    // Collect dst candidates: all dst-label nodes (or just the anchored dst).
523    let anchored_dst: Option<u32> = match anchor {
524        ViaAnchor::Dst(dst_id) => {
525            if g.labels.get(dst_id as usize).copied() == Some(dst_sym) {
526                Some(dst_id)
527            } else {
528                return BTreeMap::new();
529            }
530        }
531        _ => None,
532    };
533
534    let mut out = BTreeMap::new();
535
536    for src in srcs {
537        let _src_key = match g.ids.key_of(src) {
538            Some(k) => k,
539            None => continue,
540        };
541        // Expand via hops from src.
542        let via_neighbors: Vec<u32> = g
543            .topo
544            .neighbors(via_etype, via_dir, src)
545            .iter()
546            .copied()
547            .filter(|&v| g.labels.get(v as usize).copied() == Some(via_sym))
548            .collect();
549
550        if via_neighbors.is_empty() {
551            continue;
552        }
553
554        // Collect dsts to evaluate: all dst-label nodes or just anchored one.
555        let dsts: Vec<u32> = if let Some(dst_id) = anchored_dst {
556            vec![dst_id]
557        } else {
558            (0..g.ids.len() as u32)
559                .filter(|&id| {
560                    id != src
561                        && matches!(
562                            g.labels.get(id as usize).copied(),
563                            Some(s) if s != u32::MAX && s == dst_sym
564                        )
565                })
566                .collect()
567        };
568
569        for dst in dsts {
570            if dst == src {
571                continue; // no self-edges
572            }
573            let dst_key = match g.ids.key_of(dst) {
574                Some(k) => k,
575                None => continue,
576            };
577            let dst_get = |f: &str| g.props.get(dst, f).map(|vr| vr.into_value());
578            let dst_view = NodeView {
579                key: dst_key,
580                props: &dst_get,
581            };
582
583            // Score = max over via nodes that satisfy predicate(via, dst).
584            let mut best: Option<f64> = None;
585            for &via_id in &via_neighbors {
586                let via_key = match g.ids.key_of(via_id) {
587                    Some(k) => k,
588                    None => continue,
589                };
590                let via_get = |f: &str| g.props.get(via_id, f).map(|vr| vr.into_value());
591                let via_view = NodeView {
592                    key: via_key,
593                    props: &via_get,
594                };
595                if let Some(score) = evaluate(&def.predicate, &via_view, &dst_view) {
596                    best = Some(match best {
597                        None => score,
598                        Some(prev) => prev.max(score),
599                    });
600                }
601            }
602
603            if let Some(score) = best {
604                out.insert((src, dst), score);
605            }
606        }
607    }
608
609    out
610}
611
612/// Anchor point for `compute_desired_via`.
613enum ViaAnchor {
614    /// Expand from one src node (src prop change or src insert).
615    Src(u32),
616    /// Re-evaluate all srcs that can reach some via satisfying predicate with
617    /// this dst (dst prop change).
618    Dst(u32),
619}
620
621fn edge_budget(def: &RuleDef) -> u64 {
622    // Only applies when max_edges is None (global-budget path).
623    // Some(k) rules use per-source top-k semantics, not this budget.
624    def.max_edges.unwrap_or(DEFAULT_MAX_EDGES)
625}
626
627/// Filter a per-source candidate map to the top-k destinations.
628///
629/// `per_src` must contain only pairs with the same source node (all
630/// `(src, dst)` keys share the same `src`).  Returns the top-`k` subset
631/// ordered by **(score DESC, dst_key ASC)** — higher scores win; ties are
632/// broken by the destination node's string key in ascending lexicographic
633/// order, giving a deterministic result independent of internal node IDs.
634///
635/// When `k` equals or exceeds the number of candidates, the input is
636/// returned unchanged (no allocation).
637///
638/// # Memory cost (per-source candidate ordering)
639///
640/// This function sorts and truncates a `Vec<((u32,u32), f64)>` of length
641/// equal to the number of matching candidates for one source.  That is
642/// O(M) per call, where M is the candidate count for this source.  Across
643/// a backfill sweep the peak additional memory is O(M_max) — the largest
644/// per-source candidate set — not the global total, because the Vec is
645/// dropped after each source.  No persistent per-source ordering is
646/// maintained beyond the materialized top-k provenance; backfill and
647/// rebuild recompute the ordering on demand from the live candidate index.
648pub(crate) fn filter_src_top_k(
649    per_src: BTreeMap<(u32, u32), f64>,
650    k: u64,
651    ids: &core_storage::IdMap,
652) -> BTreeMap<(u32, u32), f64> {
653    if per_src.len() as u64 <= k {
654        return per_src;
655    }
656    let mut candidates: Vec<((u32, u32), f64)> = per_src.into_iter().collect();
657    // Sort: score DESC (higher = better), then dst_key ASC as tiebreak.
658    candidates.sort_by(|&((_, da), sa), &((_, db), sb)| {
659        sb.total_cmp(&sa).then_with(|| {
660            let ka = ids.key_of(da).unwrap_or("");
661            let kb = ids.key_of(db).unwrap_or("");
662            ka.cmp(kb)
663        })
664    });
665    candidates.truncate(k as usize);
666    candidates.into_iter().collect()
667}
668
669/// Apply top-k derived-edge semantics for a single source node.
670///
671/// Retracts `(src, *)` provenance edges not in `desired_from_src`, then
672/// adds / refreshes weights for those that are.  Does **not** use the
673/// global tripped latch or budget check — top-k rules (`max_edges: Some(k)`)
674/// are self-capping by construction.
675fn apply_per_src_top_k(
676    def: &RuleDef,
677    src: u32,
678    desired_from_src: BTreeMap<(u32, u32), f64>,
679    prov: &mut ProvSets<'_>,
680    g: &mut GraphMut<'_>,
681) {
682    let et = g.syms.intern(&def.edge_type);
683
684    // Collect current (src, *) provenance triples for this rule.
685    // We filter to s == src so that (*, src) triples — where src is a dst
686    // for some other source — are not mistakenly retracted.
687    let current: Vec<Triple> = {
688        let rid = prov.rule_intern.get(&def.name).copied();
689        prov.by_node
690            .get(&src)
691            .into_iter()
692            .flatten()
693            .filter(|(r, t, s, _d)| Some(*r) == rid && *t == et && *s == src)
694            .map(|(_, t, s, d)| (*t, *s, *d))
695            .collect()
696    };
697
698    // Retract (src, dst) pairs no longer in the top-k.
699    for (t, s, d) in current {
700        if !desired_from_src.contains_key(&(s, d)) {
701            g.topo.remove_edge(t, s, d);
702            g.edge_props.remove_edge(t, s, d);
703            prov.remove(&def.name, (t, s, d), g.ids, g.syms);
704        }
705    }
706
707    // Insert new top-k pairs; refresh weights on already-owned pairs.
708    for ((s, d), score) in &desired_from_src {
709        let triple = (et, *s, *d);
710        let already = prov.contains(&triple);
711        if !already {
712            let newly = g.topo.add_edge(et, *s, *d);
713            if newly {
714                prov.insert(&def.name, triple, g.ids, g.syms);
715            }
716        }
717        let is_owned = already || prov.contains(&triple);
718        if is_owned {
719            if let Some(p) = &def.weight_prop {
720                g.edge_props.set(et, *s, *d, p, Value::Float(*score));
721            }
722        }
723    }
724}
725
726/// Never recycles ids. See `RuleEngine::rule_intern` for why.
727fn intern_rule(intern: &mut BTreeMap<String, u32>, names: &mut Vec<String>, rule: &str) -> u32 {
728    if let Some(&id) = intern.get(rule) {
729        return id;
730    }
731    let id = names.len() as u32;
732    intern.insert(rule.to_string(), id);
733    names.push(rule.to_string());
734    id
735}
736
737type ByNodeRebuild = (
738    BTreeMap<u32, BTreeSet<Touch>>,
739    BTreeMap<String, u32>,
740    Vec<String>,
741);
742
743fn rebuild_by_node(provenance: &BTreeMap<String, BTreeSet<Triple>>) -> ByNodeRebuild {
744    let mut by_node = BTreeMap::new();
745    let mut intern = BTreeMap::new();
746    let mut names = Vec::new();
747    for (rule, set) in provenance {
748        let rid = intern_rule(&mut intern, &mut names, rule);
749        for &triple in set {
750            touch_insert(&mut by_node, rid, triple);
751        }
752    }
753    (by_node, intern, names)
754}
755
756fn touch_insert(by_node: &mut BTreeMap<u32, BTreeSet<Touch>>, rid: u32, triple: Triple) {
757    let (t, s, d) = triple;
758    let entry = (rid, t, s, d);
759    by_node.entry(s).or_default().insert(entry);
760    if s != d {
761        by_node.entry(d).or_default().insert(entry);
762    }
763}
764
765fn touch_remove(by_node: &mut BTreeMap<u32, BTreeSet<Touch>>, rid: u32, triple: Triple) {
766    let (t, s, d) = triple;
767    let entry = (rid, t, s, d);
768    if let Some(set) = by_node.get_mut(&s) {
769        set.remove(&entry);
770        if set.is_empty() {
771            by_node.remove(&s);
772        }
773    }
774    if s != d {
775        if let Some(set) = by_node.get_mut(&d) {
776            set.remove(&entry);
777            if set.is_empty() {
778                by_node.remove(&d);
779            }
780        }
781    }
782}
783
784#[cfg(test)]
785fn resolve_by_node(
786    by_node: &BTreeMap<u32, BTreeSet<Touch>>,
787    names: &[String],
788) -> BTreeMap<u32, BTreeSet<(String, Triple)>> {
789    by_node
790        .iter()
791        .map(|(&n, set)| {
792            let resolved = set
793                .iter()
794                .map(|&(rid, t, s, d)| (names[rid as usize].clone(), (t, s, d)))
795                .collect();
796            (n, resolved)
797        })
798        .collect()
799}
800
801/// Mutable provenance + derived reverse index. Every insert/remove goes
802/// through [`ProvSets::insert`] / [`ProvSets::remove`].
803struct ProvSets<'a> {
804    set: &'a mut BTreeSet<Triple>,
805    owned: &'a mut BTreeSet<Triple>,
806    by_node: &'a mut BTreeMap<u32, BTreeSet<Touch>>,
807    rule_intern: &'a mut BTreeMap<String, u32>,
808    intern_rule: &'a mut Vec<String>,
809    /// Staging buffer for post-commit events. Keys are resolved at capture
810    /// time (before any tombstone step) so the strings remain valid after
811    /// node deletion.
812    deltas: &'a mut Vec<EngineEdgeDelta>,
813    /// Mirror of [`RuleEngine::emit_deltas`]: when `false`, pushes to
814    /// `deltas` are skipped entirely (no heap allocation, no String clone).
815    emit: bool,
816}
817
818impl ProvSets<'_> {
819    /// `ids` and `syms` are passed by the caller (not stored in ProvSets) to
820    /// avoid a conflicting borrow when callers also need `&mut g.syms` for
821    /// `intern` calls in the same function body.
822    fn insert(&mut self, rule: &str, triple: Triple, ids: &IdMap, syms: &Interner) -> bool {
823        if !self.set.insert(triple) {
824            return false;
825        }
826        self.owned.insert(triple);
827        let rid = intern_rule(self.rule_intern, self.intern_rule, rule);
828        touch_insert(self.by_node, rid, triple);
829        let (etype, src, dst) = triple;
830        if self.emit {
831            if let (Some(sk), Some(dk), Some(et)) =
832                (ids.key_of(src), ids.key_of(dst), syms.resolve(etype))
833            {
834                self.deltas.push(EngineEdgeDelta {
835                    rule: rule.to_string(),
836                    src_key: sk.to_string(),
837                    dst_key: dk.to_string(),
838                    edge_type: et.to_string(),
839                    etype_sym: etype,
840                    src_id: src,
841                    dst_id: dst,
842                    fired: true,
843                });
844            }
845        }
846        true
847    }
848
849    fn remove(&mut self, rule: &str, triple: Triple, ids: &IdMap, syms: &Interner) -> bool {
850        if !self.set.remove(&triple) {
851            return false;
852        }
853        self.owned.remove(&triple);
854        let rid = intern_rule(self.rule_intern, self.intern_rule, rule);
855        touch_remove(self.by_node, rid, triple);
856        let (etype, src, dst) = triple;
857        if self.emit {
858            if let (Some(sk), Some(dk), Some(et)) =
859                (ids.key_of(src), ids.key_of(dst), syms.resolve(etype))
860            {
861                self.deltas.push(EngineEdgeDelta {
862                    rule: rule.to_string(),
863                    src_key: sk.to_string(),
864                    dst_key: dk.to_string(),
865                    edge_type: et.to_string(),
866                    etype_sym: etype,
867                    src_id: src,
868                    dst_id: dst,
869                    fired: false,
870                });
871            }
872        }
873        true
874    }
875
876    fn contains(&self, triple: &Triple) -> bool {
877        self.set.contains(triple)
878    }
879
880    fn len(&self) -> usize {
881        self.set.len()
882    }
883}
884
885/// Diff-apply `desired` against provenance. `retract_touching = Some(n)` only
886/// retracts triples that involve `n` (incremental fire). `None` retracts any
887/// current provenance triple not in `desired` (backfill / rebuild).
888///
889/// `tripped` is a one-way latch: once set, no new provenance edges are added
890/// (gate on the flag itself, not `prov.len()`), even if retracts have brought
891/// the set below budget. Retracts and weight refreshes on already-owned edges
892/// still run. Crossing the budget on a not-yet-tripped rule sets the latch
893/// and skips that add and every later add in this call. Never an error.
894/// First-N (pre-trip) is BTree `(src, dst)` order of `desired` after retract.
895fn apply_desired(
896    def: &RuleDef,
897    desired: BTreeMap<(u32, u32), f64>,
898    retract_touching: Option<u32>,
899    prov: &mut ProvSets<'_>,
900    tripped: &mut bool,
901    g: &mut GraphMut<'_>,
902) {
903    let budget = edge_budget(def);
904    let et = g.syms.intern(&def.edge_type);
905
906    let current: Vec<Triple> = match retract_touching {
907        None => prov
908            .set
909            .iter()
910            .filter(|(t, _, _)| *t == et)
911            .copied()
912            .collect(),
913        Some(n) => {
914            let rid = prov.rule_intern.get(&def.name).copied();
915            prov.by_node
916                .get(&n)
917                .into_iter()
918                .flatten()
919                .filter(|(r, t, _, _)| Some(*r) == rid && *t == et)
920                .map(|(_, t, s, d)| (*t, *s, *d))
921                .collect()
922        }
923    };
924
925    for (t, s, d) in current {
926        if !desired.contains_key(&(s, d)) {
927            g.topo.remove_edge(t, s, d);
928            g.edge_props.remove_edge(t, s, d);
929            prov.remove(&def.name, (t, s, d), g.ids, g.syms);
930        }
931    }
932
933    for ((s, d), score) in desired {
934        let triple = (et, s, d);
935        let already = prov.contains(&triple);
936        if !already {
937            if *tripped || prov.len() as u64 >= budget {
938                *tripped = true;
939                continue;
940            }
941            let newly = g.topo.add_edge(et, s, d);
942            if newly {
943                prov.insert(&def.name, triple, g.ids, g.syms);
944            }
945        }
946        // Only set weight_prop on edges this rule owns (newly added now, or
947        // already in provenance). Pre-existing user edges are never owned, so
948        // writing a weight to them would leave a ghost property after deletion.
949        let is_owned_here = already || prov.contains(&triple);
950        if is_owned_here {
951            if let Some(p) = &def.weight_prop {
952                g.edge_props.set(et, s, d, p, Value::Float(score));
953            }
954        }
955    }
956}
957
958/// Union of `compute_desired(..., as_src)` over every live src-label node.
959/// BTree iteration of the result is the engine's deterministic first-N order
960/// for backfill and rebuild.
961///
962/// Kept for test reference comparators only.  Production paths use the
963/// streaming variants (`apply_streaming_create`, `apply_streaming_rebuild`)
964/// which never materialise the global map.
965#[cfg(test)]
966#[allow(dead_code)]
967fn compute_full_desired(
968    def: &RuleDef,
969    index: &RuleIndex,
970    g: &GraphMut<'_>,
971) -> BTreeMap<(u32, u32), f64> {
972    let mut desired = BTreeMap::new();
973    let src_sym = g.syms.get(&def.src_label);
974    for id in 0..g.ids.len() as u32 {
975        let label_sym = match g.labels.get(id as usize).copied() {
976            Some(s) if s != u32::MAX => s,
977            _ => continue,
978        };
979        if src_sym == Some(label_sym) {
980            desired.extend(compute_desired(def, index, id, true, g));
981            #[cfg(test)]
982            record_desired_len(desired.len());
983        }
984    }
985    desired
986}
987
988/// Returns `true` if the `(s, d)` pair is still desired under `def` given the
989/// current graph state.  Calls `evaluate` directly — bypasses the candidate
990/// index.  Valid in `rebuild` after a full reindex because every pair that
991/// evaluates to `Some` is reachable via the freshly-built index; the direct
992/// call is therefore semantically equivalent to membership in
993/// `compute_desired(def, index, s, true, g)`.  O(eval) per call.
994fn pair_still_desired(def: &RuleDef, s: u32, d: u32, g: &GraphMut<'_>) -> bool {
995    let src_sym = match g.syms.get(&def.src_label) {
996        Some(sym) => sym,
997        None => return false,
998    };
999    let dst_sym = match g.syms.get(&def.dst_label) {
1000        Some(sym) => sym,
1001        None => return false,
1002    };
1003    if g.labels.get(s as usize).copied() != Some(src_sym) {
1004        return false;
1005    }
1006    if g.labels.get(d as usize).copied() != Some(dst_sym) {
1007        return false;
1008    }
1009    let s_key = match g.ids.key_of(s) {
1010        Some(k) => k,
1011        None => return false,
1012    };
1013    let d_key = match g.ids.key_of(d) {
1014        Some(k) => k,
1015        None => return false,
1016    };
1017    let s_get = |f: &str| g.props.get(s, f).map(|vr| vr.into_value());
1018    let d_get = |f: &str| g.props.get(d, f).map(|vr| vr.into_value());
1019    evaluate(
1020        &def.predicate,
1021        &NodeView {
1022            key: s_key,
1023            props: &s_get,
1024        },
1025        &NodeView {
1026            key: d_key,
1027            props: &d_get,
1028        },
1029    )
1030    .is_some()
1031}
1032
1033/// Count desired `(src, dst)` pairs up to `limit + 1`; returns as soon as the
1034/// threshold is crossed.  Peak additional memory: O(max-candidates-per-src).
1035/// Used in `rebuild` to detect the over-budget case without materialising the
1036/// full desired map.
1037fn count_desired_up_to(def: &RuleDef, index: &RuleIndex, limit: u64, g: &GraphMut<'_>) -> u64 {
1038    let mut count = 0u64;
1039    let src_sym = g.syms.get(&def.src_label);
1040    for id in 0..g.ids.len() as u32 {
1041        let label_sym = match g.labels.get(id as usize).copied() {
1042            Some(s) if s != u32::MAX => s,
1043            _ => continue,
1044        };
1045        if src_sym != Some(label_sym) {
1046            continue;
1047        }
1048        count += compute_desired(def, index, id, true, g).len() as u64;
1049        if count > limit {
1050            return count;
1051        }
1052    }
1053    count
1054}
1055
1056/// Streaming backfill for `create_rule`.
1057///
1058/// Iterates src nodes in ascending id order.  For each src, computes and
1059/// immediately applies the per-src desired edges (already dst-sorted within
1060/// that src).  This traversal visits `(src, dst)` pairs in exactly the same
1061/// order as iterating the result of `compute_full_desired` would — because the
1062/// global BTree order on `(u32, u32)` keys is src-major ascending with
1063/// dst-sorted-within, matching the per-src ascending-dst order emitted by
1064/// `compute_desired`.
1065///
1066/// Consequently the first-N edges selected by the running cap are byte-identical
1067/// to what the old full-map approach would have selected for EXACT rules
1068/// (`def.approximate == false`).  For approximate rules the IVF candidate order
1069/// is deterministic (replay-identical within the same fitted clusters) but is not
1070/// equivalent to the old full-map path, which was never exercised for approximate
1071/// rules.
1072///
1073/// Cap semantics: on `create_rule` there is no pre-existing provenance for
1074/// this rule, so when the cap trips we can `break` immediately — no
1075/// weight-refresh-on-existing path can be skipped, because every remaining
1076/// `already == false` entry would have been `continue`-d by the old loop too.
1077fn apply_streaming_create(
1078    def: &RuleDef,
1079    index: &RuleIndex,
1080    prov: &mut ProvSets<'_>,
1081    tripped: &mut bool,
1082    g: &mut GraphMut<'_>,
1083) {
1084    let budget = edge_budget(def);
1085    let et = g.syms.intern(&def.edge_type);
1086    let src_sym = g.syms.get(&def.src_label);
1087
1088    'outer: for id in 0..g.ids.len() as u32 {
1089        let label_sym = match g.labels.get(id as usize).copied() {
1090            Some(s) if s != u32::MAX => s,
1091            _ => continue,
1092        };
1093        if src_sym != Some(label_sym) {
1094            continue;
1095        }
1096        let per_src = compute_desired(def, index, id, true, g);
1097        for ((s, d), score) in per_src {
1098            let triple = (et, s, d);
1099            // On create_rule, this rule has no pre-existing provenance, so
1100            // `already` is always false.  The path is kept for correctness
1101            // if the same edge is co-owned by another rule (topo.add_edge
1102            // returns false; we do not record it in our provenance).
1103            let already = prov.contains(&triple);
1104            if !already {
1105                if *tripped || prov.len() as u64 >= budget {
1106                    *tripped = true;
1107                    break 'outer;
1108                }
1109                let newly = g.topo.add_edge(et, s, d);
1110                if newly {
1111                    prov.insert(&def.name, triple, g.ids, g.syms);
1112                }
1113            }
1114            let is_owned_here = already || prov.contains(&triple);
1115            if is_owned_here {
1116                if let Some(p) = &def.weight_prop {
1117                    g.edge_props.set(et, s, d, p, Value::Float(score));
1118                }
1119            }
1120        }
1121    }
1122}
1123
1124/// Streaming backfill for `create_rule` with top-k per-source semantics.
1125///
1126/// Iterates src-label nodes in ascending id order. For each src, computes
1127/// the full candidate set, filters to the top-k destinations (score DESC,
1128/// dst_key ASC), and applies via `apply_per_src_top_k`.  No global budget or
1129/// tripped latch is used — the per-source cap is enforced by `filter_src_top_k`.
1130fn apply_streaming_create_top_k(
1131    def: &RuleDef,
1132    k: u64,
1133    index: &RuleIndex,
1134    prov: &mut ProvSets<'_>,
1135    g: &mut GraphMut<'_>,
1136) {
1137    let src_sym = g.syms.get(&def.src_label);
1138    for id in 0..g.ids.len() as u32 {
1139        let label_sym = match g.labels.get(id as usize).copied() {
1140            Some(s) if s != u32::MAX => s,
1141            _ => continue,
1142        };
1143        if src_sym != Some(label_sym) {
1144            continue;
1145        }
1146        let per_src = compute_desired(def, index, id, true, g);
1147        let top_k = filter_src_top_k(per_src, k, g.ids);
1148        apply_per_src_top_k(def, id, top_k, prov, g);
1149    }
1150}
1151
1152/// Streaming rebuild for top-k per-source rules.
1153///
1154/// Iterates all src-label nodes. For each src, computes fresh desired set,
1155/// filters to top-k, and applies via `apply_per_src_top_k` — which retracts
1156/// stale edges and inserts newly-ranked ones.  No budget counting, no tripped
1157/// latch.
1158fn apply_streaming_rebuild_top_k(
1159    def: &RuleDef,
1160    k: u64,
1161    index: &RuleIndex,
1162    prov: &mut ProvSets<'_>,
1163    g: &mut GraphMut<'_>,
1164) {
1165    let et = g.syms.intern(&def.edge_type);
1166
1167    // Collect all src nodes: those currently in provenance (may have lost their
1168    // label since last fire) + all live src-label nodes.
1169    let existing_srcs: BTreeSet<u32> = prov
1170        .set
1171        .iter()
1172        .filter(|(t, _, _)| *t == et)
1173        .map(|(_, s, _)| *s)
1174        .collect();
1175
1176    let src_sym = g.syms.get(&def.src_label);
1177    let mut all_srcs: BTreeSet<u32> = existing_srcs;
1178    for id in 0..g.ids.len() as u32 {
1179        let label_sym = match g.labels.get(id as usize).copied() {
1180            Some(s) if s != u32::MAX => s,
1181            _ => continue,
1182        };
1183        if src_sym == Some(label_sym) {
1184            all_srcs.insert(id);
1185        }
1186    }
1187
1188    for src in all_srcs {
1189        let desired_src = compute_desired(def, index, src, true, g);
1190        let top_k = filter_src_top_k(desired_src, k, g.ids);
1191        apply_per_src_top_k(def, src, top_k, prov, g);
1192    }
1193}
1194
1195/// Streaming rebuild for `rebuild`.
1196///
1197/// Replaces `compute_full_desired` + size-check + `apply_desired(None)`.
1198///
1199/// Over-budget path: counts desired pairs up to `budget + 1` (early exit);
1200/// if the total exceeds the budget, sets the tripped latch and returns without
1201/// touching provenance (identical to the current no-op rebuild behaviour).
1202///
1203/// Fits-budget path: un-trips, retracts each existing provenance triple whose
1204/// pair is no longer desired via direct `evaluate` — O(existing × eval) —
1205/// then streams-adds all desired edges in the same src-ascending / dst-within
1206/// order as `apply_streaming_create`.
1207fn apply_streaming_rebuild(
1208    def: &RuleDef,
1209    index: &RuleIndex,
1210    prov: &mut ProvSets<'_>,
1211    tripped: &mut bool,
1212    g: &mut GraphMut<'_>,
1213) {
1214    let budget = edge_budget(def);
1215    let et = g.syms.intern(&def.edge_type);
1216
1217    // 1. Count: early-exit as soon as the budget is exceeded.
1218    let total = count_desired_up_to(def, index, budget, g);
1219    if total > budget {
1220        *tripped = true;
1221        return; // provenance untouched; latch stays set.
1222    }
1223
1224    // 2. Un-trip — full desired fits.
1225    *tripped = false;
1226
1227    // 3. Retract existing edges that are no longer desired.
1228    //    O(existing × eval): evaluate each pair directly, no full-map build.
1229    let current: Vec<Triple> = prov
1230        .set
1231        .iter()
1232        .filter(|(t, _, _)| *t == et)
1233        .copied()
1234        .collect();
1235    for (t, s, d) in current {
1236        if !pair_still_desired(def, s, d, g) {
1237            g.topo.remove_edge(t, s, d);
1238            g.edge_props.remove_edge(t, s, d);
1239            prov.remove(&def.name, (t, s, d), g.ids, g.syms);
1240        }
1241    }
1242
1243    // 4. Stream-add desired edges; refresh weights on already-owned edges.
1244    //    count_desired_up_to verified total <= budget so no trip guard is needed.
1245    let src_sym = g.syms.get(&def.src_label);
1246    for id in 0..g.ids.len() as u32 {
1247        let label_sym = match g.labels.get(id as usize).copied() {
1248            Some(s) if s != u32::MAX => s,
1249            _ => continue,
1250        };
1251        if src_sym != Some(label_sym) {
1252            continue;
1253        }
1254        let per_src = compute_desired(def, index, id, true, g);
1255        for ((s, d), score) in per_src {
1256            let triple = (et, s, d);
1257            let already = prov.contains(&triple);
1258            if !already {
1259                let newly = g.topo.add_edge(et, s, d);
1260                if newly {
1261                    prov.insert(&def.name, triple, g.ids, g.syms);
1262                }
1263            }
1264            let is_owned_here = already || prov.contains(&triple);
1265            if is_owned_here {
1266                if let Some(p) = &def.weight_prop {
1267                    g.edge_props.set(et, s, d, p, Value::Float(score));
1268                }
1269            }
1270        }
1271    }
1272}
1273
1274/// Increment `fires` once per live node whose label matches either side.
1275/// Backfill / rebuild counting: one tick per participating node evaluated.
1276fn bump_fires_for_participants(def: &RuleDef, g: &GraphMut<'_>, fires: &mut u64) {
1277    let src_sym = g.syms.get(&def.src_label);
1278    let dst_sym = g.syms.get(&def.dst_label);
1279    for id in 0..g.ids.len() as u32 {
1280        let label_sym = match g.labels.get(id as usize).copied() {
1281            Some(s) if s != u32::MAX => s,
1282            _ => continue,
1283        };
1284        if src_sym == Some(label_sym) || dst_sym == Some(label_sym) {
1285            *fires += 1;
1286        }
1287    }
1288}
1289
1290/// Insert node `id` into the rule's src/dst indexes where its label matches.
1291fn index_node_for_rule(
1292    id: u32,
1293    label_sym: u32,
1294    def: &RuleDef,
1295    index: &mut RuleIndex,
1296    syms: &Interner,
1297    props: ColumnsView<'_>,
1298) {
1299    let get = |f: &str| props.get(id, f).map(|vr| vr.into_value());
1300    if syms.get(&def.src_label) == Some(label_sym) {
1301        let spec = src_lookup_spec_for(def);
1302        index.src_side.insert(&spec, id, &get);
1303    }
1304    if syms.get(&def.dst_label) == Some(label_sym) {
1305        let spec = candidate_spec_for(def);
1306        index.dst_side.insert(&spec, id, &get);
1307    }
1308}
1309
1310// ---------------------------------------------------------------------------
1311// RuleEngine
1312// ---------------------------------------------------------------------------
1313
1314impl RuleEngine {
1315    pub fn new() -> Self {
1316        Self::default()
1317    }
1318
1319    pub fn rules(&self) -> impl Iterator<Item = &RuleDef> {
1320        self.rules.values()
1321    }
1322
1323    pub fn is_owned(&self, etype: u32, src: u32, dst: u32) -> bool {
1324        self.owned.contains(&(etype, src, dst))
1325    }
1326
1327    /// Whether provenance bytes are still retained (not yet consumed by a mutation).
1328    ///
1329    /// When `true`, `provenance()` and `provenance_touching*` dispatch to
1330    /// `lazy_provenance`; when `false` they use the live mutable fields.
1331    fn provenance_is_retained(&self) -> bool {
1332        self.retained_provenance_bytes
1333            .lock()
1334            .expect("lock poisoned")
1335            .is_some()
1336    }
1337
1338    /// Read-only view of the provenance map: rule name → set of (etype_sym, src, dst).
1339    ///
1340    /// Triggers a one-time lazy decode from retained bytes when called before
1341    /// the first mutation on a clean-open (no-WAL) store.
1342    pub fn provenance(&self) -> &BTreeMap<String, BTreeSet<(u32, u32, u32)>> {
1343        if self.provenance_is_retained() {
1344            self.ensure_provenance_loaded();
1345            &self.lazy_provenance.get().unwrap().provenance
1346        } else {
1347            &self.provenance
1348        }
1349    }
1350
1351    /// O(degree) reverse-index lookup: every provenance triple that touches `node`.
1352    ///
1353    /// Dispatches to the lazy-decoded or live index depending on whether
1354    /// retained bytes have already been consumed by a mutation.
1355    pub fn provenance_touching(
1356        &self,
1357        node: u32,
1358    ) -> impl Iterator<Item = (&str, u32, u32, u32)> + '_ {
1359        let use_lazy = self.provenance_is_retained();
1360        let (by_node, intern_rule): (&BTreeMap<u32, BTreeSet<Touch>>, &Vec<String>) = if use_lazy {
1361            self.ensure_provenance_loaded();
1362            let lp = self.lazy_provenance.get().unwrap();
1363            (&lp.by_node, &lp.intern_rule)
1364        } else {
1365            (&self.by_node, &self.intern_rule)
1366        };
1367        by_node
1368            .get(&node)
1369            .into_iter()
1370            .flatten()
1371            .map(move |&(rid, t, s, d)| (intern_rule[rid as usize].as_str(), t, s, d))
1372    }
1373
1374    /// Number of provenance triples incident on `node`.
1375    pub fn provenance_touching_len(&self, node: u32) -> usize {
1376        if self.provenance_is_retained() {
1377            self.ensure_provenance_loaded();
1378            self.lazy_provenance
1379                .get()
1380                .unwrap()
1381                .by_node
1382                .get(&node)
1383                .map_or(0, BTreeSet::len)
1384        } else {
1385            self.by_node.get(&node).map_or(0, BTreeSet::len)
1386        }
1387    }
1388
1389    /// One-way latch: `true` after a budget breach until [`Self::rebuild`]
1390    /// is the only exit (and only if the full desired set then fits).
1391    pub fn is_tripped(&self, name: &str) -> bool {
1392        self.tripped.get(name).copied().unwrap_or(false)
1393    }
1394
1395    /// Evaluations of this rule: one tick per `on_node_changed` fire, and
1396    /// one tick per participating node on backfill **and rebuild** (even
1397    /// when rebuild is a provenance no-op).
1398    pub fn fire_count(&self, name: &str) -> u64 {
1399        self.fires.get(name).copied().unwrap_or(0)
1400    }
1401
1402    /// Drain and return all pending edge-fire / retract deltas since the last
1403    /// call.  Callers (`db.rs` `log_then_apply_with`) invoke this after a
1404    /// successful WAL commit + apply to build [`DbEvent`]s for live
1405    /// subscriptions.  [`GraphDb::open_with`] drains and discards after WAL
1406    /// replay so replay noise never leaks to subscribers.
1407    ///
1408    /// # T2 note (as-of replay)
1409    ///
1410    /// When Plan-15 T2 adds as-of replay for subscribers, that path should
1411    /// call apply-only (no `log_then_apply_with`) and then call
1412    /// `drain_deltas()` to feed those events to the replaying subscriber.
1413    /// The suppression is already in place: `apply` accumulates but never
1414    /// emits; `drain_deltas` is the only emission gate.
1415    pub fn drain_deltas(&mut self) -> Vec<EngineEdgeDelta> {
1416        std::mem::take(&mut self.pending_deltas)
1417    }
1418
1419    /// Number of accumulated deltas not yet drained.  Used by
1420    /// `debug_assert` in `log_then_apply_with` to catch stale-delta bugs.
1421    pub fn pending_delta_count(&self) -> usize {
1422        self.pending_deltas.len()
1423    }
1424
1425    /// Borrow the slice of deltas accumulated since `cursor` without
1426    /// consuming them.  `cursor` should be the value returned by
1427    /// `pending_delta_count()` before an engine call.
1428    ///
1429    /// The returned slice is valid until the next call to `drain_deltas()`.
1430    /// T1's drain discipline is preserved: these deltas are still in the
1431    /// buffer and will be drained by `log_then_apply_with` after `apply`
1432    /// returns.
1433    pub fn pending_deltas_since(&self, cursor: usize) -> &[EngineEdgeDelta] {
1434        &self.pending_deltas[cursor..]
1435    }
1436
1437    /// Snapshot support: definitions + provenance + tripped/fires. Candidate
1438    /// indexes and the `by_node` reverse index are NOT included (derived:
1439    /// `reindex_all` / `rebuild_by_node` on open).
1440    #[allow(clippy::type_complexity)]
1441    pub fn to_persist(
1442        &self,
1443    ) -> (
1444        Vec<RuleDef>,
1445        BTreeMap<String, BTreeSet<(u32, u32, u32)>>,
1446        BTreeMap<String, bool>,
1447        BTreeMap<String, u64>,
1448    ) {
1449        (
1450            self.rules.values().cloned().collect(),
1451            self.provenance.clone(),
1452            self.tripped.clone(),
1453            self.fires.clone(),
1454        )
1455    }
1456
1457    /// Reconstruct engine from a snapshot.  Caller must call `reindex_all` after.
1458    pub fn from_persist(
1459        rules: Vec<RuleDef>,
1460        prov: BTreeMap<String, BTreeSet<(u32, u32, u32)>>,
1461        tripped: BTreeMap<String, bool>,
1462        fires: BTreeMap<String, u64>,
1463    ) -> Self {
1464        let mut owned = BTreeSet::new();
1465        for set in prov.values() {
1466            owned.extend(set.iter().copied());
1467        }
1468        let indexes = rules
1469            .iter()
1470            .map(|r| (r.name.clone(), RuleIndex::default()))
1471            .collect();
1472        let rules: BTreeMap<String, RuleDef> =
1473            rules.into_iter().map(|r| (r.name.clone(), r)).collect();
1474        // Fill any missing keys so live rules always have entries.
1475        let mut tripped = tripped;
1476        let mut fires = fires;
1477        for name in rules.keys() {
1478            tripped.entry(name.clone()).or_insert(false);
1479            fires.entry(name.clone()).or_insert(0);
1480        }
1481        let (by_node, rule_intern, intern_rule) = rebuild_by_node(&prov);
1482        Self {
1483            rules,
1484            indexes,
1485            provenance: prov,
1486            owned,
1487            by_node,
1488            rule_intern,
1489            intern_rule,
1490            tripped,
1491            fires,
1492            pending_deltas: Vec::new(),
1493            emit_deltas: false,
1494            rebuild_needed: BTreeSet::new(),
1495            // Candidate indexes start empty; caller must either call
1496            // consume_retained_state_eager (WAL-present open) or rely on the
1497            // lazy init in the mutation hooks (clean open, first-write cost).
1498            indexes_populated: false,
1499            retained_hnsw_blobs: Mutex::new(BTreeMap::new()),
1500            retained_ivf_bytes: Mutex::new(None),
1501            retained_provenance_bytes: Mutex::new(None),
1502            lazy_provenance: OnceLock::new(),
1503            lazy_hnsw: OnceLock::new(),
1504        }
1505    }
1506
1507    /// Enable or disable delta accumulation.
1508    ///
1509    /// Set to `true` before the first subscriber or view is added.
1510    /// Set to `false` when the last subscriber and last view are removed.
1511    /// See the `emit_deltas` field doc for the safety invariant.
1512    pub fn set_emit_deltas(&mut self, emit: bool) {
1513        self.emit_deltas = emit;
1514    }
1515
1516    /// Whether delta accumulation is currently enabled.
1517    pub fn emit_deltas(&self) -> bool {
1518        self.emit_deltas
1519    }
1520
1521    /// Drain rule names that exceeded the IVF dst-drift rebuild threshold
1522    /// during the most recent `on_node_changed` / `on_node_removed`.
1523    pub fn take_rebuild_needed(&mut self) -> Vec<String> {
1524        std::mem::take(&mut self.rebuild_needed)
1525            .into_iter()
1526            .collect()
1527    }
1528
1529    /// Re-queue `name` so a later write can issue `RebuildRule`.
1530    ///
1531    /// Used when auto-rebuild WAL IO fails after a durable user op.
1532    pub fn queue_rebuild_needed(&mut self, name: String) {
1533        self.rebuild_needed.insert(name);
1534    }
1535
1536    fn maybe_queue_ivf_rebuild(&mut self, rule_name: &str, def: &RuleDef) {
1537        if !def.approximate {
1538            return;
1539        }
1540        let Some(idx) = self.indexes.get(rule_name) else {
1541            return;
1542        };
1543        if idx.dst_side.ivf_drift > ivf_drift_rebuild_threshold() {
1544            self.rebuild_needed.insert(rule_name.to_string());
1545        }
1546    }
1547
1548    /// Export IVF state for all approximate rules.  Passed to `snapshot()` in
1549    /// `core-api` and stored in the V4 snapshot so `open()` can restore cluster
1550    /// assignments without re-fitting k-means.
1551    pub fn export_ivf_state(&self) -> BTreeMap<String, RuleIvfExport> {
1552        let mut out = BTreeMap::new();
1553        for (name, def) in &self.rules {
1554            if def.approximate {
1555                if let Some(idx) = self.indexes.get(name) {
1556                    out.insert(
1557                        name.clone(),
1558                        (
1559                            idx.src_side.export_ivf_state(),
1560                            idx.dst_side.export_ivf_state(),
1561                        ),
1562                    );
1563                }
1564            }
1565        }
1566        out
1567    }
1568
1569    /// Rebuild all candidate indexes by scanning every node.  Call on open.
1570    pub fn reindex_all(
1571        &mut self,
1572        ids: &IdMap,
1573        syms: &Interner,
1574        labels: &[u32],
1575        props: ColumnsView<'_>,
1576    ) {
1577        for idx in self.indexes.values_mut() {
1578            *idx = RuleIndex::default();
1579        }
1580        // Collect rule names once outside the per-node loop to avoid repeated
1581        // allocation and to satisfy the borrow checker without cloning inside.
1582        let rule_names: Vec<String> = self.rules.keys().cloned().collect();
1583
1584        // Init HNSW for approximate rules before inserting nodes.
1585        for name in &rule_names {
1586            if self.rules[name].approximate {
1587                let idx = self.indexes.get_mut(name).unwrap();
1588                idx.src_side.init_hnsw(name);
1589                idx.dst_side.init_hnsw(name);
1590            }
1591        }
1592
1593        for id in 0..ids.len() as u32 {
1594            let label_sym = match labels.get(id as usize).copied() {
1595                Some(s) if s != u32::MAX => s,
1596                _ => continue,
1597            };
1598            for name in &rule_names {
1599                let def = self.rules[name].clone();
1600                let idx = self.indexes.get_mut(name).unwrap();
1601                index_node_for_rule(id, label_sym, &def, idx, syms, props);
1602            }
1603        }
1604        // After all nodes are indexed, fit IVF clusters for approximate rules.
1605        // HNSW was built incrementally; IVF kept as legacy fallback.
1606        for name in &rule_names {
1607            if self.rules[name].approximate {
1608                let idx = self.indexes.get_mut(name).unwrap();
1609                idx.src_side.fit_ivf_clusters(name);
1610                idx.dst_side.fit_ivf_clusters(name);
1611            }
1612        }
1613        self.indexes_populated = true;
1614    }
1615
1616    /// Like `reindex_all` but LOADS persisted IVF state for approximate rules
1617    /// instead of re-fitting k-means.  This eliminates the cold-start re-fit
1618    /// cost when opening a V4 snapshot.
1619    ///
1620    /// `ivf_state`: map from rule name to `(src_export, dst_export)` as
1621    /// produced by `export_ivf_state` / stored in the V4 snapshot.
1622    ///
1623    /// For approximate rules absent from `ivf_state` (e.g. a rule added
1624    /// after the snapshot), falls back to `fit_ivf_clusters`.
1625    pub fn reindex_all_load_ivf(
1626        &mut self,
1627        ids: &IdMap,
1628        syms: &Interner,
1629        labels: &[u32],
1630        props: ColumnsView<'_>,
1631        ivf_state: BTreeMap<String, RuleIvfExport>,
1632    ) {
1633        for idx in self.indexes.values_mut() {
1634            *idx = RuleIndex::default();
1635        }
1636        let rule_names: Vec<String> = self.rules.keys().cloned().collect();
1637
1638        // Init HNSW for approximate rules before inserting nodes so each
1639        // insert also populates the HNSW graph incrementally.
1640        for name in &rule_names {
1641            if self.rules[name].approximate {
1642                let idx = self.indexes.get_mut(name).unwrap();
1643                idx.src_side.init_hnsw(name);
1644                idx.dst_side.init_hnsw(name);
1645            }
1646        }
1647
1648        for id in 0..ids.len() as u32 {
1649            let label_sym = match labels.get(id as usize).copied() {
1650                Some(s) if s != u32::MAX => s,
1651                _ => continue,
1652            };
1653            for name in &rule_names {
1654                let def = self.rules[name].clone();
1655                let idx = self.indexes.get_mut(name).unwrap();
1656                index_node_for_rule(id, label_sym, &def, idx, syms, props);
1657            }
1658        }
1659        // For approximate rules: restore persisted IVF state (no re-fit).
1660        // HNSW was built incrementally; it will be replaced by `load_hnsw_state`
1661        // in `restore_snapshot_state` when a snapshot blob is available.
1662        for name in &rule_names {
1663            if !self.rules[name].approximate {
1664                continue;
1665            }
1666            let idx = self.indexes.get_mut(name).unwrap();
1667            if let Some(((sc, sa, sd), (dc, da, dd))) = ivf_state.get(name) {
1668                idx.src_side.load_ivf_state(sc.clone(), sa.clone(), *sd);
1669                idx.dst_side.load_ivf_state(dc.clone(), da.clone(), *dd);
1670            } else {
1671                // No persisted state for this rule: fall back to full re-fit.
1672                idx.src_side.fit_ivf_clusters(name);
1673                idx.dst_side.fit_ivf_clusters(name);
1674            }
1675        }
1676        self.indexes_populated = true;
1677    }
1678
1679    /// Store HNSW blobs and raw IVF bytes from a snapshot **without deserializing**.
1680    ///
1681    /// Called from `restore_snapshot_state` in db.rs.  Neither the HNSW graphs
1682    /// nor the IVF centroids are materialized here; they are consumed lazily:
1683    ///   - `consume_retained_state_eager` (WAL-present open, before WAL replay)
1684    ///   - The mutation-hook lazy-init guard (clean open, first-write cost)
1685    ///   - `ensure_hnsw_loaded` (first ANN query on a clean open)
1686    pub fn store_snapshot_state(
1687        &self,
1688        hnsw_blobs: BTreeMap<String, (Vec<u8>, Vec<u8>)>,
1689        ivf_bytes: Vec<u8>,
1690    ) {
1691        *self
1692            .retained_hnsw_blobs
1693            .lock()
1694            .expect("retained_hnsw_blobs lock poisoned") = hnsw_blobs;
1695        *self
1696            .retained_ivf_bytes
1697            .lock()
1698            .expect("retained_ivf_bytes lock poisoned") = if ivf_bytes.is_empty() {
1699            None
1700        } else {
1701            Some(ivf_bytes)
1702        };
1703        // indexes_populated remains false.
1704    }
1705
1706    /// Store raw rkyv provenance bytes retained from a V8 snapshot.
1707    ///
1708    /// Called from `restore_v8_base` in db.rs after open.  Provenance is not
1709    /// decoded here; it is materialized lazily — either by the `&self` read path
1710    /// (`ensure_provenance_loaded`) for stats/explain, or by the `&mut self`
1711    /// write path (`ensure_provenance_loaded_mut`) on the first mutation.
1712    pub fn store_provenance_bytes(&self, bytes: Vec<u8>) {
1713        *self
1714            .retained_provenance_bytes
1715            .lock()
1716            .expect("lock poisoned") = if bytes.is_empty() { None } else { Some(bytes) };
1717    }
1718
1719    /// Populate `lazy_provenance` from retained bytes for `&self` read paths.
1720    ///
1721    /// Uses `OnceLock` for exactly-once initialization.  The retained bytes are
1722    /// NOT consumed here; `ensure_provenance_loaded_mut` still has access to them
1723    /// for the write path.  After the first mutation, `retained_provenance_bytes`
1724    /// is `None` and callers switch to the live `self.provenance` field instead.
1725    pub fn ensure_provenance_loaded(&self) {
1726        self.lazy_provenance.get_or_init(|| {
1727            // Hold the Mutex across decode to avoid cloning 115 MiB.  This is a
1728            // one-time cost; subsequent calls return immediately via OnceLock.
1729            let guard = self
1730                .retained_provenance_bytes
1731                .lock()
1732                .expect("retained_provenance_bytes lock poisoned");
1733            let bytes = match &*guard {
1734                Some(b) if !b.is_empty() => b,
1735                _ => return LazyProvenance::default(),
1736            };
1737            let prov = decode_provenance_bytes(bytes);
1738            let (by_node, _rule_intern, intern_rule) = rebuild_by_node(&prov);
1739            LazyProvenance {
1740                provenance: prov,
1741                by_node,
1742                intern_rule,
1743            }
1744        });
1745    }
1746
1747    /// Decode and install retained provenance bytes into the live mutable fields.
1748    ///
1749    /// No-op if bytes have already been consumed or were never stored.
1750    /// Must be called under `&mut self` before any operation that reads or
1751    /// diffs against `self.provenance`, `self.owned`, or `self.by_node`.
1752    pub fn ensure_provenance_loaded_mut(&mut self) {
1753        let bytes = match self
1754            .retained_provenance_bytes
1755            .lock()
1756            .expect("lock poisoned")
1757            .take()
1758        {
1759            Some(b) => b,
1760            None => return,
1761        };
1762        let prov = decode_provenance_bytes(&bytes);
1763        for set in prov.values() {
1764            self.owned.extend(set.iter().copied());
1765        }
1766        let (by_node, rule_intern, intern_rule) = rebuild_by_node(&prov);
1767        self.provenance = prov;
1768        self.by_node = by_node;
1769        self.rule_intern = rule_intern;
1770        self.intern_rule = intern_rule;
1771    }
1772
1773    /// Eagerly consume retained snapshot state before WAL replay.
1774    ///
1775    /// Call this in `open_with` when the WAL has records.  Runs the O(n) node
1776    /// scan + restores persisted IVF centroids and HNSW blobs so that WAL
1777    /// replay finds fully-populated indexes.  Marks `indexes_populated = true`.
1778    pub fn consume_retained_state_eager(
1779        &mut self,
1780        ids: &IdMap,
1781        syms: &Interner,
1782        labels: &[u32],
1783        props: ColumnsView<'_>,
1784    ) {
1785        if self.indexes_populated {
1786            return;
1787        }
1788        // Also ensure provenance is loaded before WAL replay so diffs apply
1789        // against the correct pre-snapshot provenance state.
1790        self.ensure_provenance_loaded_mut();
1791        let hnsw = std::mem::take(
1792            &mut *self
1793                .retained_hnsw_blobs
1794                .lock()
1795                .expect("retained_hnsw_blobs lock poisoned"),
1796        );
1797        let ivf_bytes = self
1798            .retained_ivf_bytes
1799            .lock()
1800            .expect("retained_ivf_bytes lock poisoned")
1801            .take()
1802            .unwrap_or_default();
1803        let ivf = decode_ivf_bytes_to_export(&ivf_bytes);
1804        self.reindex_all_load_ivf(ids, syms, labels, props, ivf);
1805        // Override the incrementally-built HNSW with the persisted blob (better).
1806        self.load_hnsw_state(hnsw);
1807    }
1808
1809    /// Deserialize retained HNSW blobs into `lazy_hnsw` for the clean-open ANN
1810    /// read path.  Takes `&self` so it can be called from `find_similar_vector`
1811    /// and `search_hybrid` under a shared (`db.read()`) lock.
1812    ///
1813    /// Uses `OnceLock` to guarantee exactly-once initialization even under
1814    /// concurrent shared access.  The retained blobs are borrowed (not consumed)
1815    /// so that a subsequent first-mutation call to `consume_retained_state_eager`
1816    /// can still load the persisted HNSW graphs into `self.indexes`.
1817    ///
1818    /// Called before the first ANN query on a clean-open (no WAL) store.
1819    pub fn ensure_hnsw_loaded(&self) {
1820        self.lazy_hnsw.get_or_init(|| {
1821            // Snapshot blob entries into a local Vec, then release the Mutex
1822            // before deserialization so the lock is not held across potentially
1823            // expensive bincode::deserialize calls.
1824            let snapshot: Vec<(String, Vec<u8>, Vec<u8>)> = {
1825                let guard = self
1826                    .retained_hnsw_blobs
1827                    .lock()
1828                    .expect("retained_hnsw_blobs lock poisoned");
1829                if guard.is_empty() {
1830                    return BTreeMap::new();
1831                }
1832                guard
1833                    .iter()
1834                    .map(|(name, (sb, db))| (name.clone(), sb.clone(), db.clone()))
1835                    .collect()
1836            }; // lock released here
1837            snapshot
1838                .into_iter()
1839                .map(|(name, sb, db)| {
1840                    let src = if !sb.is_empty() {
1841                        bincode::deserialize::<HnswIndex>(&sb).ok()
1842                    } else {
1843                        None
1844                    };
1845                    let dst = if !db.is_empty() {
1846                        bincode::deserialize::<HnswIndex>(&db).ok()
1847                    } else {
1848                        None
1849                    };
1850                    (name, (src, dst))
1851                })
1852                .collect()
1853        });
1854    }
1855
1856    /// Returns `true` if candidate indexes have been built (either eagerly or
1857    /// via the lazy mutation-hook trigger).
1858    pub fn indexes_populated(&self) -> bool {
1859        self.indexes_populated
1860    }
1861
1862    /// Export HNSW graphs for all approximate rules as opaque bincoded blobs.
1863    ///
1864    /// Returns a map from rule name to `(src_blob, dst_blob)`.  An empty `Vec`
1865    /// means the corresponding side has no initialized HNSW graph.
1866    pub fn export_hnsw_state(&self) -> BTreeMap<String, (Vec<u8>, Vec<u8>)> {
1867        let mut out = BTreeMap::new();
1868        for (name, def) in &self.rules {
1869            if def.approximate {
1870                if let Some(idx) = self.indexes.get(name) {
1871                    out.insert(
1872                        name.clone(),
1873                        (
1874                            idx.src_side.export_hnsw_blob(),
1875                            idx.dst_side.export_hnsw_blob(),
1876                        ),
1877                    );
1878                }
1879            }
1880        }
1881        out
1882    }
1883
1884    /// Returns HNSW state for snapshotting.  When indexes are not yet populated
1885    /// (clean open with no mutation), returns the retained raw blobs directly so
1886    /// that a migrate/snapshot does not silently drop fitted indexes.
1887    pub fn export_hnsw_state_passthrough(&self) -> BTreeMap<String, (Vec<u8>, Vec<u8>)> {
1888        if !self.indexes_populated {
1889            let guard = self
1890                .retained_hnsw_blobs
1891                .lock()
1892                .expect("retained_hnsw_blobs lock poisoned");
1893            if !guard.is_empty() {
1894                return guard.clone();
1895            }
1896        }
1897        self.export_hnsw_state()
1898    }
1899
1900    /// Returns a clone of the retained raw IVF bincode bytes.
1901    ///
1902    /// Returns `None` if no bytes are retained (fresh store or indexes already
1903    /// consumed by a mutation).  Used by `snapshot_with` for passthrough when
1904    /// indexes have not yet been populated.
1905    pub fn retained_ivf_bytes_clone(&self) -> Option<Vec<u8>> {
1906        self.retained_ivf_bytes
1907            .lock()
1908            .expect("retained_ivf_bytes lock poisoned")
1909            .clone()
1910    }
1911
1912    /// Restore HNSW graphs from bincoded blobs (overrides any incrementally built
1913    /// graphs produced during `reindex_all_load_ivf`).
1914    ///
1915    /// Called from `restore_snapshot_state` in db.rs after reindex.
1916    pub fn load_hnsw_state(&mut self, blobs: BTreeMap<String, (Vec<u8>, Vec<u8>)>) {
1917        for (name, (src_blob, dst_blob)) in blobs {
1918            if let Some(idx) = self.indexes.get_mut(&name) {
1919                if !src_blob.is_empty() {
1920                    idx.src_side.load_hnsw_blob(&src_blob);
1921                }
1922                if !dst_blob.is_empty() {
1923                    idx.dst_side.load_hnsw_blob(&dst_blob);
1924                }
1925            }
1926        }
1927    }
1928
1929    /// Find approximate nearest-neighbor ids on the dst side of the first
1930    /// approximate VectorSimilar rule covering `(dst_label, field)`.
1931    ///
1932    /// Returns `None` when no matching rule or HNSW index exists.
1933    pub fn hnsw_search_dst(
1934        &self,
1935        field: &str,
1936        dst_label: &str,
1937        q: &[f64],
1938        k: usize,
1939    ) -> Option<Vec<(u32, f64)>> {
1940        for (name, def) in &self.rules {
1941            if !def.approximate || def.dst_label != dst_label {
1942                continue;
1943            }
1944            // Check that the predicate covers this vector field.
1945            if !predicate_covers_field(&def.predicate, field) {
1946                continue;
1947            }
1948            if let Some(idx) = self.indexes.get(name) {
1949                if let Some(h) = idx.dst_side.hnsw_ref() {
1950                    if !h.is_empty() {
1951                        return Some(h.search(q, k));
1952                    }
1953                }
1954            }
1955            // Fallback: blobs deserialized via ensure_hnsw_loaded (read path,
1956            // no mutation has populated self.indexes yet).
1957            if let Some(lazy) = self.lazy_hnsw.get() {
1958                if let Some((_, Some(h))) = lazy.get(name) {
1959                    if !h.is_empty() {
1960                        return Some(h.search(q, k));
1961                    }
1962                }
1963            }
1964        }
1965        None
1966    }
1967
1968    /// Returns `true` if any approximate VectorSimilar rule covers `field`.
1969    ///
1970    /// Use as a capability probe before calling `hnsw_search_dst` or
1971    /// `hnsw_search_any_dst` — presence of the rule guarantees the native Rust
1972    /// path will be used (HNSW when the index is populated, Rust brute-force
1973    /// otherwise); it does NOT guarantee a populated HNSW index.
1974    pub fn hnsw_has_rule(&self, field: &str) -> bool {
1975        self.rules
1976            .values()
1977            .any(|def| def.approximate && predicate_covers_field(&def.predicate, field))
1978    }
1979
1980    /// Like `hnsw_search_dst` but searches across **all** dst_labels that have
1981    /// an approximate VectorSimilar rule covering `field`.
1982    ///
1983    /// Results from multiple rules are merged by node id (keeping the maximum
1984    /// score for any id that appears in more than one rule's index), then
1985    /// sorted descending and truncated to `k`.
1986    ///
1987    /// Returns `None` when no applicable rule has a populated HNSW index
1988    /// (same sentinel convention as `hnsw_search_dst`).
1989    pub fn hnsw_search_any_dst(&self, field: &str, q: &[f64], k: usize) -> Option<Vec<(u32, f64)>> {
1990        let mut merged: std::collections::BTreeMap<u32, f64> = std::collections::BTreeMap::new();
1991        let mut found_index = false;
1992
1993        for (name, def) in &self.rules {
1994            if !def.approximate {
1995                continue;
1996            }
1997            if !predicate_covers_field(&def.predicate, field) {
1998                continue;
1999            }
2000            let hits: Option<Vec<(u32, f64)>> = if let Some(idx) = self.indexes.get(name) {
2001                if let Some(h) = idx.dst_side.hnsw_ref() {
2002                    if !h.is_empty() {
2003                        found_index = true;
2004                        Some(h.search(q, k))
2005                    } else {
2006                        None
2007                    }
2008                } else {
2009                    None
2010                }
2011            } else if let Some(lazy) = self.lazy_hnsw.get() {
2012                if let Some((_, Some(h))) = lazy.get(name) {
2013                    if !h.is_empty() {
2014                        found_index = true;
2015                        Some(h.search(q, k))
2016                    } else {
2017                        None
2018                    }
2019                } else {
2020                    None
2021                }
2022            } else {
2023                None
2024            };
2025
2026            if let Some(hits) = hits {
2027                for (id, score) in hits {
2028                    merged
2029                        .entry(id)
2030                        .and_modify(|s| {
2031                            if score > *s {
2032                                *s = score;
2033                            }
2034                        })
2035                        .or_insert(score);
2036                }
2037            }
2038        }
2039
2040        if !found_index {
2041            return None;
2042        }
2043        let mut result: Vec<(u32, f64)> = merged.into_iter().collect();
2044        result.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
2045        result.truncate(k);
2046        Some(result)
2047    }
2048
2049    /// Register a rule and backfill existing nodes.
2050    /// Returns Err on failed validate() or duplicate name.
2051    pub fn create_rule(&mut self, def: RuleDef, g: &mut GraphMut<'_>) -> Result<(), String> {
2052        def.validate()?;
2053        if self.rules.contains_key(&def.name) {
2054            return Err(format!("rule {:?} already exists", def.name));
2055        }
2056        let name = def.name.clone();
2057        self.rules.insert(name.clone(), def);
2058        self.indexes.insert(name.clone(), RuleIndex::default());
2059        self.provenance.entry(name.clone()).or_default();
2060        self.tripped.insert(name.clone(), false);
2061        self.fires.insert(name.clone(), 0);
2062
2063        // Phase 1: index all existing nodes for this rule.
2064        let n_total = g.ids.len() as u32;
2065        let def = self.rules[&name].clone();
2066
2067        // Phase 1a: init HNSW for approximate rules before inserting nodes so
2068        // each insert also populates the HNSW graph incrementally.
2069        if def.approximate {
2070            let idx = self.indexes.get_mut(&name).unwrap();
2071            idx.src_side.init_hnsw(&name);
2072            idx.dst_side.init_hnsw(&name);
2073        }
2074
2075        for id in 0..n_total {
2076            let label_sym = match g.labels.get(id as usize).copied() {
2077                Some(s) if s != u32::MAX => s,
2078                _ => continue,
2079            };
2080            let idx = self.indexes.get_mut(&name).unwrap();
2081            index_node_for_rule(id, label_sym, &def, idx, g.syms, g.props);
2082        }
2083
2084        // Phase 1b: fit IVF clusters for approximate rules (after all nodes indexed).
2085        // HNSW was built incrementally above; IVF is kept as legacy fallback.
2086        if def.approximate {
2087            let idx = self.indexes.get_mut(&name).unwrap();
2088            idx.src_side.fit_ivf_clusters(&name);
2089            idx.dst_side.fit_ivf_clusters(&name);
2090        }
2091
2092        // Phase 2: streaming backfill.
2093        // Branches on max_edges semantics:
2094        //   None    → global-budget path (tripped latch, first-N in BTree order)
2095        //   Some(k) → per-source top-k path (no tripped latch, score-ordered)
2096        let mut prov = ProvSets {
2097            set: self.provenance.get_mut(&name).unwrap(),
2098            owned: &mut self.owned,
2099            by_node: &mut self.by_node,
2100            rule_intern: &mut self.rule_intern,
2101            intern_rule: &mut self.intern_rule,
2102            deltas: &mut self.pending_deltas,
2103            emit: self.emit_deltas,
2104        };
2105        if def.via_label.is_some() {
2106            // Via-hop backfill: bypass the candidate index; use compute_desired_via.
2107            let budget = edge_budget(&def);
2108            let et = g.syms.intern(&def.edge_type);
2109            let src_sym = g.syms.get(&def.src_label);
2110            let tripped = self.tripped.get_mut(&name).unwrap();
2111            'via_outer: for id in 0..g.ids.len() as u32 {
2112                let label_sym = match g.labels.get(id as usize).copied() {
2113                    Some(s) if s != u32::MAX => s,
2114                    _ => continue,
2115                };
2116                if src_sym != Some(label_sym) {
2117                    continue;
2118                }
2119                let per_src = compute_desired_via(&def, ViaAnchor::Src(id), g);
2120                if let Some(k) = def.max_edges {
2121                    let top_k = filter_src_top_k(per_src, k, g.ids);
2122                    apply_per_src_top_k(&def, id, top_k, &mut prov, g);
2123                } else {
2124                    for ((s, d), score) in per_src {
2125                        let triple = (et, s, d);
2126                        let already = prov.contains(&triple);
2127                        if !already {
2128                            if *tripped || prov.len() as u64 >= budget {
2129                                *tripped = true;
2130                                break 'via_outer;
2131                            }
2132                            let newly = g.topo.add_edge(et, s, d);
2133                            if newly {
2134                                prov.insert(&name, triple, g.ids, g.syms);
2135                            }
2136                        }
2137                        let is_owned_here = already || prov.contains(&triple);
2138                        if is_owned_here {
2139                            if let Some(p) = &def.weight_prop {
2140                                g.edge_props.set(et, s, d, p, Value::Float(score));
2141                            }
2142                        }
2143                    }
2144                }
2145            }
2146        } else if let Some(k) = def.max_edges {
2147            apply_streaming_create_top_k(&def, k, &self.indexes[&name], &mut prov, g);
2148        } else {
2149            let tripped = self.tripped.get_mut(&name).unwrap();
2150            apply_streaming_create(&def, &self.indexes[&name], &mut prov, tripped, g);
2151        }
2152        // Fires: one tick per participating node evaluated (same unit as
2153        // on_node_changed). Empty-graph create_rule therefore leaves fires=0.
2154        let fires = self.fires.get_mut(&name).unwrap();
2155        bump_fires_for_participants(&def, g, fires);
2156
2157        // This rule's index is now populated. If prior rules' indexes were
2158        // already populated (or there are no other rules) mark the whole engine
2159        // as ready; otherwise a later reindex_all call will set the flag.
2160        self.indexes_populated = true;
2161
2162        Ok(())
2163    }
2164
2165    /// Remove the rule and exactly its owned edges.  Returns Err if unknown.
2166    pub fn delete_rule(&mut self, name: &str, g: &mut GraphMut<'_>) -> Result<(), String> {
2167        if !self.rules.contains_key(name) {
2168            return Err(format!("rule {:?} not found", name));
2169        }
2170        let def = self.rules.remove(name).unwrap();
2171        self.indexes.remove(name);
2172        self.tripped.remove(name);
2173        self.fires.remove(name);
2174        let mut leftover = self.provenance.remove(name).unwrap_or_default();
2175        // intern so the symbol exists; edge_type was already interned at create time.
2176        let _et = g.syms.intern(&def.edge_type);
2177        let triples: Vec<Triple> = leftover.iter().copied().collect();
2178        let mut sets = ProvSets {
2179            set: &mut leftover,
2180            owned: &mut self.owned,
2181            by_node: &mut self.by_node,
2182            rule_intern: &mut self.rule_intern,
2183            intern_rule: &mut self.intern_rule,
2184            deltas: &mut self.pending_deltas,
2185            emit: self.emit_deltas,
2186        };
2187        for triple in triples {
2188            let (t, s, d) = triple;
2189            g.topo.remove_edge(t, s, d);
2190            g.edge_props.remove_edge(t, s, d);
2191            sets.remove(name, triple, g.ids, g.syms);
2192        }
2193        // Surviving rules that share the same edge_type may derive edges that
2194        // were previously blocked (add_edge returned false because the deleted
2195        // rule already owned them, so their provenance never recorded them).
2196        // Rebuilding each such rule lets it claim those edges now that the
2197        // deleted rule's entries have been removed from the topology.
2198        let same_etype_survivors: Vec<String> = self
2199            .rules
2200            .values()
2201            .filter(|r| r.edge_type == def.edge_type)
2202            .map(|r| r.name.clone())
2203            .collect();
2204        for survivor in same_etype_survivors {
2205            // rebuild returns Err only for unknown rules; survivor is live.
2206            let _ = self.rebuild(&survivor, g);
2207        }
2208        Ok(())
2209    }
2210
2211    /// Called when node `n` is inserted (changed=None) or a field is updated.
2212    /// - None: all rules where n's label matches either side fire; index gains n.
2213    /// - Some((field, old_value)): only rules watching `field` fire; index is
2214    ///   updated using old_value for removal so stale buckets are cleaned.
2215    ///
2216    /// For via-hop rules (`def.via_label.is_some()`), also fires when n carries
2217    /// the via-label: finds all srcs that route through n and recomputes their
2218    /// derived edges. Via-hop rules bypass the candidate index and use
2219    /// `compute_desired_via` instead.
2220    pub fn on_node_changed(
2221        &mut self,
2222        n: u32,
2223        changed: Option<(&str, Option<Value>)>,
2224        g: &mut GraphMut<'_>,
2225    ) {
2226        // Ensure provenance is decoded before diffing against existing edges.
2227        self.ensure_provenance_loaded_mut();
2228        // Lazy index build: on restore from a V8 snapshot, candidate indexes
2229        // start empty to avoid an O(n) scan at open time.  The first mutation
2230        // pays the cost instead.  Subsequent calls skip this branch.
2231        // Retained HNSW/IVF blobs from the snapshot are consumed here so the
2232        // reindex does NOT wipe the loaded HNSW graphs.
2233        if !self.indexes_populated && !self.rules.is_empty() {
2234            let hnsw = std::mem::take(
2235                &mut *self
2236                    .retained_hnsw_blobs
2237                    .lock()
2238                    .expect("retained_hnsw_blobs lock poisoned"),
2239            );
2240            let ivf_bytes = self
2241                .retained_ivf_bytes
2242                .lock()
2243                .expect("retained_ivf_bytes lock poisoned")
2244                .take()
2245                .unwrap_or_default();
2246            let ivf = decode_ivf_bytes_to_export(&ivf_bytes);
2247            self.reindex_all_load_ivf(g.ids, g.syms, g.labels, g.props, ivf);
2248            self.load_hnsw_state(hnsw);
2249        }
2250
2251        let n_label = g.labels.get(n as usize).copied();
2252        let rule_names: Vec<String> = self.rules.keys().cloned().collect();
2253
2254        for rule_name in rule_names {
2255            let def = self.rules[&rule_name].clone();
2256
2257            if def.via_label.is_some() {
2258                // --- Via-hop rule path ---
2259                self.on_node_changed_via(&rule_name, &def, n, n_label, changed.clone(), g);
2260            } else {
2261                // --- Standard 2-node rule path ---
2262                let src_sym = g.syms.get(&def.src_label);
2263                let dst_sym = g.syms.get(&def.dst_label);
2264                let as_src = src_sym.is_some() && n_label == src_sym;
2265                let as_dst = dst_sym.is_some() && n_label == dst_sym;
2266
2267                let fires = match changed {
2268                    None => as_src || as_dst,
2269                    Some((field, _)) => def.watched_fields().contains(field) && (as_src || as_dst),
2270                };
2271                if !fires {
2272                    continue;
2273                }
2274                *self.fires.entry(rule_name.clone()).or_default() += 1;
2275
2276                // --- Index maintenance ---
2277                if let Some((field, ref old_val)) = changed {
2278                    let old_val_cloned = old_val.clone();
2279                    let old_getter = |f: &str| {
2280                        if f == field {
2281                            old_val_cloned.clone()
2282                        } else {
2283                            g.props.get(n, f).map(|vr| vr.into_value())
2284                        }
2285                    };
2286                    let idx = self.indexes.get_mut(&rule_name).unwrap();
2287                    if as_src {
2288                        let spec = src_lookup_spec_for(&def);
2289                        idx.src_side.remove(&spec, n, &old_getter);
2290                    }
2291                    if as_dst {
2292                        let spec = candidate_spec_for(&def);
2293                        idx.dst_side.remove(&spec, n, &old_getter);
2294                    }
2295                }
2296
2297                {
2298                    let cur_getter = |f: &str| g.props.get(n, f).map(|vr| vr.into_value());
2299                    let idx = self.indexes.get_mut(&rule_name).unwrap();
2300                    if as_src {
2301                        let spec = src_lookup_spec_for(&def);
2302                        idx.src_side.insert(&spec, n, &cur_getter);
2303                    }
2304                    if as_dst {
2305                        let spec = candidate_spec_for(&def);
2306                        idx.dst_side.insert(&spec, n, &cur_getter);
2307                    }
2308                }
2309
2310                self.maybe_queue_ivf_rebuild(&rule_name, &def);
2311
2312                // --- Desired set + diff-apply ---
2313                if let Some(k) = def.max_edges {
2314                    let et = g.syms.intern(&def.edge_type);
2315                    let affected_srcs_for_n_dst: BTreeSet<u32> = if as_dst {
2316                        let rid = self.rule_intern.get(&def.name).copied();
2317                        self.by_node
2318                            .get(&n)
2319                            .into_iter()
2320                            .flatten()
2321                            .filter(|(r, t, _s, d)| Some(*r) == rid && *t == et && *d == n)
2322                            .map(|(_, _, s, _)| *s)
2323                            .collect()
2324                    } else {
2325                        BTreeSet::new()
2326                    };
2327
2328                    let mut prov = ProvSets {
2329                        set: self.provenance.entry(rule_name.clone()).or_default(),
2330                        owned: &mut self.owned,
2331                        by_node: &mut self.by_node,
2332                        rule_intern: &mut self.rule_intern,
2333                        intern_rule: &mut self.intern_rule,
2334                        deltas: &mut self.pending_deltas,
2335                        emit: self.emit_deltas,
2336                    };
2337
2338                    if as_src {
2339                        let desired_n_src =
2340                            compute_desired(&def, &self.indexes[&rule_name], n, true, g);
2341                        let top_k = filter_src_top_k(desired_n_src, k, g.ids);
2342                        apply_per_src_top_k(&def, n, top_k, &mut prov, g);
2343                    }
2344
2345                    if as_dst {
2346                        let new_desired =
2347                            compute_desired(&def, &self.indexes[&rule_name], n, false, g);
2348                        let new_srcs: BTreeSet<u32> = new_desired.keys().map(|(s, _)| *s).collect();
2349                        let affected_srcs: BTreeSet<u32> =
2350                            affected_srcs_for_n_dst.union(&new_srcs).copied().collect();
2351                        for src in affected_srcs {
2352                            if src == n {
2353                                continue;
2354                            }
2355                            let desired_src =
2356                                compute_desired(&def, &self.indexes[&rule_name], src, true, g);
2357                            let top_k = filter_src_top_k(desired_src, k, g.ids);
2358                            apply_per_src_top_k(&def, src, top_k, &mut prov, g);
2359                        }
2360                    }
2361                } else {
2362                    let mut desired = BTreeMap::new();
2363                    if as_src {
2364                        desired.extend(compute_desired(
2365                            &def,
2366                            &self.indexes[&rule_name],
2367                            n,
2368                            true,
2369                            g,
2370                        ));
2371                    }
2372                    if as_dst {
2373                        desired.extend(compute_desired(
2374                            &def,
2375                            &self.indexes[&rule_name],
2376                            n,
2377                            false,
2378                            g,
2379                        ));
2380                    }
2381                    let tripped = self.tripped.entry(rule_name.clone()).or_default();
2382                    apply_desired(
2383                        &def,
2384                        desired,
2385                        Some(n),
2386                        &mut ProvSets {
2387                            set: self.provenance.entry(rule_name).or_default(),
2388                            owned: &mut self.owned,
2389                            by_node: &mut self.by_node,
2390                            rule_intern: &mut self.rule_intern,
2391                            intern_rule: &mut self.intern_rule,
2392                            deltas: &mut self.pending_deltas,
2393                            emit: self.emit_deltas,
2394                        },
2395                        tripped,
2396                        g,
2397                    );
2398                }
2399            }
2400        }
2401    }
2402
2403    /// Inner handler for `on_node_changed` when the rule is a via-hop rule.
2404    ///
2405    /// For each role n can play (src, via, dst), computes and applies the
2406    /// desired edge set using `compute_desired_via`. Via-hop rules bypass the
2407    /// candidate index; no index maintenance is performed here.
2408    ///
2409    /// Incremental correctness by change class:
2410    /// - **src prop / insert** (`as_src`): re-expand via from n, recompute all
2411    ///   (n, dst) pairs. `apply_via_for_srcs([n])`.
2412    /// - **via-node prop change** (`as_via`, field in watched_fields): find
2413    ///   srcs that hop to n via `via_edge`, recompute their (src, dst) pairs.
2414    ///   `apply_via_for_srcs(reverse_via_neighbors(n))`.
2415    /// - **dst prop / insert** (`as_dst`): anchor on n, compute desired for all
2416    ///   srcs. `apply_via_for_srcs(all_src_label_nodes)`.
2417    fn on_node_changed_via(
2418        &mut self,
2419        rule_name: &str,
2420        def: &RuleDef,
2421        n: u32,
2422        n_label: Option<u32>,
2423        changed: Option<(&str, Option<Value>)>,
2424        g: &mut GraphMut<'_>,
2425    ) {
2426        let src_sym = g.syms.get(&def.src_label);
2427        let dst_sym = g.syms.get(&def.dst_label);
2428        let via_sym = def.via_label.as_deref().and_then(|l| g.syms.get(l));
2429
2430        let as_src = src_sym.is_some() && n_label == src_sym;
2431        let as_dst = dst_sym.is_some() && n_label == dst_sym;
2432        let as_via = via_sym.is_some() && n_label == via_sym;
2433
2434        // Via-hop predicates are evaluated between via and dst, so watched_fields
2435        // cover both via-node and dst-node fields (predicate fields come from the
2436        // via→dst evaluation). A via-node prop change fires if its field is watched.
2437        let fires = match changed {
2438            None => as_src || as_via || as_dst,
2439            Some((field, _)) => {
2440                let wf = def.watched_fields();
2441                (wf.contains(field)) && (as_src || as_via || as_dst)
2442            }
2443        };
2444        if !fires {
2445            return;
2446        }
2447        *self.fires.entry(rule_name.to_string()).or_default() += 1;
2448
2449        // Collect affected srcs: union of srcs identified from each role.
2450        let mut affected_srcs: BTreeSet<u32> = BTreeSet::new();
2451        if as_src {
2452            affected_srcs.insert(n);
2453        }
2454        if as_via {
2455            // Srcs that hop to this via-node via via_edge (reverse direction).
2456            let via_edge_str = def.via_edge.as_deref().unwrap();
2457            let via_dir = def.via_dir.unwrap_or(core_storage::Direction::Out);
2458            let rev_dir = match via_dir {
2459                core_storage::Direction::Out => core_storage::Direction::In,
2460                core_storage::Direction::In => core_storage::Direction::Out,
2461            };
2462            if let (Some(via_etype), Some(s_sym)) = (g.syms.get(via_edge_str), src_sym) {
2463                for &src in g.topo.neighbors(via_etype, rev_dir, n).as_ref() {
2464                    if g.labels.get(src as usize).copied() == Some(s_sym) {
2465                        affected_srcs.insert(src);
2466                    }
2467                }
2468            }
2469        }
2470        if as_dst {
2471            // Recompute all srcs whose via-hops might produce edges to n.
2472            let desired_touching_n = compute_desired_via(def, ViaAnchor::Dst(n), g);
2473            for (src, _dst) in desired_touching_n.keys() {
2474                affected_srcs.insert(*src);
2475            }
2476            // Also include any srcs that currently have provenance pointing to n.
2477            let et = g.syms.intern(&def.edge_type);
2478            let rid = self.rule_intern.get(rule_name).copied();
2479            let old_srcs: Vec<u32> = self
2480                .by_node
2481                .get(&n)
2482                .into_iter()
2483                .flatten()
2484                .filter(|(r, t, _s, d)| Some(*r) == rid && *t == et && *d == n)
2485                .map(|(_, _, s, _)| *s)
2486                .collect();
2487            affected_srcs.extend(old_srcs);
2488        }
2489
2490        // For each affected src, compute desired_via(Src) and apply.
2491        let affected_srcs: Vec<u32> = affected_srcs.into_iter().collect();
2492
2493        if let Some(k) = def.max_edges {
2494            let mut prov = ProvSets {
2495                set: self.provenance.entry(rule_name.to_string()).or_default(),
2496                owned: &mut self.owned,
2497                by_node: &mut self.by_node,
2498                rule_intern: &mut self.rule_intern,
2499                intern_rule: &mut self.intern_rule,
2500                deltas: &mut self.pending_deltas,
2501                emit: self.emit_deltas,
2502            };
2503            for src in affected_srcs {
2504                let desired_src = compute_desired_via(def, ViaAnchor::Src(src), g);
2505                let top_k = filter_src_top_k(desired_src, k, g.ids);
2506                apply_per_src_top_k(def, src, top_k, &mut prov, g);
2507            }
2508        } else {
2509            let tripped = self.tripped.entry(rule_name.to_string()).or_default();
2510            let budget = edge_budget(def);
2511            // Apply per-src so each affected src retracts its stale edges and
2512            // adds its new desired edges independently.
2513            for src in affected_srcs {
2514                let desired_src = compute_desired_via(def, ViaAnchor::Src(src), g);
2515                if !*tripped {
2516                    let mut prov = ProvSets {
2517                        set: self.provenance.entry(rule_name.to_string()).or_default(),
2518                        owned: &mut self.owned,
2519                        by_node: &mut self.by_node,
2520                        rule_intern: &mut self.rule_intern,
2521                        intern_rule: &mut self.intern_rule,
2522                        deltas: &mut self.pending_deltas,
2523                        emit: self.emit_deltas,
2524                    };
2525                    apply_desired(def, desired_src, Some(src), &mut prov, tripped, g);
2526                }
2527                // If budget was just tripped inside apply_desired, stop adding
2528                // but continue retracting stale edges for already-processed srcs
2529                // (apply_desired handles retracts even when tripped).
2530                let _ = budget;
2531            }
2532        }
2533    }
2534
2535    /// Called when a user edge `(etype_str, src_id, dst_id)` is inserted or
2536    /// deleted (not a derived edge — those are managed by provenance, not here).
2537    ///
2538    /// For any via-hop rule where `via_edge == etype_str` and src_id carries
2539    /// `src_label`, the src_id's desired derived-edge set may have changed:
2540    /// a new WORKS_AT edge makes a new Org reachable as a via-node, and a
2541    /// deleted WORKS_AT removes a previously reachable Org.
2542    ///
2543    /// This is the only hook the engine exposes for topology changes. It is
2544    /// called from `db.rs` on `WalRecord::InsertEdge` and `WalRecord::DeleteEdge`
2545    /// immediately after the topo is updated (so `g.topo` already reflects the
2546    /// new state).
2547    pub fn on_edge_changed(
2548        &mut self,
2549        etype_str: &str,
2550        src_id: u32,
2551        dst_id: u32,
2552        g: &mut GraphMut<'_>,
2553    ) {
2554        // Ensure provenance is decoded before diffing against existing edges.
2555        self.ensure_provenance_loaded_mut();
2556        // Lazy index build: same guard as on_node_changed.  Retained snapshot
2557        // blobs are consumed to avoid wiping any HNSW graphs.
2558        if !self.indexes_populated && !self.rules.is_empty() {
2559            let hnsw = std::mem::take(
2560                &mut *self
2561                    .retained_hnsw_blobs
2562                    .lock()
2563                    .expect("retained_hnsw_blobs lock poisoned"),
2564            );
2565            let ivf_bytes = self
2566                .retained_ivf_bytes
2567                .lock()
2568                .expect("retained_ivf_bytes lock poisoned")
2569                .take()
2570                .unwrap_or_default();
2571            let ivf = decode_ivf_bytes_to_export(&ivf_bytes);
2572            self.reindex_all_load_ivf(g.ids, g.syms, g.labels, g.props, ivf);
2573            self.load_hnsw_state(hnsw);
2574        }
2575
2576        let rule_names: Vec<String> = self.rules.keys().cloned().collect();
2577        for rule_name in rule_names {
2578            let def = self.rules[&rule_name].clone();
2579            let Some(ref via_edge) = def.via_edge else {
2580                continue; // not a via-hop rule
2581            };
2582            if via_edge != etype_str {
2583                continue; // edge type doesn't match this rule's via_edge
2584            }
2585
2586            // Check that src_id carries src_label and dst_id carries via_label.
2587            let src_sym = match g.syms.get(&def.src_label) {
2588                Some(s) => s,
2589                None => continue,
2590            };
2591            let via_sym = match def.via_label.as_deref().and_then(|l| g.syms.get(l)) {
2592                Some(s) => s,
2593                None => continue,
2594            };
2595            // via_dir == Out → the edge goes src_id → dst_id (src-label node to via-label node)
2596            // via_dir == In  → the edge goes dst_id ← src_id, i.e., src_id is the via-label
2597            //                  end and dst_id is the src-label end. Adjust accordingly.
2598            let via_dir = def.via_dir.unwrap_or(core_storage::Direction::Out);
2599            let (rule_src, rule_via) = match via_dir {
2600                core_storage::Direction::Out => (src_id, dst_id),
2601                core_storage::Direction::In => (dst_id, src_id),
2602            };
2603
2604            if g.labels.get(rule_src as usize).copied() != Some(src_sym) {
2605                continue;
2606            }
2607            if g.labels.get(rule_via as usize).copied() != Some(via_sym) {
2608                continue;
2609            }
2610
2611            // Recompute derived edges for rule_src — its via-hop set just changed.
2612            *self.fires.entry(rule_name.clone()).or_default() += 1;
2613            let desired_src = compute_desired_via(&def, ViaAnchor::Src(rule_src), g);
2614
2615            if let Some(k) = def.max_edges {
2616                let mut prov = ProvSets {
2617                    set: self.provenance.entry(rule_name).or_default(),
2618                    owned: &mut self.owned,
2619                    by_node: &mut self.by_node,
2620                    rule_intern: &mut self.rule_intern,
2621                    intern_rule: &mut self.intern_rule,
2622                    deltas: &mut self.pending_deltas,
2623                    emit: self.emit_deltas,
2624                };
2625                let top_k = filter_src_top_k(desired_src, k, g.ids);
2626                apply_per_src_top_k(&def, rule_src, top_k, &mut prov, g);
2627            } else {
2628                let tripped = self.tripped.entry(rule_name.clone()).or_default();
2629                let mut prov = ProvSets {
2630                    set: self.provenance.entry(rule_name).or_default(),
2631                    owned: &mut self.owned,
2632                    by_node: &mut self.by_node,
2633                    rule_intern: &mut self.rule_intern,
2634                    intern_rule: &mut self.intern_rule,
2635                    deltas: &mut self.pending_deltas,
2636                    emit: self.emit_deltas,
2637                };
2638                apply_desired(&def, desired_src, Some(rule_src), &mut prov, tripped, g);
2639            }
2640        }
2641    }
2642
2643    /// Retract every provenance edge touching `n` across all rules and drop
2644    /// `n` from every rule index using its *current* props.
2645    ///
2646    /// Caller must invoke this while labels/props are still intact (before
2647    /// tombstone). Rules are walked in BTree name order; touching edges in
2648    /// BTree triple order. A second call on an already-retracted node is a
2649    /// no-op (crash-window replay / absent state).
2650    pub fn on_node_removed(&mut self, n: u32, g: &mut GraphMut<'_>) {
2651        // Ensure provenance is decoded before diffing against existing edges.
2652        self.ensure_provenance_loaded_mut();
2653        // Lazy index build: same guard as on_node_changed.  Top-k backfill
2654        // compute_desired consults the candidate index; an empty index would
2655        // silently produce no backfill.  Consume retained snapshot blobs here
2656        // rather than wiping any loaded HNSW graphs.
2657        if !self.indexes_populated && !self.rules.is_empty() {
2658            let hnsw = std::mem::take(
2659                &mut *self
2660                    .retained_hnsw_blobs
2661                    .lock()
2662                    .expect("retained_hnsw_blobs lock poisoned"),
2663            );
2664            let ivf_bytes = self
2665                .retained_ivf_bytes
2666                .lock()
2667                .expect("retained_ivf_bytes lock poisoned")
2668                .take()
2669                .unwrap_or_default();
2670            let ivf = decode_ivf_bytes_to_export(&ivf_bytes);
2671            self.reindex_all_load_ivf(g.ids, g.syms, g.labels, g.props, ivf);
2672            self.load_hnsw_state(hnsw);
2673        }
2674
2675        let n_label = g.labels.get(n as usize).copied();
2676        let rule_names: Vec<String> = self.rules.keys().cloned().collect();
2677
2678        for rule_name in rule_names {
2679            let def = self.rules[&rule_name].clone();
2680            let src_sym = g.syms.get(&def.src_label);
2681            let dst_sym = g.syms.get(&def.dst_label);
2682            let as_src = src_sym.is_some() && n_label == src_sym;
2683            let as_dst = dst_sym.is_some() && n_label == dst_sym;
2684
2685            {
2686                let cur_getter = |f: &str| g.props.get(n, f).map(|vr| vr.into_value());
2687                let idx = self.indexes.get_mut(&rule_name).unwrap();
2688                if as_src {
2689                    let spec = src_lookup_spec_for(&def);
2690                    idx.src_side.remove(&spec, n, &cur_getter);
2691                }
2692                if as_dst {
2693                    let spec = candidate_spec_for(&def);
2694                    idx.dst_side.remove(&spec, n, &cur_getter);
2695                }
2696            }
2697
2698            self.maybe_queue_ivf_rebuild(&rule_name, &def);
2699        }
2700
2701        let touching: Vec<(String, Triple)> = self
2702            .by_node
2703            .get(&n)
2704            .into_iter()
2705            .flatten()
2706            .map(|&(rid, t, s, d)| (self.intern_rule[rid as usize].clone(), (t, s, d)))
2707            .collect();
2708
2709        // Collect srcs that need top-k backfill BEFORE retracting provenance.
2710        // For top-k rules: when n is a dst, the src loses one from its top-k
2711        // and needs the next-best candidate added.
2712        let topk_backfill: Vec<(String, u32)> = touching
2713            .iter()
2714            .filter_map(|(rule_name, triple)| {
2715                let &(_, s, d) = triple;
2716                let def = self.rules.get(rule_name)?;
2717                def.max_edges?; // only top-k rules need backfill
2718                if d == n && s != n {
2719                    Some((rule_name.clone(), s))
2720                } else {
2721                    None
2722                }
2723            })
2724            .collect();
2725
2726        for (rule_name, triple) in touching {
2727            let (t, s, d) = triple;
2728            g.topo.remove_edge(t, s, d);
2729            g.edge_props.remove_edge(t, s, d);
2730            if let Some(set) = self.provenance.get_mut(&rule_name) {
2731                ProvSets {
2732                    set,
2733                    owned: &mut self.owned,
2734                    by_node: &mut self.by_node,
2735                    rule_intern: &mut self.rule_intern,
2736                    intern_rule: &mut self.intern_rule,
2737                    deltas: &mut self.pending_deltas,
2738                    emit: self.emit_deltas,
2739                }
2740                .remove(&rule_name, triple, g.ids, g.syms);
2741            }
2742        }
2743
2744        // Backfill top-k srcs whose dst was removed.
2745        // By now n is removed from the dst index (done in the first loop above),
2746        // so compute_desired(src, true) will not include n in candidates — the
2747        // resulting top-k automatically promotes the next-best candidate.
2748        for (rule_name, src) in topk_backfill {
2749            let def = self.rules[&rule_name].clone();
2750            let k = def.max_edges.unwrap(); // guarded by filter above
2751            let desired_src = compute_desired(&def, &self.indexes[&rule_name], src, true, g);
2752            let top_k = filter_src_top_k(desired_src, k, g.ids);
2753            let mut prov = ProvSets {
2754                set: self.provenance.entry(rule_name.clone()).or_default(),
2755                owned: &mut self.owned,
2756                by_node: &mut self.by_node,
2757                rule_intern: &mut self.rule_intern,
2758                intern_rule: &mut self.intern_rule,
2759                deltas: &mut self.pending_deltas,
2760                emit: self.emit_deltas,
2761            };
2762            apply_per_src_top_k(&def, src, top_k, &mut prov, g);
2763        }
2764    }
2765
2766    /// Recompute one rule from scratch. Only exit from the tripped latch.
2767    ///
2768    /// If the full desired set fits in the budget, it is applied completely
2769    /// and `tripped` is cleared. If it still exceeds the budget, existing
2770    /// provenance is left completely untouched and `tripped` stays true
2771    /// (rebuild-is-noop for at/over-cap rules). Always counts as a fire
2772    /// evaluation per participating node. Returns Err if unknown.
2773    pub fn rebuild(&mut self, name: &str, g: &mut GraphMut<'_>) -> Result<(), String> {
2774        if !self.rules.contains_key(name) {
2775            return Err(format!("rule {:?} not found", name));
2776        }
2777        self.rebuild_needed.remove(name);
2778        let def = self.rules[name].clone();
2779
2780        // Reindex this rule from scratch (indexes only).
2781        *self.indexes.get_mut(name).unwrap() = RuleIndex::default();
2782
2783        // Init HNSW before indexing so inserts populate the graph incrementally.
2784        if def.approximate {
2785            let idx = self.indexes.get_mut(name).unwrap();
2786            idx.src_side.init_hnsw(name);
2787            idx.dst_side.init_hnsw(name);
2788        }
2789
2790        let n_total = g.ids.len() as u32;
2791        for id in 0..n_total {
2792            let label_sym = match g.labels.get(id as usize).copied() {
2793                Some(s) if s != u32::MAX => s,
2794                _ => continue,
2795            };
2796            let idx = self.indexes.get_mut(name).unwrap();
2797            index_node_for_rule(id, label_sym, &def, idx, g.syms, g.props);
2798        }
2799
2800        // Fit IVF clusters for approximate rules after reindex (drift reset).
2801        // HNSW was built incrementally; IVF kept as legacy fallback.
2802        if def.approximate {
2803            let idx = self.indexes.get_mut(name).unwrap();
2804            idx.src_side.fit_ivf_clusters(name);
2805            idx.dst_side.fit_ivf_clusters(name);
2806        }
2807
2808        // Streaming rebuild: branches on max_edges semantics.
2809        //   None    → global-budget path (may no-op if still over budget)
2810        //   Some(k) → per-source top-k rebuild (always converges; no tripped latch)
2811        let mut prov = ProvSets {
2812            set: self.provenance.get_mut(name).unwrap(),
2813            owned: &mut self.owned,
2814            by_node: &mut self.by_node,
2815            rule_intern: &mut self.rule_intern,
2816            intern_rule: &mut self.intern_rule,
2817            deltas: &mut self.pending_deltas,
2818            emit: self.emit_deltas,
2819        };
2820        if let Some(k) = def.max_edges {
2821            apply_streaming_rebuild_top_k(&def, k, &self.indexes[name], &mut prov, g);
2822        } else {
2823            let tripped = self.tripped.get_mut(name).unwrap();
2824            apply_streaming_rebuild(&def, &self.indexes[name], &mut prov, tripped, g);
2825        }
2826        let fires = self.fires.entry(name.to_string()).or_default();
2827        bump_fires_for_participants(&def, g, fires);
2828
2829        Ok(())
2830    }
2831
2832    #[cfg(test)]
2833    fn by_node_consistent(&self) -> bool {
2834        let (rebuilt, intern, names) = rebuild_by_node(&self.provenance);
2835        resolve_by_node(&self.by_node, &self.intern_rule) == resolve_by_node(&rebuilt, &names)
2836            && intern.len() == names.len()
2837    }
2838}
2839
2840// ---------------------------------------------------------------------------
2841// Tests
2842// ---------------------------------------------------------------------------
2843
2844#[cfg(test)]
2845mod tests {
2846    use super::*;
2847    use crate::def::{evaluate, NodeView, Predicate, RuleDef};
2848    use core_storage::{ColumnStore, Direction, EdgeProps, IdMap, Interner, Topology, Value};
2849
2850    struct Fx {
2851        ids: IdMap,
2852        syms: Interner,
2853        labels: Vec<u32>,
2854        props: ColumnStore,
2855        topo: Topology,
2856        eprops: EdgeProps,
2857    }
2858    impl Fx {
2859        fn new() -> Self {
2860            Fx {
2861                ids: IdMap::new(),
2862                syms: Interner::new(),
2863                labels: vec![],
2864                props: ColumnStore::new(),
2865                topo: Topology::new(),
2866                eprops: EdgeProps::new(),
2867            }
2868        }
2869        fn add(&mut self, label: &str, key: &str, props: Vec<(&str, Value)>) -> u32 {
2870            let id = self.ids.get_or_insert(key);
2871            let sym = self.syms.intern(label);
2872            self.labels.resize(id as usize + 1, u32::MAX);
2873            self.labels[id as usize] = sym;
2874            for (f, v) in props {
2875                self.props.set(id, f, v);
2876            }
2877            id
2878        }
2879        fn g(&mut self) -> GraphMut<'_> {
2880            GraphMut {
2881                ids: &self.ids,
2882                syms: &mut self.syms,
2883                labels: &self.labels,
2884                props: ColumnsView::owned(&self.props),
2885                topo: &mut self.topo,
2886                edge_props: &mut self.eprops,
2887            }
2888        }
2889    }
2890
2891    fn tags(items: &[&str]) -> Value {
2892        Value::List(items.iter().map(|s| Value::Str((*s).into())).collect())
2893    }
2894
2895    fn overlap_rule() -> RuleDef {
2896        RuleDef {
2897            name: "rel".into(),
2898            src_label: "A".into(),
2899            dst_label: "A".into(),
2900            predicate: Predicate::Overlap {
2901                field: "tags".into(),
2902                min: 0.4,
2903            },
2904            edge_type: "REL".into(),
2905            weight_prop: Some("score".into()),
2906            max_edges: None,
2907            approximate: false,
2908            via_label: None,
2909            via_edge: None,
2910            via_dir: None,
2911        }
2912    }
2913
2914    fn emb(xs: &[f64]) -> Value {
2915        Value::List(xs.iter().copied().map(Value::Float).collect())
2916    }
2917
2918    fn approx_vec_rule() -> RuleDef {
2919        RuleDef {
2920            name: "sim".into(),
2921            src_label: "V".into(),
2922            dst_label: "V".into(),
2923            predicate: Predicate::VectorSimilar {
2924                field: "emb".into(),
2925                min: 0.5,
2926            },
2927            edge_type: "SIM".into(),
2928            weight_prop: None,
2929            max_edges: None,
2930            approximate: true,
2931            via_label: None,
2932            via_edge: None,
2933            via_dir: None,
2934        }
2935    }
2936
2937    #[test]
2938    fn approximate_rule_rebuilds_after_drift_threshold() {
2939        with_ivf_drift_rebuild(1, || {
2940            let mut fx = Fx::new();
2941            let mut ids = Vec::new();
2942            for i in 0..6 {
2943                let x = i as f64 * 0.2;
2944                ids.push(fx.add("V", &format!("v{i}"), vec![("emb", emb(&[x, 1.0 - x]))]));
2945            }
2946            let mut eng = RuleEngine::new();
2947            {
2948                let mut g = fx.g();
2949                eng.create_rule(approx_vec_rule(), &mut g).unwrap();
2950            }
2951            assert!(eng.take_rebuild_needed().is_empty());
2952            {
2953                let mut g = fx.g();
2954                eng.on_node_removed(ids[0], &mut g);
2955            }
2956            assert!(
2957                eng.take_rebuild_needed().is_empty(),
2958                "drift=1 is not > threshold 1"
2959            );
2960            {
2961                let mut g = fx.g();
2962                eng.on_node_removed(ids[1], &mut g);
2963            }
2964            assert_eq!(eng.take_rebuild_needed(), vec!["sim".to_string()]);
2965            {
2966                let mut g = fx.g();
2967                eng.rebuild("sim", &mut g).unwrap();
2968            }
2969            assert!(
2970                eng.take_rebuild_needed().is_empty(),
2971                "rebuild must reset drift and not re-queue itself"
2972            );
2973            let drift = eng
2974                .export_ivf_state()
2975                .get("sim")
2976                .map(|(_, dst)| dst.2)
2977                .unwrap();
2978            assert_eq!(drift, 0, "rebuild resets dst-side IVF drift");
2979        });
2980    }
2981
2982    #[test]
2983    fn backfill_creates_edges_with_scores_and_delete_removes_exactly_them() {
2984        let mut fx = Fx::new();
2985        let a = fx.add("A", "a", vec![("tags", tags(&["x", "y"]))]);
2986        let b = fx.add("A", "b", vec![("tags", tags(&["x", "y"]))]);
2987        let _c = fx.add("A", "c", vec![("tags", tags(&["q"]))]);
2988        // pre-existing user edge with same type: must survive rule delete
2989        let et = fx.syms.intern("REL");
2990        fx.topo.add_edge(et, a, b);
2991        let mut eng = RuleEngine::new();
2992        let mut g = fx.g();
2993        eng.create_rule(overlap_rule(), &mut g).unwrap();
2994        // a↔b jaccard 1.0 both directions; user edge a→b pre-existed so only b→a is owned
2995        assert!(g.topo.neighbors(et, Direction::Out, b).contains(&a));
2996        assert_eq!(
2997            g.edge_props.get(et, b, a, "score"),
2998            Some(&Value::Float(1.0))
2999        );
3000        assert!(!eng.is_owned(et, a, b));
3001        assert!(eng.is_owned(et, b, a));
3002        eng.delete_rule("rel", &mut g).unwrap();
3003        assert!(g.topo.neighbors(et, Direction::Out, a).contains(&b)); // user edge kept
3004        assert!(!g.topo.neighbors(et, Direction::Out, b).contains(&a)); // derived removed
3005        assert_eq!(g.edge_props.get(et, b, a, "score"), None);
3006    }
3007
3008    #[test]
3009    fn incremental_update_adds_and_removes_edges() {
3010        let mut fx = Fx::new();
3011        let a = fx.add("A", "a", vec![("tags", tags(&["x", "y"]))]);
3012        let b = fx.add("A", "b", vec![("tags", tags(&["y", "z"]))]);
3013        let et = fx.syms.intern("REL");
3014        let mut eng = RuleEngine::new();
3015        {
3016            let mut g = fx.g();
3017            eng.create_rule(overlap_rule(), &mut g).unwrap(); // jaccard 1/3 < 0.4 → no edges
3018            assert_eq!(g.topo.edge_count(), 0);
3019        }
3020        // b's tags change to overlap strongly
3021        let old = fx.props.get(b, "tags").cloned();
3022        fx.props.set(b, "tags", tags(&["x", "y"]));
3023        {
3024            let mut g = fx.g();
3025            eng.on_node_changed(b, Some(("tags", old)), &mut g);
3026            assert!(g.topo.neighbors(et, Direction::Out, a).contains(&b));
3027            assert!(g.topo.neighbors(et, Direction::Out, b).contains(&a));
3028        }
3029        // and change away again → edges retract
3030        let old = fx.props.get(b, "tags").cloned();
3031        fx.props.set(b, "tags", tags(&["qqq"]));
3032        let mut g = fx.g();
3033        eng.on_node_changed(b, Some(("tags", old)), &mut g);
3034        assert_eq!(g.topo.edge_count(), 0);
3035        assert_eq!(g.edge_props.get(et, a, b, "score"), None);
3036    }
3037
3038    #[test]
3039    fn key_match_new_node_links_and_rebuild_is_noop() {
3040        let mut fx = Fx::new();
3041        fx.add("C", "c1", vec![]);
3042        let mut eng = RuleEngine::new();
3043        {
3044            let mut g = fx.g();
3045            eng.create_rule(
3046                RuleDef {
3047                    name: "fk".into(),
3048                    src_label: "T".into(),
3049                    dst_label: "C".into(),
3050                    predicate: Predicate::KeyMatch {
3051                        field: "cid".into(),
3052                    },
3053                    edge_type: "AT".into(),
3054                    weight_prop: None,
3055                    max_edges: None,
3056                    approximate: false,
3057                    via_label: None,
3058                    via_edge: None,
3059                    via_dir: None,
3060                },
3061                &mut g,
3062            )
3063            .unwrap();
3064        }
3065        let t = fx.add("T", "t1", vec![("cid", Value::Str("c1".into()))]);
3066        let (at, c1, count_before) = {
3067            let mut g = fx.g();
3068            eng.on_node_changed(t, None, &mut g);
3069            let at = g.syms.get("AT").unwrap();
3070            let c1 = g.ids.get("c1").unwrap();
3071            assert!(g.topo.neighbors(at, Direction::Out, t).contains(&c1));
3072            (at, c1, g.topo.edge_count())
3073        };
3074        let mut g = fx.g();
3075        eng.rebuild("fk", &mut g).unwrap();
3076        assert_eq!(g.topo.edge_count(), count_before); // rebuild is a no-op on consistent state
3077        assert!(g.topo.neighbors(at, Direction::Out, t).contains(&c1));
3078    }
3079
3080    #[test]
3081    fn score_refresh_on_persisting_owned_edge() {
3082        // Pins: weight set unconditionally even when add_edge returns false (edge persists).
3083        // jaccard({x,y,z},{x,y,q}) = |{x,y}|/|{x,y,z,q}| = 2/4 = 0.5 ≥ 0.2 → edges both ways.
3084        let mut fx = Fx::new();
3085        let a = fx.add("A", "a", vec![("tags", tags(&["x", "y", "z"]))]);
3086        let b = fx.add("A", "b", vec![("tags", tags(&["x", "y", "q"]))]);
3087        let et = fx.syms.intern("SIM");
3088        let mut eng = RuleEngine::new();
3089        {
3090            let mut g = fx.g();
3091            eng.create_rule(
3092                RuleDef {
3093                    name: "sim".into(),
3094                    src_label: "A".into(),
3095                    dst_label: "A".into(),
3096                    predicate: Predicate::Overlap {
3097                        field: "tags".into(),
3098                        min: 0.2,
3099                    },
3100                    edge_type: "SIM".into(),
3101                    weight_prop: Some("score".into()),
3102                    max_edges: None,
3103                    approximate: false,
3104                    via_label: None,
3105                    via_edge: None,
3106                    via_dir: None,
3107                },
3108                &mut g,
3109            )
3110            .unwrap();
3111            // Both directions present and owned with score ≈ 0.5.
3112            assert!(g.topo.neighbors(et, Direction::Out, a).contains(&b));
3113            assert!(g.topo.neighbors(et, Direction::Out, b).contains(&a));
3114            assert!(eng.is_owned(et, a, b) || eng.is_owned(et, b, a));
3115            let check = |v: Option<&Value>| {
3116                if let Some(Value::Float(f)) = v {
3117                    assert!(
3118                        (f - 0.5).abs() < 1e-9,
3119                        "initial score should be 0.5, got {f}"
3120                    );
3121                }
3122            };
3123            check(g.edge_props.get(et, a, b, "score"));
3124            check(g.edge_props.get(et, b, a, "score"));
3125        }
3126        // Change b's tags to match a exactly → jaccard = 1.0.
3127        let old = fx.props.get(b, "tags").cloned();
3128        fx.props.set(b, "tags", tags(&["x", "y", "z"]));
3129        {
3130            let mut g = fx.g();
3131            eng.on_node_changed(b, Some(("tags", old)), &mut g);
3132            // Both directions still present.
3133            assert!(g.topo.neighbors(et, Direction::Out, a).contains(&b));
3134            assert!(g.topo.neighbors(et, Direction::Out, b).contains(&a));
3135            // Scores must now be 1.0 on both directions.
3136            assert_eq!(
3137                g.edge_props.get(et, a, b, "score"),
3138                Some(&Value::Float(1.0)),
3139                "score on a→b must refresh to 1.0"
3140            );
3141            assert_eq!(
3142                g.edge_props.get(et, b, a, "score"),
3143                Some(&Value::Float(1.0)),
3144                "score on b→a must refresh to 1.0"
3145            );
3146        }
3147    }
3148
3149    #[test]
3150    fn dst_side_keymatch_links_when_c_node_inserted_after_t() {
3151        // Exercises the synthetic key-probe on src_side Scalar index (dst-side KeyMatch path).
3152        let mut fx = Fx::new();
3153        // Insert T node first with cid="c9" — no C node yet → no edge.
3154        let t = fx.add("T", "t1", vec![("cid", Value::Str("c9".into()))]);
3155        let mut eng = RuleEngine::new();
3156        {
3157            let mut g = fx.g();
3158            eng.create_rule(
3159                RuleDef {
3160                    name: "fk".into(),
3161                    src_label: "T".into(),
3162                    dst_label: "C".into(),
3163                    predicate: Predicate::KeyMatch {
3164                        field: "cid".into(),
3165                    },
3166                    edge_type: "AT".into(),
3167                    weight_prop: None,
3168                    max_edges: None,
3169                    approximate: false,
3170                    via_label: None,
3171                    via_edge: None,
3172                    via_dir: None,
3173                },
3174                &mut g,
3175            )
3176            .unwrap();
3177            // No C node → no edge.
3178            let at = g.syms.intern("AT");
3179            assert_eq!(g.topo.edge_count(), 0, "no C node yet → no edge");
3180            // t is indexed in src_side with Scalar{cid}="c9"
3181            let _ = at;
3182        }
3183        // Now insert C node "c9" and notify the engine.
3184        let c9 = fx.add("C", "c9", vec![]);
3185        {
3186            let mut g = fx.g();
3187            eng.on_node_changed(c9, None, &mut g);
3188            let at = g.syms.get("AT").unwrap();
3189            // The dst-side path must have probed src_side with key="c9" and found t.
3190            assert!(
3191                g.topo.neighbors(at, Direction::Out, t).contains(&c9),
3192                "T→C edge must appear when C node is inserted"
3193            );
3194            assert!(eng.is_owned(at, t, c9));
3195        }
3196    }
3197
3198    #[test]
3199    fn on_node_removed_retracts_both_sides_and_deindexes() {
3200        let mut fx = Fx::new();
3201        let a = fx.add("A", "a", vec![("tags", tags(&["x", "y"]))]);
3202        let b = fx.add("A", "b", vec![("tags", tags(&["x", "y"]))]);
3203        let et = fx.syms.intern("REL");
3204        let mut eng = RuleEngine::new();
3205        {
3206            let mut g = fx.g();
3207            eng.create_rule(overlap_rule(), &mut g).unwrap();
3208            assert!(g.topo.neighbors(et, Direction::Out, a).contains(&b));
3209            assert!(g.topo.neighbors(et, Direction::Out, b).contains(&a));
3210        }
3211        {
3212            let mut g = fx.g();
3213            eng.on_node_removed(a, &mut g);
3214            assert!(!g.topo.neighbors(et, Direction::Out, a).contains(&b));
3215            assert!(!g.topo.neighbors(et, Direction::Out, b).contains(&a));
3216            assert_eq!(g.edge_props.get(et, a, b, "score"), None);
3217            assert_eq!(g.edge_props.get(et, b, a, "score"), None);
3218            assert!(!eng.is_owned(et, a, b));
3219            assert!(!eng.is_owned(et, b, a));
3220        }
3221        // Partner re-links to a NEW matching node; de-indexed a is not a candidate.
3222        let c = fx.add("A", "c", vec![("tags", tags(&["x", "y"]))]);
3223        {
3224            let mut g = fx.g();
3225            eng.on_node_changed(c, None, &mut g);
3226            assert!(g.topo.neighbors(et, Direction::Out, b).contains(&c));
3227            assert!(g.topo.neighbors(et, Direction::Out, c).contains(&b));
3228            assert!(!g.topo.neighbors(et, Direction::Out, c).contains(&a));
3229            assert!(!g.topo.neighbors(et, Direction::Out, a).contains(&c));
3230        }
3231        // Second remove is a no-op (crash-window / already-retracted).
3232        {
3233            let mut g = fx.g();
3234            eng.on_node_removed(a, &mut g);
3235            assert!(g.topo.neighbors(et, Direction::Out, b).contains(&c));
3236        }
3237    }
3238
3239    #[test]
3240    fn duplicate_name_and_unknown_delete_error() {
3241        let mut fx = Fx::new();
3242        let mut eng = RuleEngine::new();
3243        let mut g = fx.g();
3244        eng.create_rule(overlap_rule(), &mut g).unwrap();
3245        assert!(eng.create_rule(overlap_rule(), &mut g).is_err());
3246        assert!(eng.delete_rule("nope", &mut g).is_err());
3247    }
3248
3249    /// C1: two rules sharing the same edge_type both match a pair of nodes.
3250    /// During backfill of R2, add_edge returns false for edges R1 already owns,
3251    /// so R2's provenance lacks them.  Deleting R1 removes those edges from the
3252    /// topology — but the rebuild-survivors step must then re-run R2 so it claims
3253    /// them.  Deleting R2 afterward must actually remove the edge.
3254    #[test]
3255    fn coowned_edge_type_survives_first_delete_gone_after_second() {
3256        let mut fx = Fx::new();
3257        let a = fx.add("A", "a", vec![("tags", tags(&["x", "y"]))]);
3258        let b = fx.add("A", "b", vec![("tags", tags(&["x", "y"]))]);
3259        let mut eng = RuleEngine::new();
3260        {
3261            let mut g = fx.g();
3262            // R1: Overlap min=0.1 — derives a↔b (jaccard 1.0 ≥ 0.1).
3263            eng.create_rule(
3264                RuleDef {
3265                    name: "r1".into(),
3266                    src_label: "A".into(),
3267                    dst_label: "A".into(),
3268                    predicate: Predicate::Overlap {
3269                        field: "tags".into(),
3270                        min: 0.1,
3271                    },
3272                    edge_type: "REL2".into(),
3273                    weight_prop: None,
3274                    max_edges: None,
3275                    approximate: false,
3276                    via_label: None,
3277                    via_edge: None,
3278                    via_dir: None,
3279                },
3280                &mut g,
3281            )
3282            .unwrap();
3283            // R2: same edge_type, Overlap min=0.2 — also derives a↔b.
3284            eng.create_rule(
3285                RuleDef {
3286                    name: "r2".into(),
3287                    src_label: "A".into(),
3288                    dst_label: "A".into(),
3289                    predicate: Predicate::Overlap {
3290                        field: "tags".into(),
3291                        min: 0.2,
3292                    },
3293                    edge_type: "REL2".into(),
3294                    weight_prop: None,
3295                    max_edges: None,
3296                    approximate: false,
3297                    via_label: None,
3298                    via_edge: None,
3299                    via_dir: None,
3300                },
3301                &mut g,
3302            )
3303            .unwrap();
3304
3305            let et = g.syms.intern("REL2");
3306            // Both directions must exist (either rule claims them).
3307            assert!(
3308                g.topo.neighbors(et, Direction::Out, a).contains(&b),
3309                "a→b must exist after both rules created"
3310            );
3311            assert!(
3312                g.topo.neighbors(et, Direction::Out, b).contains(&a),
3313                "b→a must exist after both rules created"
3314            );
3315
3316            // Delete R1 — rebuild-survivors re-runs R2 which must reclaim the edges.
3317            eng.delete_rule("r1", &mut g).unwrap();
3318            assert!(
3319                g.topo.neighbors(et, Direction::Out, a).contains(&b),
3320                "a→b must survive R1 deletion (R2 rebuilds and claims it)"
3321            );
3322            assert!(
3323                g.topo.neighbors(et, Direction::Out, b).contains(&a),
3324                "b→a must survive R1 deletion (R2 rebuilds and claims it)"
3325            );
3326            // R2 now owns both directions.
3327            assert!(
3328                eng.is_owned(et, a, b),
3329                "a→b must be owned by R2 after rebuild"
3330            );
3331            assert!(
3332                eng.is_owned(et, b, a),
3333                "b→a must be owned by R2 after rebuild"
3334            );
3335
3336            // Delete R2 — no survivor left, edges must be gone.
3337            eng.delete_rule("r2", &mut g).unwrap();
3338            assert!(
3339                !g.topo.neighbors(et, Direction::Out, a).contains(&b),
3340                "a→b must be gone after both rules deleted"
3341            );
3342            assert!(
3343                !g.topo.neighbors(et, Direction::Out, b).contains(&a),
3344                "b→a must be gone after both rules deleted"
3345            );
3346        }
3347    }
3348
3349    /// Helper: FieldEqual rule with top-k per-source cap.
3350    fn topk_eq_rule(k: u64) -> RuleDef {
3351        RuleDef {
3352            name: "eq".into(),
3353            src_label: "N".into(),
3354            dst_label: "N".into(),
3355            predicate: Predicate::FieldEqual { field: "k".into() },
3356            edge_type: "EQ".into(),
3357            weight_prop: None,
3358            max_edges: Some(k),
3359            approximate: false,
3360            via_label: None,
3361            via_edge: None,
3362            via_dir: None,
3363        }
3364    }
3365
3366    fn prov_pairs(eng: &RuleEngine, name: &str) -> BTreeSet<(u32, u32)> {
3367        eng.provenance()
3368            .get(name)
3369            .map(|s| s.iter().map(|&(_, a, b)| (a, b)).collect())
3370            .unwrap_or_default()
3371    }
3372
3373    /// k=1: each src gets its single best-scored dst (score DESC, key ASC
3374    /// tiebreak).  FieldEqual has uniform score 1.0, so the winner is the dst
3375    /// with the lexicographically smallest key that is not the src itself.
3376    #[test]
3377    fn topk_k1_keeps_best_scored_dst() {
3378        let mut fx = Fx::new();
3379        let mut eng = RuleEngine::new();
3380        {
3381            let mut g = fx.g();
3382            eng.create_rule(topk_eq_rule(1), &mut g).unwrap();
3383        }
3384        // Insert 4 nodes all sharing k="const".  Keys: n0 < n1 < n2 < n3.
3385        let mut ids = Vec::new();
3386        for i in 0..4usize {
3387            let id = fx.add(
3388                "N",
3389                &format!("n{i}"),
3390                vec![("k", Value::Str("const".into()))],
3391            );
3392            ids.push(id);
3393            let mut g = fx.g();
3394            eng.on_node_changed(id, None, &mut g);
3395        }
3396        let et = fx.syms.get("EQ").unwrap();
3397        // Each src's single allowed dst must be the smallest key ≠ self.
3398        // n0 → n1 (smallest other)
3399        // n1 → n0 (n0 < n1)
3400        // n2 → n0
3401        // n3 → n0
3402        let expected_dsts = [ids[1], ids[0], ids[0], ids[0]];
3403        for (i, (&src, &expected_dst)) in ids.iter().zip(expected_dsts.iter()).enumerate() {
3404            let out: Vec<u32> = fx.topo.neighbors(et, Direction::Out, src).to_vec();
3405            assert_eq!(
3406                out,
3407                vec![expected_dst],
3408                "src n{i} should point only to the best dst"
3409            );
3410        }
3411        assert_eq!(eng.provenance()["eq"].len(), 4);
3412        assert!(!eng.is_tripped("eq"), "top-k rules never trip");
3413    }
3414
3415    /// k=2 insert-evict: adding a better dst evicts the worst of the current k.
3416    /// Uses NumericWithin (scored) so scores differ across dsts.
3417    #[test]
3418    fn topk_insert_evict() {
3419        // Rule: S→D with VectorSimilar-alike (we use NumericWithin for simplicity).
3420        // 3 src nodes, numeric field "v"; tolerance 10.0 so score = 1-|Δ|/10.
3421        // k=1 per source.
3422        let mut fx = Fx::new();
3423        let rule = RuleDef {
3424            name: "nw".into(),
3425            src_label: "S".into(),
3426            dst_label: "D".into(),
3427            predicate: Predicate::NumericWithin {
3428                field: "v".into(),
3429                tolerance: 10.0,
3430            },
3431            edge_type: "NEAR".into(),
3432            weight_prop: Some("score".into()),
3433            max_edges: Some(1),
3434            approximate: false,
3435            via_label: None,
3436            via_edge: None,
3437            via_dir: None,
3438        };
3439        let mut eng = RuleEngine::new();
3440        {
3441            let mut g = fx.g();
3442            eng.create_rule(rule, &mut g).unwrap();
3443        }
3444
3445        // src s0 with v=0.0
3446        let s0 = fx.add("S", "s0", vec![("v", Value::Float(0.0))]);
3447        // dst d_far with v=9.0 → score=0.1 (worst)
3448        let d_far = fx.add("D", "d_far", vec![("v", Value::Float(9.0))]);
3449        {
3450            let mut g = fx.g();
3451            eng.on_node_changed(s0, None, &mut g);
3452            eng.on_node_changed(d_far, None, &mut g);
3453        }
3454        let et = fx.syms.get("NEAR").unwrap();
3455        // s0 → d_far (only candidate)
3456        assert!(fx.topo.neighbors(et, Direction::Out, s0).contains(&d_far));
3457        assert_eq!(eng.provenance()["nw"].len(), 1);
3458
3459        // Insert d_close with v=1.0 → score=0.9 (better than d_far).
3460        let d_close = fx.add("D", "d_close", vec![("v", Value::Float(1.0))]);
3461        {
3462            let mut g = fx.g();
3463            eng.on_node_changed(d_close, None, &mut g);
3464        }
3465        // s0 should now point to d_close (evicting d_far).
3466        let out: Vec<u32> = fx.topo.neighbors(et, Direction::Out, s0).to_vec();
3467        assert_eq!(out, vec![d_close], "d_close should evict d_far");
3468        assert!(!fx.topo.neighbors(et, Direction::Out, s0).contains(&d_far));
3469        assert_eq!(eng.provenance()["nw"].len(), 1);
3470        assert!(eng.by_node_consistent());
3471    }
3472
3473    /// Retract-backfill: removing the best dst causes the next-best to fill in.
3474    #[test]
3475    fn topk_retract_backfill() {
3476        let mut fx = Fx::new();
3477        let rule = RuleDef {
3478            name: "nw".into(),
3479            src_label: "S".into(),
3480            dst_label: "D".into(),
3481            predicate: Predicate::NumericWithin {
3482                field: "v".into(),
3483                tolerance: 10.0,
3484            },
3485            edge_type: "NEAR".into(),
3486            weight_prop: Some("score".into()),
3487            max_edges: Some(1),
3488            approximate: false,
3489            via_label: None,
3490            via_edge: None,
3491            via_dir: None,
3492        };
3493        let mut eng = RuleEngine::new();
3494
3495        let s0 = fx.add("S", "s0", vec![("v", Value::Float(0.0))]);
3496        let d_close = fx.add("D", "d_close", vec![("v", Value::Float(1.0))]); // score=0.9
3497        let d_far = fx.add("D", "d_far", vec![("v", Value::Float(8.0))]); // score=0.2
3498        {
3499            let mut g = fx.g();
3500            eng.create_rule(rule, &mut g).unwrap();
3501        }
3502        let et = fx.syms.get("NEAR").unwrap();
3503        // d_close is the top-1 dst.
3504        assert!(fx.topo.neighbors(et, Direction::Out, s0).contains(&d_close));
3505        assert!(!fx.topo.neighbors(et, Direction::Out, s0).contains(&d_far));
3506        assert_eq!(eng.provenance()["nw"].len(), 1);
3507
3508        // Break d_close's match by pushing its v out of tolerance.
3509        let old = fx.props.get(d_close, "v").cloned();
3510        fx.props.set(d_close, "v", Value::Float(50.0));
3511        {
3512            let mut g = fx.g();
3513            eng.on_node_changed(d_close, Some(("v", old)), &mut g);
3514        }
3515        // d_far should backfill.
3516        assert!(!fx.topo.neighbors(et, Direction::Out, s0).contains(&d_close));
3517        assert!(
3518            fx.topo.neighbors(et, Direction::Out, s0).contains(&d_far),
3519            "d_far should backfill after d_close retracted"
3520        );
3521        assert_eq!(eng.provenance()["nw"].len(), 1);
3522        assert!(eng.by_node_consistent());
3523    }
3524
3525    /// Tie-breaking: equal scores → dst_key ASC wins.
3526    #[test]
3527    fn topk_tie_broken_by_dst_key() {
3528        // FieldEqual: all dsts have score 1.0 → tiebreak by key.
3529        let mut fx = Fx::new();
3530        let mut eng = RuleEngine::new();
3531        {
3532            let mut g = fx.g();
3533            eng.create_rule(topk_eq_rule(2), &mut g).unwrap();
3534        }
3535        // 5 nodes all with k="x" → each src matches 4 others; top-2 by key.
3536        // Keys: a, b, c, d, e (alphabetical).
3537        for name in ["a", "b", "c", "d", "e"] {
3538            let id = fx.add("N", name, vec![("k", Value::Str("x".into()))]);
3539            let mut g = fx.g();
3540            eng.on_node_changed(id, None, &mut g);
3541        }
3542        let et = fx.syms.get("EQ").unwrap();
3543        let get_id = |key: &str| fx.ids.get(key).unwrap();
3544        // Node "a" should point to the two smallest keys that aren't "a": b, c.
3545        let a = get_id("a");
3546        let b = get_id("b");
3547        let c = get_id("c");
3548        let out_a: BTreeSet<u32> = fx
3549            .topo
3550            .neighbors(et, Direction::Out, a)
3551            .iter()
3552            .copied()
3553            .collect();
3554        assert!(out_a.contains(&b), "a→b (b is best key after a)");
3555        assert!(out_a.contains(&c), "a→c (c is 2nd best key)");
3556        assert_eq!(out_a.len(), 2);
3557        // Node "e" should point to "a" and "b" (two smallest keys ≠ "e").
3558        let e = get_id("e");
3559        let out_e: BTreeSet<u32> = fx
3560            .topo
3561            .neighbors(et, Direction::Out, e)
3562            .iter()
3563            .copied()
3564            .collect();
3565        assert!(out_e.contains(&a), "e→a");
3566        assert!(out_e.contains(&b), "e→b");
3567        assert_eq!(out_e.len(), 2);
3568        assert!(eng.by_node_consistent());
3569    }
3570
3571    /// When k >= candidate count, all candidates are included (no truncation).
3572    #[test]
3573    fn topk_k_larger_than_candidate_count() {
3574        let mut fx = Fx::new();
3575        let mut eng = RuleEngine::new();
3576        {
3577            let mut g = fx.g();
3578            // k=100 but only 3 other nodes → all 3 included.
3579            eng.create_rule(topk_eq_rule(100), &mut g).unwrap();
3580        }
3581        for i in 0..4usize {
3582            let id = fx.add("N", &format!("n{i}"), vec![("k", Value::Str("c".into()))]);
3583            let mut g = fx.g();
3584            eng.on_node_changed(id, None, &mut g);
3585        }
3586        // 4 nodes × 3 matches each = 12 directed edges.
3587        assert_eq!(eng.provenance()["eq"].len(), 12);
3588        assert!(!eng.is_tripped("eq"));
3589    }
3590
3591    /// rebuild() with top-k rule re-converges to the correct per-source top-k
3592    /// after externally removing a node's field.
3593    #[test]
3594    fn topk_rebuild_exact() {
3595        let mut fx = Fx::new();
3596        let mut eng = RuleEngine::new();
3597        {
3598            let mut g = fx.g();
3599            eng.create_rule(topk_eq_rule(1), &mut g).unwrap();
3600        }
3601        // 3 nodes with k="x" → each gets 1 dst (smallest key ≠ self).
3602        let _a = fx.add("N", "a", vec![("k", Value::Str("x".into()))]);
3603        let _b = fx.add("N", "b", vec![("k", Value::Str("x".into()))]);
3604        let _c = fx.add("N", "c", vec![("k", Value::Str("x".into()))]);
3605        {
3606            let mut g = fx.g();
3607            eng.on_node_changed(_a, None, &mut g);
3608            eng.on_node_changed(_b, None, &mut g);
3609            eng.on_node_changed(_c, None, &mut g);
3610        }
3611        assert_eq!(eng.provenance()["eq"].len(), 3);
3612
3613        // rebuild should produce the same result.
3614        {
3615            let mut g = fx.g();
3616            eng.rebuild("eq", &mut g).unwrap();
3617        }
3618        assert_eq!(eng.provenance()["eq"].len(), 3);
3619        assert!(!eng.is_tripped("eq"));
3620        assert!(eng.by_node_consistent());
3621    }
3622
3623    /// by_node index stays consistent across top-k inserts, evictions and rebuild.
3624    #[test]
3625    fn topk_by_node_consistent() {
3626        let mut fx = Fx::new();
3627        let mut eng = RuleEngine::new();
3628        {
3629            let mut g = fx.g();
3630            eng.create_rule(topk_eq_rule(2), &mut g).unwrap();
3631        }
3632        for i in 0..5usize {
3633            let id = fx.add(
3634                "N",
3635                &format!("n{i}"),
3636                vec![("k", Value::Str("const".into()))],
3637            );
3638            let mut g = fx.g();
3639            eng.on_node_changed(id, None, &mut g);
3640        }
3641        assert!(eng.by_node_consistent(), "consistent after insertions");
3642
3643        // Evict by changing a prop.
3644        let id2 = fx.ids.get("n2").unwrap();
3645        let old = fx.props.get(id2, "k").cloned();
3646        fx.props.set(id2, "k", Value::Str("other".into()));
3647        {
3648            let mut g = fx.g();
3649            eng.on_node_changed(id2, Some(("k", old)), &mut g);
3650        }
3651        assert!(eng.by_node_consistent(), "consistent after eviction");
3652
3653        {
3654            let mut g = fx.g();
3655            eng.rebuild("eq", &mut g).unwrap();
3656        }
3657        assert!(eng.by_node_consistent(), "consistent after rebuild");
3658    }
3659
3660    fn numeric_rule() -> RuleDef {
3661        RuleDef {
3662            name: "nw".into(),
3663            src_label: "C".into(),
3664            dst_label: "C".into(),
3665            predicate: Predicate::NumericWithin {
3666                field: "year".into(),
3667                tolerance: 2.0,
3668            },
3669            edge_type: "NEAR".into(),
3670            weight_prop: Some("score".into()),
3671            max_edges: None,
3672            approximate: false,
3673            via_label: None,
3674            via_edge: None,
3675            via_dir: None,
3676        }
3677    }
3678
3679    fn geo_rule() -> RuleDef {
3680        RuleDef {
3681            name: "geo".into(),
3682            src_label: "City".into(),
3683            dst_label: "City".into(),
3684            predicate: Predicate::GeoRadius {
3685                field: "loc".into(),
3686                km: 400.0,
3687            },
3688            edge_type: "NEAR_GEO".into(),
3689            weight_prop: Some("score".into()),
3690            max_edges: None,
3691            approximate: false,
3692            via_label: None,
3693            via_edge: None,
3694            via_dir: None,
3695        }
3696    }
3697
3698    fn vec_rule() -> RuleDef {
3699        RuleDef {
3700            name: "vec".into(),
3701            src_label: "Doc".into(),
3702            dst_label: "Doc".into(),
3703            predicate: Predicate::VectorSimilar {
3704                field: "emb".into(),
3705                min: 0.9,
3706            },
3707            edge_type: "SIM".into(),
3708            weight_prop: Some("score".into()),
3709            max_edges: None,
3710            approximate: false,
3711            via_label: None,
3712            via_edge: None,
3713            via_dir: None,
3714        }
3715    }
3716
3717    fn pair_edges(topo: &Topology, et: u32, a: u32, b: u32) -> bool {
3718        topo.neighbors(et, Direction::Out, a).contains(&b)
3719            && topo.neighbors(et, Direction::Out, b).contains(&a)
3720    }
3721
3722    #[test]
3723    fn numeric_within_incremental_crosses_bucket_and_clears_old_index() {
3724        let mut fx = Fx::new();
3725        let a = fx.add("C", "a", vec![("year", Value::Float(10.0))]);
3726        let b = fx.add("C", "b", vec![("year", Value::Float(12.0))]);
3727        let et = fx.syms.intern("NEAR");
3728        let mut eng = RuleEngine::new();
3729        {
3730            let mut g = fx.g();
3731            eng.create_rule(numeric_rule(), &mut g).unwrap();
3732            // |12−10| = 2 ≤ 2 → score 0.0 both ways
3733            assert!(pair_edges(g.topo, et, a, b));
3734        }
3735
3736        // 12.0 (bucket 6) → 16.1 (bucket 8): two buckets away, so the old
3737        // value's ±1 probe no longer reaches b. Match breaks.
3738        let old = fx.props.get(b, "year").cloned();
3739        fx.props.set(b, "year", Value::Float(16.1));
3740        {
3741            let mut g = fx.g();
3742            eng.on_node_changed(b, Some(("year", old)), &mut g);
3743            assert!(!pair_edges(g.topo, et, a, b));
3744            assert_eq!(g.topo.edge_count(), 0);
3745        }
3746        let def = numeric_rule();
3747        let spec = candidate_spec_for(&def);
3748        let old_map: std::collections::HashMap<_, _> =
3749            [("year".to_string(), Value::Float(12.0))].into();
3750        let old_get = |f: &str| old_map.get(f).cloned();
3751        let src_hits = eng.indexes["nw"].src_side.candidates(&spec, &old_get);
3752        let dst_hits = eng.indexes["nw"].dst_side.candidates(&spec, &old_get);
3753        assert!(!src_hits.contains(&b), "old src bucket must drop b");
3754        assert!(!dst_hits.contains(&b), "old dst bucket must drop b");
3755        assert!(src_hits.contains(&a));
3756
3757        // 16.1 → 11.9 (bucket 5): match returns.
3758        let old = fx.props.get(b, "year").cloned();
3759        fx.props.set(b, "year", Value::Float(11.9));
3760        let mut g = fx.g();
3761        eng.on_node_changed(b, Some(("year", old)), &mut g);
3762        assert!(pair_edges(g.topo, et, a, b));
3763    }
3764
3765    fn loc_val(lat: f64, lon: f64) -> Value {
3766        Value::List(vec![Value::Float(lat), Value::Float(lon)])
3767    }
3768
3769    fn emb_val(vals: &[f64]) -> Value {
3770        Value::List(vals.iter().copied().map(Value::Float).collect())
3771    }
3772
3773    #[test]
3774    fn rebuild_is_noop_for_numeric_geo_and_vector() {
3775        let mut fx = Fx::new();
3776        let ca = fx.add("C", "ca", vec![("year", Value::Int(1998))]);
3777        let cb = fx.add("C", "cb", vec![("year", Value::Float(2000.0))]);
3778        let pa = fx.add("City", "paris", vec![("loc", loc_val(48.8566, 2.3522))]);
3779        let lo = fx.add("City", "london", vec![("loc", loc_val(51.5074, -0.1278))]);
3780        let da = fx.add("Doc", "d1", vec![("emb", emb_val(&[1.0, 0.0]))]);
3781        let db = fx.add("Doc", "d2", vec![("emb", emb_val(&[1.0, 0.0]))]);
3782
3783        let mut eng = RuleEngine::new();
3784        {
3785            let mut g = fx.g();
3786            eng.create_rule(numeric_rule(), &mut g).unwrap();
3787            eng.create_rule(geo_rule(), &mut g).unwrap();
3788            eng.create_rule(vec_rule(), &mut g).unwrap();
3789        }
3790
3791        let (near, ngeo, sim) = (
3792            fx.syms.get("NEAR").unwrap(),
3793            fx.syms.get("NEAR_GEO").unwrap(),
3794            fx.syms.get("SIM").unwrap(),
3795        );
3796        assert!(pair_edges(&fx.topo, near, ca, cb));
3797        assert!(pair_edges(&fx.topo, ngeo, pa, lo));
3798        assert!(pair_edges(&fx.topo, sim, da, db));
3799        let before = fx.topo.edge_count();
3800
3801        {
3802            let mut g = fx.g();
3803            eng.rebuild("nw", &mut g).unwrap();
3804            eng.rebuild("geo", &mut g).unwrap();
3805            eng.rebuild("vec", &mut g).unwrap();
3806        }
3807        assert_eq!(fx.topo.edge_count(), before);
3808        assert!(pair_edges(&fx.topo, near, ca, cb));
3809        assert!(pair_edges(&fx.topo, ngeo, pa, lo));
3810        assert!(pair_edges(&fx.topo, sim, da, db));
3811    }
3812
3813    fn fk_rule() -> RuleDef {
3814        RuleDef {
3815            name: "works_at".into(),
3816            src_label: "T".into(),
3817            dst_label: "C".into(),
3818            predicate: Predicate::KeyMatch {
3819                field: "cid".into(),
3820            },
3821            edge_type: "AT".into(),
3822            weight_prop: None,
3823            max_edges: None,
3824            approximate: false,
3825            via_label: None,
3826            via_edge: None,
3827            via_dir: None,
3828        }
3829    }
3830
3831    #[test]
3832    fn by_node_matches_rebuild_after_mutation_storm() {
3833        let mut fx = Fx::new();
3834        let hub = fx.add("C", "hub", vec![]);
3835        let other = fx.add("C", "other", vec![]);
3836        let mut people = Vec::new();
3837        for i in 0..40 {
3838            let cid = if i < 30 { "hub" } else { "other" };
3839            people.push(fx.add(
3840                "T",
3841                &format!("t{i}"),
3842                vec![("cid", Value::Str(cid.into())), ("tags", tags(&["x", "y"]))],
3843            ));
3844        }
3845        let mut overlap = overlap_rule();
3846        overlap.src_label = "T".into();
3847        overlap.dst_label = "T".into();
3848        let mut eng = RuleEngine::new();
3849        {
3850            let mut g = fx.g();
3851            eng.create_rule(fk_rule(), &mut g).unwrap();
3852            eng.create_rule(overlap, &mut g).unwrap();
3853        }
3854        assert!(eng.by_node_consistent());
3855        assert_eq!(eng.provenance_touching_len(hub), 30);
3856
3857        // Incremental: re-home half the hub people, flip tags, then restore.
3858        for (i, &id) in people.iter().enumerate().take(15) {
3859            let old = fx.props.get(id, "cid").cloned();
3860            fx.props.set(id, "cid", Value::Str("other".into()));
3861            let mut g = fx.g();
3862            eng.on_node_changed(id, Some(("cid", old)), &mut g);
3863            assert!(
3864                eng.by_node_consistent(),
3865                "inconsistent after cid update {i}"
3866            );
3867        }
3868        for &id in people.iter().take(8) {
3869            let old = fx.props.get(id, "tags").cloned();
3870            fx.props.set(id, "tags", tags(&["q"]));
3871            let mut g = fx.g();
3872            eng.on_node_changed(id, Some(("tags", old)), &mut g);
3873        }
3874        assert!(eng.by_node_consistent());
3875
3876        // Delete-node cleanup uses the reverse index.
3877        {
3878            let mut g = fx.g();
3879            eng.on_node_removed(people[0], &mut g);
3880        }
3881        fx.labels[people[0] as usize] = u32::MAX;
3882        assert!(eng.by_node_consistent());
3883        assert_eq!(eng.provenance_touching_len(people[0]), 0);
3884
3885        {
3886            let mut g = fx.g();
3887            eng.rebuild("works_at", &mut g).unwrap();
3888            eng.rebuild("rel", &mut g).unwrap();
3889        }
3890        assert!(eng.by_node_consistent());
3891
3892        {
3893            let mut g = fx.g();
3894            eng.delete_rule("rel", &mut g).unwrap();
3895        }
3896        assert!(eng.by_node_consistent());
3897        assert_eq!(eng.provenance_touching(people[1]).count(), 1);
3898
3899        // Persist-restore rebuilds the reverse index from provenance.
3900        let (defs, prov, tripped, fires) = eng.to_persist();
3901        let restored = RuleEngine::from_persist(defs, prov, tripped, fires);
3902        assert!(restored.by_node_consistent());
3903        assert_eq!(
3904            restored.provenance_touching_len(hub),
3905            eng.provenance_touching_len(hub)
3906        );
3907        assert_eq!(
3908            restored.provenance_touching_len(other),
3909            eng.provenance_touching_len(other)
3910        );
3911    }
3912
3913    #[test]
3914    fn provenance_touching_high_degree_hub() {
3915        let mut fx = Fx::new();
3916        let hub = fx.add("C", "hub", vec![]);
3917        let mut first = None;
3918        for i in 0..256 {
3919            let id = fx.add(
3920                "T",
3921                &format!("t{i}"),
3922                vec![("cid", Value::Str("hub".into()))],
3923            );
3924            if first.is_none() {
3925                first = Some(id);
3926            }
3927        }
3928        let first = first.unwrap();
3929        let mut eng = RuleEngine::new();
3930        {
3931            let mut g = fx.g();
3932            eng.create_rule(fk_rule(), &mut g).unwrap();
3933        }
3934        assert!(eng.by_node_consistent());
3935        assert_eq!(eng.provenance_touching_len(hub), 256);
3936        assert_eq!(eng.provenance_touching_len(first), 1);
3937        let hits: Vec<_> = eng.provenance_touching(first).collect();
3938        assert_eq!(hits.len(), 1);
3939        assert_eq!(hits[0].0, "works_at");
3940        assert_eq!(hits[0].2, first);
3941        assert_eq!(hits[0].3, hub);
3942    }
3943
3944    /// by_node index stays consistent across global-budget trip and rebuild
3945    /// (max_edges: None path — DEFAULT_MAX_EDGES = 1_000_000).
3946    ///
3947    /// Uses a tiny budget via a special rule with `max_edges: None` but many
3948    /// nodes to naturally exceed the default; instead we directly test the
3949    /// None-path by verifying that the by_node index is consistent at each
3950    /// step of normal insertions and rebuilds.
3951    #[test]
3952    fn by_node_consistent_across_inserts_and_rebuild() {
3953        let mut fx = Fx::new();
3954        let mut eng = RuleEngine::new();
3955        let rule = RuleDef {
3956            name: "eq".into(),
3957            src_label: "N".into(),
3958            dst_label: "N".into(),
3959            predicate: Predicate::FieldEqual { field: "k".into() },
3960            edge_type: "EQ".into(),
3961            weight_prop: None,
3962            max_edges: None, // global-budget path, DEFAULT_MAX_EDGES = 1_000_000
3963            approximate: false,
3964            via_label: None,
3965            via_edge: None,
3966            via_dir: None,
3967        };
3968        {
3969            let mut g = fx.g();
3970            eng.create_rule(rule, &mut g).unwrap();
3971        }
3972        let mut ids = Vec::new();
3973        for i in 0..6 {
3974            let id = fx.add(
3975                "N",
3976                &format!("n{i}"),
3977                vec![("k", Value::Str("const".into()))],
3978            );
3979            ids.push(id);
3980            let mut g = fx.g();
3981            eng.on_node_changed(id, None, &mut g);
3982        }
3983        // 6 nodes × 5 matches each = 30 directed edges (well under 1M budget).
3984        assert_eq!(eng.provenance()["eq"].len(), 30);
3985        assert!(!eng.is_tripped("eq"));
3986        assert!(eng.by_node_consistent(), "consistent after insertions");
3987
3988        // Change one node's field — triggers retract + backfill on that src.
3989        let old = fx.props.get(ids[3], "k").cloned();
3990        fx.props.set(ids[3], "k", Value::Str("other".into()));
3991        {
3992            let mut g = fx.g();
3993            eng.on_node_changed(ids[3], Some(("k", old)), &mut g);
3994        }
3995        assert!(eng.by_node_consistent(), "consistent after property change");
3996
3997        {
3998            let mut g = fx.g();
3999            eng.rebuild("eq", &mut g).unwrap();
4000        }
4001        assert!(!eng.is_tripped("eq"));
4002        assert!(eng.by_node_consistent(), "consistent after rebuild");
4003    }
4004
4005    fn mix64(mut x: u64) -> u64 {
4006        x = x.wrapping_add(0x9E3779B97F4A7C15);
4007        x = (x ^ (x >> 30)).wrapping_mul(0xBF58476D1CE4E5B9);
4008        x = (x ^ (x >> 27)).wrapping_mul(0x94D049BB133111EB);
4009        x ^ (x >> 31)
4010    }
4011
4012    fn rand_emb(seed: u64, i: u32, dim: usize) -> Value {
4013        let vals: Vec<f64> = (0..dim)
4014            .map(|d| {
4015                let bits = mix64(seed ^ ((i as u64 + 1).wrapping_mul(0x100000001)) ^ (d as u64));
4016                let mut f = (bits as f64) / (u64::MAX as f64) * 2.0 - 1.0;
4017                if f == 0.0 {
4018                    f = 1.0;
4019                }
4020                f
4021            })
4022            .collect();
4023        emb_val(&vals)
4024    }
4025
4026    fn seed_docs(n: u32, seed: u64) -> (Fx, Vec<u32>) {
4027        let dims = [2usize, 3, 4, 8];
4028        let mut fx = Fx::new();
4029        let mut ids = Vec::new();
4030        for i in 0..n {
4031            let dim = dims[(i as usize) % dims.len()];
4032            ids.push(fx.add(
4033                "Doc",
4034                &format!("d{i}"),
4035                vec![("emb", rand_emb(seed, i, dim))],
4036            ));
4037        }
4038        (fx, ids)
4039    }
4040
4041    /// Identity proof: 500 mixed-dim vectors, derived edges with the dim
4042    /// reject on vs forced off (and vs brute-force evaluate) are identical.
4043    #[test]
4044    fn vector_dim_reject_matches_unfiltered_and_oracle() {
4045        const N: u32 = 500;
4046        const SEED: u64 = 0xC0FF_EE00_D15C;
4047        let def = vec_rule();
4048
4049        let (mut fx_on, ids) = seed_docs(N, SEED);
4050        let mut eng_on = RuleEngine::new();
4051        {
4052            let mut g = fx_on.g();
4053            eng_on.create_rule(def.clone(), &mut g).unwrap();
4054        }
4055        let on = prov_pairs(&eng_on, "vec");
4056        assert!(!on.is_empty(), "seeded set must produce some edges");
4057
4058        let (mut fx_off, _) = seed_docs(N, SEED);
4059        let mut eng_off = RuleEngine::new();
4060        {
4061            let mut g = fx_off.g();
4062            with_vector_dim_reject(false, || {
4063                eng_off.create_rule(def.clone(), &mut g).unwrap();
4064            });
4065        }
4066        assert_eq!(on, prov_pairs(&eng_off, "vec"), "filter vs no-filter");
4067
4068        let mut brute = BTreeSet::new();
4069        for &s in &ids {
4070            for &d in &ids {
4071                if s == d {
4072                    continue;
4073                }
4074                let skey = fx_on.ids.key_of(s).unwrap();
4075                let dkey = fx_on.ids.key_of(d).unwrap();
4076                let sget = |f: &str| fx_on.props.get(s, f).cloned();
4077                let dget = |f: &str| fx_on.props.get(d, f).cloned();
4078                if evaluate(
4079                    &def.predicate,
4080                    &NodeView {
4081                        key: skey,
4082                        props: &sget,
4083                    },
4084                    &NodeView {
4085                        key: dkey,
4086                        props: &dget,
4087                    },
4088                )
4089                .is_some()
4090                {
4091                    brute.insert((s, d));
4092                }
4093            }
4094        }
4095        assert_eq!(on, brute, "filter vs brute-force evaluate");
4096    }
4097
4098    /// Dim change must flow through remove(old)+insert(new); edges match a
4099    /// fresh engine built from the post-update props.
4100    #[test]
4101    fn vector_dim_change_updates_cache_and_matches_fresh_build() {
4102        let mut fx = Fx::new();
4103        let a = fx.add("Doc", "a", vec![("emb", emb_val(&[1.0, 0.0]))]);
4104        let b = fx.add("Doc", "b", vec![("emb", emb_val(&[1.0, 0.0]))]);
4105        let c = fx.add("Doc", "c", vec![("emb", emb_val(&[1.0, 0.0, 0.0]))]);
4106        let mut eng = RuleEngine::new();
4107        {
4108            let mut g = fx.g();
4109            eng.create_rule(vec_rule(), &mut g).unwrap();
4110        }
4111        assert_eq!(eng.indexes["vec"].src_side.vec_dim(a), Some(2));
4112        assert_eq!(eng.indexes["vec"].src_side.vec_dim(c), Some(3));
4113        assert_eq!(prov_pairs(&eng, "vec"), BTreeSet::from([(a, b), (b, a)]));
4114
4115        let old = fx.props.get(b, "emb").cloned();
4116        fx.props.set(b, "emb", emb_val(&[1.0, 0.0, 0.0]));
4117        {
4118            let mut g = fx.g();
4119            eng.on_node_changed(b, Some(("emb", old)), &mut g);
4120        }
4121        assert_eq!(eng.indexes["vec"].src_side.vec_dim(b), Some(3));
4122        assert_eq!(eng.indexes["vec"].dst_side.vec_dim(b), Some(3));
4123        let after = prov_pairs(&eng, "vec");
4124        assert_eq!(after, BTreeSet::from([(b, c), (c, b)]));
4125
4126        // Separate graph: first engine already owns the b↔c edges in `fx.topo`.
4127        let mut fresh_fx = Fx::new();
4128        let fa = fresh_fx.add("Doc", "a", vec![("emb", emb_val(&[1.0, 0.0]))]);
4129        let fb = fresh_fx.add("Doc", "b", vec![("emb", emb_val(&[1.0, 0.0, 0.0]))]);
4130        let fc = fresh_fx.add("Doc", "c", vec![("emb", emb_val(&[1.0, 0.0, 0.0]))]);
4131        let mut fresh = RuleEngine::new();
4132        {
4133            let mut g = fresh_fx.g();
4134            fresh.create_rule(vec_rule(), &mut g).unwrap();
4135        }
4136        assert_eq!(
4137            prov_pairs(&fresh, "vec"),
4138            BTreeSet::from([(fb, fc), (fc, fb)])
4139        );
4140        assert_eq!(fresh.indexes["vec"].src_side.vec_dim(fb), Some(3));
4141        assert_eq!(fresh.indexes["vec"].src_side.vec_dim(fa), Some(2));
4142    }
4143
4144    // -----------------------------------------------------------------------
4145    // Streaming backfill — order-identity and memory-bound tests (Plan 11 M1)
4146    // -----------------------------------------------------------------------
4147
4148    /// Top-k order-identity property test.
4149    ///
4150    /// For rules with `max_edges: Some(k)` (top-k per-source semantics),
4151    /// verifies that `create_rule` streaming backfill produces the same
4152    /// per-source top-k set as an independent brute-force reference.
4153    ///
4154    /// The reference is intentionally independent of `filter_src_top_k`:
4155    /// it sorts candidates inline (score DESC, dst-key ASC, take k) so a
4156    /// comparator bug cannot self-agree between reference and actual.
4157    ///
4158    /// Covers all four `CandidateSpec` paths through `compute_desired`:
4159    /// - `FieldEqual` → `CandidateSpec::Scalar` (uniform score=1.0, tiebreak by key)
4160    /// - `NumericWithin` → `CandidateSpec::NumericBucket` (scored, variable top-k)
4161    /// - `KeyMatch` → `CandidateSpec::ByKey` (FK probe, at most 1 dst per src)
4162    /// - `VectorSimilar` / `approximate=false` → `CandidateSpec::ScanAll` (scored)
4163    #[test]
4164    fn streaming_topk_order_identity_property_test() {
4165        // Reference: build index, compute_desired per src, then brute-force
4166        // sort (score DESC, dst-key ASC, take k) — independent of filter_src_top_k.
4167        fn reference_topk(rule: &RuleDef, k: u64, fx: &mut Fx) -> BTreeSet<(u32, u32)> {
4168            let mut idx = RuleIndex::default();
4169            for id in 0..fx.ids.len() as u32 {
4170                let label_sym = match fx.labels.get(id as usize).copied() {
4171                    Some(s) if s != u32::MAX => s,
4172                    _ => continue,
4173                };
4174                index_node_for_rule(
4175                    id,
4176                    label_sym,
4177                    rule,
4178                    &mut idx,
4179                    &fx.syms,
4180                    ColumnsView::owned(&fx.props),
4181                );
4182            }
4183            let src_sym = fx.syms.get(&rule.src_label);
4184            let mut out = BTreeSet::new();
4185            let ids_snap: Vec<u32> = (0..fx.ids.len() as u32).collect();
4186            for id in ids_snap {
4187                let label_sym = match fx.labels.get(id as usize).copied() {
4188                    Some(s) if s != u32::MAX => s,
4189                    _ => continue,
4190                };
4191                if src_sym != Some(label_sym) {
4192                    continue;
4193                }
4194                let g = GraphMut {
4195                    ids: &fx.ids,
4196                    syms: &mut fx.syms,
4197                    labels: &fx.labels,
4198                    props: ColumnsView::owned(&fx.props),
4199                    topo: &mut fx.topo,
4200                    edge_props: &mut fx.eprops,
4201                };
4202                let per_src = compute_desired(rule, &idx, id, true, &g);
4203                // Independent brute-force sort: score DESC, dst-key ASC, take k.
4204                let mut candidates: Vec<((u32, u32), f64)> = per_src.into_iter().collect();
4205                candidates.sort_by(|&((_, da), sa), &((_, db), sb)| {
4206                    sb.total_cmp(&sa).then_with(|| {
4207                        let ka = fx.ids.key_of(da).unwrap_or("");
4208                        let kb = fx.ids.key_of(db).unwrap_or("");
4209                        ka.cmp(kb)
4210                    })
4211                });
4212                candidates.truncate(k as usize);
4213                out.extend(candidates.into_iter().map(|(k, _)| k));
4214            }
4215            out
4216        }
4217
4218        // Helper: run create_rule and return provenance (src,dst) pairs.
4219        fn streaming_pairs(rule: RuleDef, fx: &mut Fx) -> BTreeSet<(u32, u32)> {
4220            let name = rule.name.clone();
4221            let mut eng = RuleEngine::new();
4222            eng.create_rule(rule, &mut fx.g()).unwrap();
4223            eng.provenance()
4224                .get(&name)
4225                .map(|s| s.iter().map(|&(_, a, b)| (a, b)).collect())
4226                .unwrap_or_default()
4227        }
4228
4229        // ----------------------------------------------------------------
4230        // Case 1: FieldEqual (uniform score=1.0, tiebreak by key ASC)
4231        // N→N, 3-value "k" field; top-k filters per src by key.
4232        // ----------------------------------------------------------------
4233        for seed in [0u64, 1, 42, 0xDEAD_BEEF, 0x1234_5678, 99, 12_648_430, 7] {
4234            for k in [1u64, 2, 3, 5] {
4235                let rule = RuleDef {
4236                    name: "eq".into(),
4237                    src_label: "N".into(),
4238                    dst_label: "N".into(),
4239                    predicate: Predicate::FieldEqual { field: "k".into() },
4240                    edge_type: "EQ".into(),
4241                    weight_prop: None,
4242                    max_edges: Some(k),
4243                    approximate: false,
4244                    via_label: None,
4245                    via_edge: None,
4246                    via_dir: None,
4247                };
4248
4249                let build = || {
4250                    let mut fx = Fx::new();
4251                    for i in 0..12u32 {
4252                        let h = mix64(seed ^ (i as u64 + 1));
4253                        let val = match h % 3 {
4254                            0 => "a",
4255                            1 => "b",
4256                            _ => "c",
4257                        };
4258                        fx.add(
4259                            "N",
4260                            &format!("n{i:02}"),
4261                            vec![("k", Value::Str(val.into()))],
4262                        );
4263                    }
4264                    fx
4265                };
4266
4267                let expected = reference_topk(&rule, k, &mut build());
4268                let actual = streaming_pairs(rule, &mut build());
4269
4270                assert_eq!(
4271                    expected, actual,
4272                    "FieldEqual seed={seed} k={k}: streaming top-k must match brute-force top-k"
4273                );
4274            }
4275        }
4276
4277        // ----------------------------------------------------------------
4278        // Case 2: NumericWithin (scored — top-k filters by score DESC, key ASC)
4279        // S→D, numeric field "v", tolerance 10.0.
4280        // ----------------------------------------------------------------
4281        for seed in [0u64, 1, 42, 7] {
4282            for k in [1u64, 2, 4] {
4283                let rule = RuleDef {
4284                    name: "nw".into(),
4285                    src_label: "S".into(),
4286                    dst_label: "D".into(),
4287                    predicate: Predicate::NumericWithin {
4288                        field: "v".into(),
4289                        tolerance: 10.0,
4290                    },
4291                    edge_type: "NEAR".into(),
4292                    weight_prop: Some("score".into()),
4293                    max_edges: Some(k),
4294                    approximate: false,
4295                    via_label: None,
4296                    via_edge: None,
4297                    via_dir: None,
4298                };
4299
4300                let build = || {
4301                    let mut fx = Fx::new();
4302                    for i in 0..6u32 {
4303                        let h = mix64(seed ^ (i as u64 + 1));
4304                        let v = (h % 20) as f64;
4305                        fx.add("S", &format!("s{i}"), vec![("v", Value::Float(v))]);
4306                    }
4307                    for i in 0..8u32 {
4308                        let h = mix64(seed ^ (i as u64 + 101));
4309                        let v = (h % 20) as f64;
4310                        fx.add("D", &format!("d{i}"), vec![("v", Value::Float(v))]);
4311                    }
4312                    fx
4313                };
4314
4315                let expected = reference_topk(&rule, k, &mut build());
4316                let actual = streaming_pairs(rule, &mut build());
4317
4318                assert_eq!(
4319                    expected, actual,
4320                    "NumericWithin seed={seed} k={k}: streaming top-k must match brute-force top-k"
4321                );
4322            }
4323        }
4324
4325        // ----------------------------------------------------------------
4326        // Case 3: KeyMatch (CandidateSpec::ByKey)
4327        // T→C FK rule: each T has a "cid" field whose value is the key of
4328        // a C node.  Each src has at most 1 candidate, so filter_src_top_k
4329        // is the identity — but the ByKey candidate path must be exercised.
4330        // ----------------------------------------------------------------
4331        for seed in [0u64, 1, 42, 7] {
4332            for k in [1u64, 2] {
4333                let rule = RuleDef {
4334                    name: "fk".into(),
4335                    src_label: "T".into(),
4336                    dst_label: "C".into(),
4337                    predicate: Predicate::KeyMatch {
4338                        field: "cid".into(),
4339                    },
4340                    edge_type: "AT".into(),
4341                    weight_prop: None,
4342                    max_edges: Some(k),
4343                    approximate: false,
4344                    via_label: None,
4345                    via_edge: None,
4346                    via_dir: None,
4347                };
4348
4349                let build = || {
4350                    let mut fx = Fx::new();
4351                    // 4 C nodes.
4352                    for i in 0..4u32 {
4353                        fx.add("C", &format!("c{i}"), vec![]);
4354                    }
4355                    // 8 T nodes, each pointing at a C node determined by hash.
4356                    for i in 0..8u32 {
4357                        let h = mix64(seed ^ (i as u64 + 1));
4358                        let cid = format!("c{}", h % 4);
4359                        fx.add("T", &format!("t{i}"), vec![("cid", Value::Str(cid))]);
4360                    }
4361                    fx
4362                };
4363
4364                let expected = reference_topk(&rule, k, &mut build());
4365                let actual = streaming_pairs(rule, &mut build());
4366
4367                assert_eq!(
4368                    expected, actual,
4369                    "KeyMatch seed={seed} k={k}: streaming top-k must match brute-force top-k"
4370                );
4371            }
4372        }
4373
4374        // ----------------------------------------------------------------
4375        // Case 4: VectorSimilar approximate=false (CandidateSpec::ScanAll)
4376        // V→V cosine-sim rule.  6 nodes in 2 clusters of 3; min=0.9 so only
4377        // within-cluster pairs qualify.  top-k=2 filters the 2 best in cluster.
4378        // ----------------------------------------------------------------
4379        {
4380            // cluster A: unit vectors near [1,0]; cluster B: near [0,1].
4381            let cluster_a: &[(&str, f64, f64)] = &[
4382                ("va0", 1.0_f64, 0.0_f64),
4383                ("va1", 0.98_f64, 0.199_f64), // cos(~11.5°) ≈ 0.98
4384                ("va2", 0.97_f64, 0.243_f64), // cos(~14°) ≈ 0.97
4385            ];
4386            let cluster_b: &[(&str, f64, f64)] = &[
4387                ("vb0", 0.0_f64, 1.0_f64),
4388                ("vb1", 0.1_f64, 0.995_f64),
4389                ("vb2", 0.05_f64, 0.999_f64),
4390            ];
4391            for k in [1u64, 2] {
4392                let rule = RuleDef {
4393                    name: "vsim".into(),
4394                    src_label: "V".into(),
4395                    dst_label: "V".into(),
4396                    predicate: Predicate::VectorSimilar {
4397                        field: "emb".into(),
4398                        min: 0.9,
4399                    },
4400                    edge_type: "VSIM".into(),
4401                    weight_prop: Some("score".into()),
4402                    max_edges: Some(k),
4403                    approximate: false,
4404                    via_label: None,
4405                    via_edge: None,
4406                    via_dir: None,
4407                };
4408
4409                let build = || {
4410                    let mut fx = Fx::new();
4411                    let mut add_v = |key: &str, x: f64, y: f64| {
4412                        let norm = (x * x + y * y).sqrt();
4413                        let v = Value::List(vec![Value::Float(x / norm), Value::Float(y / norm)]);
4414                        fx.add("V", key, vec![("emb", v)]);
4415                    };
4416                    for &(k, x, y) in cluster_a.iter().chain(cluster_b.iter()) {
4417                        add_v(k, x, y);
4418                    }
4419                    fx
4420                };
4421
4422                let expected = reference_topk(&rule, k, &mut build());
4423                let actual = streaming_pairs(rule, &mut build());
4424
4425                assert_eq!(
4426                    expected, actual,
4427                    "VectorSimilar/ScanAll k={k}: streaming top-k must match brute-force top-k"
4428                );
4429            }
4430        }
4431    }
4432
4433    /// Streaming peak-transient allocation bound.
4434    ///
4435    /// Measures the PEAK process RSS *during* `create_rule` by polling from a
4436    /// background sampler thread at ~1 ms intervals.  Unlike a before/after
4437    /// snapshot this captures transient allocations freed before the call
4438    /// returns.
4439    ///
4440    /// **Why the OLD code would fail this test:**
4441    /// The old `compute_full_desired` built a global `BTreeMap<(u32,u32),f64>`
4442    /// for ALL 250 000 desired pairs (500 Talent × 500 Company, same field
4443    /// value, FieldEqual) before applying the cap.  At ~26 bytes per BTree
4444    /// entry (amortised node overhead on aarch64) that is ≈6.5 MiB transient
4445    /// — held for the entire duration of `apply_desired`.  The peak sampler
4446    /// would observe this spike; the 3 MiB threshold would be exceeded.
4447    ///
4448    /// **Why the NEW code passes:**
4449    /// `apply_streaming_create` caps after ~1 000 evaluations (one pass over
4450    /// the first few src nodes).  The largest in-flight allocation is one
4451    /// per-src `BTreeMap` of ≤ 500 entries ≈ 13 KiB — never materialising
4452    /// the full 250 000-pair map.  Peak transient delta is sub-100 KiB.
4453    ///
4454    /// Threshold 3 MiB: old ≈ 6.5 MiB (FAILS); new ≈ 13 KiB (PASSES).
4455    ///
4456    /// Marked `#[ignore]` (forks `ps`, environment-dependent).
4457    /// Run: `cargo test -p core-rules streaming_peak_transient_bound -- --ignored --test-threads=1`
4458    #[test]
4459    #[ignore]
4460    fn streaming_peak_transient_bound() {
4461        use std::sync::{
4462            atomic::{AtomicBool, AtomicU64, Ordering},
4463            Arc,
4464        };
4465
4466        // Sample process RSS every ~1 ms from a background thread.
4467        // Returns the peak RSS observed while `f` executes.
4468        fn peak_rss_during<F: FnOnce()>(f: F) -> u64 {
4469            let done = Arc::new(AtomicBool::new(false));
4470            let peak = Arc::new(AtomicU64::new(0));
4471            let done2 = done.clone();
4472            let peak2 = peak.clone();
4473            let pid = std::process::id().to_string();
4474
4475            let handle = std::thread::spawn(move || {
4476                while !done2.load(Ordering::Relaxed) {
4477                    let rss = std::process::Command::new("ps")
4478                        .args(["-o", "rss=", "-p", &pid])
4479                        .output()
4480                        .ok()
4481                        .and_then(|o| String::from_utf8(o.stdout).ok())
4482                        .and_then(|s| s.trim().parse::<u64>().ok())
4483                        .unwrap_or(0)
4484                        * 1024;
4485                    peak2.fetch_max(rss, Ordering::Relaxed);
4486                    std::thread::sleep(std::time::Duration::from_millis(1));
4487                }
4488            });
4489
4490            f();
4491
4492            done.store(true, Ordering::Relaxed);
4493            let _ = handle.join();
4494            peak.load(Ordering::Relaxed)
4495        }
4496
4497        // 500 Talent × 500 Company, all FieldEqual on k="same"
4498        // → 250 000 desired pairs, top-k = 2 per source (max_edges: Some(2)).
4499        // Peak transient: one per-src BTreeMap of ≤ 500 entries ≈ 13 KiB.
4500        let mut fx = Fx::new();
4501        for i in 0..500u32 {
4502            fx.add(
4503                "Talent",
4504                &format!("t{i}"),
4505                vec![("k", Value::Str("same".into()))],
4506            );
4507        }
4508        for i in 0..500u32 {
4509            fx.add(
4510                "Company",
4511                &format!("c{i}"),
4512                vec![("k", Value::Str("same".into()))],
4513            );
4514        }
4515        let rule = RuleDef {
4516            name: "eq_tc".into(),
4517            src_label: "Talent".into(),
4518            dst_label: "Company".into(),
4519            predicate: Predicate::FieldEqual { field: "k".into() },
4520            edge_type: "EQ".into(),
4521            weight_prop: None,
4522            max_edges: Some(2), // top-k=2 per source; 500 * 2 = 1000 total edges
4523            approximate: false,
4524            via_label: None,
4525            via_edge: None,
4526            via_dir: None,
4527        };
4528
4529        // Baseline: RSS before any create_rule allocation.
4530        let pid = std::process::id().to_string();
4531        let baseline = std::process::Command::new("ps")
4532            .args(["-o", "rss=", "-p", &pid])
4533            .output()
4534            .ok()
4535            .and_then(|o| String::from_utf8(o.stdout).ok())
4536            .and_then(|s| s.trim().parse::<u64>().ok())
4537            .unwrap_or(0)
4538            * 1024;
4539
4540        let mut eng = RuleEngine::new();
4541        let peak = peak_rss_during(|| {
4542            eng.create_rule(rule, &mut fx.g()).unwrap();
4543        });
4544
4545        let peak_delta = peak.saturating_sub(baseline);
4546
4547        // Threshold 3 MiB.  Old O(pairs) path: 250k entries × ~26 bytes ≈ 6.5 MiB
4548        // transient; would exceed threshold.  New streaming path: single per-src
4549        // BTreeMap ≤ 500 entries ≈ 13 KiB; never approaches threshold.
4550        assert!(
4551            peak_delta < 3 * 1024 * 1024,
4552            "peak transient delta {} bytes ({} KiB) exceeded 3 MiB; \
4553             streaming path may be building the full pairs map",
4554            peak_delta,
4555            peak_delta / 1024
4556        );
4557        assert_eq!(eng.provenance()["eq_tc"].len(), 1_000); // 500 Talent × top-k 2 = 1000
4558        assert!(!eng.is_tripped("eq_tc")); // top-k rules never trip
4559        eprintln!(
4560            "streaming_peak_transient_bound: baseline={baseline} peak={peak} \
4561             delta={peak_delta} bytes ({} KiB)",
4562            peak_delta / 1024
4563        );
4564    }
4565
4566    // -----------------------------------------------------------------------
4567    // Task 3 (Plan 11): Checkpointed Cauchy-Schwarz suffix-norm early exit
4568    // -----------------------------------------------------------------------
4569
4570    /// Helper: a near-threshold vector pair. Returns (a, b) where cos(a,b) is
4571    /// just above the provided threshold (so the pair SHOULD match).
4572    fn near_threshold_pair(dim: usize, min: f64) -> (Vec<f64>, Vec<f64>) {
4573        // Construct b = cos_target * a + epsilon * perp, then normalise both.
4574        // For simplicity: a = [1, 0, ..., 0], b = [cos_target, sin_small, 0, ...]
4575        let cos_target = min + 1e-6; // just above min
4576        let sin_small = (1.0 - cos_target * cos_target).sqrt();
4577        let mut a = vec![0.0f64; dim];
4578        a[0] = 1.0;
4579        let mut b = vec![0.0f64; dim];
4580        b[0] = cos_target;
4581        if dim > 1 {
4582            b[1] = sin_small;
4583        }
4584        (a, b)
4585    }
4586
4587    fn emb_val2(xs: &[f64]) -> Value {
4588        Value::List(xs.iter().copied().map(Value::Float).collect())
4589    }
4590
4591    /// Build an identical test fixture twice so ON/OFF/oracle comparisons all
4592    /// operate on the same graph topology.  Uses dims [2,4,8,16] with a
4593    /// near-threshold pair at dim=8 to exercise the checkpoint boundaries.
4594    fn make_early_exit_fixture(seed: u64, min: f64) -> (Fx, Vec<u32>, usize, usize) {
4595        let dims = [2usize, 4, 8, 16];
4596        let n = 100u32;
4597        let mut fx = Fx::new();
4598        let mut ids = Vec::new();
4599        for i in 0..n {
4600            let dim = dims[(i as usize) % dims.len()];
4601            let emb = rand_emb(seed, i, dim);
4602            ids.push(fx.add("Doc", &format!("d{i}"), vec![("emb", emb)]));
4603        }
4604        // Near-threshold pair at dim=8, cos just above min → must match.
4605        let (va, vb) = near_threshold_pair(8, min);
4606        let nt_a = fx.add("Doc", "nt_a", vec![("emb", emb_val2(&va))]);
4607        let nt_b = fx.add("Doc", "nt_b", vec![("emb", emb_val2(&vb))]);
4608        ids.push(nt_a);
4609        ids.push(nt_b);
4610        (fx, ids, nt_a as usize, nt_b as usize)
4611    }
4612
4613    /// Identity proof: derived edges are identical with early-exit ON, OFF,
4614    /// and vs the brute-force oracle.  Tests mixed dims (2, 4, 8, 16) with
4615    /// near-threshold cosines (cos ≈ min ± epsilon) to exercise exact rejects.
4616    #[test]
4617    fn vector_early_exit_identity_proof() {
4618        const SEED: u64 = 0xEA_4E_5A;
4619        const MIN: f64 = 0.85;
4620
4621        let def = RuleDef {
4622            name: "vec".into(),
4623            src_label: "Doc".into(),
4624            dst_label: "Doc".into(),
4625            predicate: Predicate::VectorSimilar {
4626                field: "emb".into(),
4627                min: MIN,
4628            },
4629            edge_type: "SIM".into(),
4630            weight_prop: Some("score".into()),
4631            max_edges: None,
4632            approximate: false,
4633            via_label: None,
4634            via_edge: None,
4635            via_dir: None,
4636        };
4637
4638        // Build three identical fixtures (independent topo state, same data).
4639        let (mut fx_on, ids, nt_a, nt_b) = make_early_exit_fixture(SEED, MIN);
4640        let (mut fx_off, _, _, _) = make_early_exit_fixture(SEED, MIN);
4641        let (fx_oracle, _, _, _) = make_early_exit_fixture(SEED, MIN);
4642
4643        let nt_a = nt_a as u32;
4644        let nt_b = nt_b as u32;
4645
4646        // Run with early-exit ON (default).
4647        let mut eng_on = RuleEngine::new();
4648        {
4649            let mut g = fx_on.g();
4650            eng_on.create_rule(def.clone(), &mut g).unwrap();
4651        }
4652        let edges_on = prov_pairs(&eng_on, "vec");
4653        assert!(!edges_on.is_empty(), "should produce some edges");
4654
4655        // Near-threshold pair must appear with early-exit ON.
4656        assert!(
4657            edges_on.contains(&(nt_a, nt_b)),
4658            "near-threshold pair nt_a→nt_b must match with early-exit ON"
4659        );
4660        assert!(
4661            edges_on.contains(&(nt_b, nt_a)),
4662            "near-threshold pair nt_b→nt_a must match with early-exit ON"
4663        );
4664
4665        // Run with early-exit OFF; must produce identical edge set.
4666        let mut eng_off = RuleEngine::new();
4667        {
4668            let mut g = fx_off.g();
4669            with_vector_early_exit(false, || {
4670                eng_off.create_rule(def.clone(), &mut g).unwrap();
4671            });
4672        }
4673        let edges_off = prov_pairs(&eng_off, "vec");
4674        assert_eq!(
4675            edges_on, edges_off,
4676            "early-exit ON vs OFF must produce identical edges"
4677        );
4678
4679        // Brute-force oracle: evaluate() on all (s,d) pairs.
4680        let mut oracle = BTreeSet::new();
4681        for &s in &ids {
4682            for &d in &ids {
4683                if s == d {
4684                    continue;
4685                }
4686                let skey = fx_oracle.ids.key_of(s).unwrap();
4687                let dkey = fx_oracle.ids.key_of(d).unwrap();
4688                let sg = |f: &str| fx_oracle.props.get(s, f).cloned();
4689                let dg = |f: &str| fx_oracle.props.get(d, f).cloned();
4690                if evaluate(
4691                    &def.predicate,
4692                    &NodeView {
4693                        key: skey,
4694                        props: &sg,
4695                    },
4696                    &NodeView {
4697                        key: dkey,
4698                        props: &dg,
4699                    },
4700                )
4701                .is_some()
4702                {
4703                    oracle.insert((s, d));
4704                }
4705            }
4706        }
4707        assert_eq!(
4708            edges_on, oracle,
4709            "early-exit ON vs brute-force oracle must be identical"
4710        );
4711    }
4712
4713    /// Coherence: checkpoints are rebuilt through the insert/remove choke-points
4714    /// when a vector prop is updated.  Dim change, freshness gate exercised.
4715    #[test]
4716    fn vector_early_exit_checkpoint_coherence() {
4717        let mut fx = Fx::new();
4718        // Two dim=4 nodes that match under VectorSimilar min=0.9.
4719        let a = fx.add("Doc", "a", vec![("emb", emb_val(&[1.0, 0.0, 0.0, 0.0]))]);
4720        let b = fx.add("Doc", "b", vec![("emb", emb_val(&[1.0, 0.0, 0.0, 0.0]))]);
4721        // dim=6 node that should NOT match dim=4 nodes.
4722        let c = fx.add(
4723            "Doc",
4724            "c",
4725            vec![("emb", emb_val(&[1.0, 0.0, 0.0, 0.0, 0.0, 0.0]))],
4726        );
4727        let def = RuleDef {
4728            name: "vec".into(),
4729            src_label: "Doc".into(),
4730            dst_label: "Doc".into(),
4731            predicate: Predicate::VectorSimilar {
4732                field: "emb".into(),
4733                min: 0.9,
4734            },
4735            edge_type: "SIM".into(),
4736            weight_prop: None,
4737            max_edges: None,
4738            approximate: false,
4739            via_label: None,
4740            via_edge: None,
4741            via_dir: None,
4742        };
4743
4744        let mut eng = RuleEngine::new();
4745        {
4746            let mut g = fx.g();
4747            eng.create_rule(def.clone(), &mut g).unwrap();
4748        }
4749
4750        // Checkpoints must be populated for all three nodes.
4751        assert!(
4752            eng.indexes["vec"].src_side.vec_ckpts(a).is_some(),
4753            "a must have src checkpoints"
4754        );
4755        assert!(
4756            eng.indexes["vec"].dst_side.vec_ckpts(b).is_some(),
4757            "b must have dst checkpoints"
4758        );
4759        assert!(
4760            eng.indexes["vec"].src_side.vec_ckpts(c).is_some(),
4761            "c must have src checkpoints (dim=6)"
4762        );
4763
4764        // ckpts[0] must equal the full L2 norm.
4765        let ckpts_a = *eng.indexes["vec"].src_side.vec_ckpts(a).unwrap();
4766        let norm_a = eng.indexes["vec"].src_side.vec_meta(a).unwrap().1;
4767        assert!(
4768            (ckpts_a[0] - norm_a).abs() < 1e-12,
4769            "ckpts[0] must equal the full L2 norm"
4770        );
4771
4772        // Initial edges: a↔b only (c is different dim).
4773        assert_eq!(prov_pairs(&eng, "vec"), BTreeSet::from([(a, b), (b, a)]));
4774
4775        // Update b to dim=6 (same as c) — choke-points must rebuild checkpoints.
4776        let old_b = fx.props.get(b, "emb").cloned();
4777        fx.props
4778            .set(b, "emb", emb_val(&[1.0, 0.0, 0.0, 0.0, 0.0, 0.0]));
4779        {
4780            let mut g = fx.g();
4781            eng.on_node_changed(b, Some(("emb", old_b)), &mut g);
4782        }
4783        // b's dim must now be 6 in both sides.
4784        assert_eq!(eng.indexes["vec"].src_side.vec_dim(b), Some(6));
4785        assert_eq!(eng.indexes["vec"].dst_side.vec_dim(b), Some(6));
4786        // b must have new checkpoints for dim=6.
4787        assert!(eng.indexes["vec"].src_side.vec_ckpts(b).is_some());
4788        // Edges must now be b↔c (both dim=6, cos=1.0 > 0.9).
4789        assert_eq!(prov_pairs(&eng, "vec"), BTreeSet::from([(b, c), (c, b)]));
4790
4791        // Freshness gate: fresh_ckpts_for returns None when live vector differs.
4792        // Simulate by passing a different live vector to fresh_ckpts_for.
4793        let wrong_live = vec![2.0f64, 0.0, 0.0, 0.0, 0.0, 0.0]; // same dim, different norm
4794        let gate_result = eng.indexes["vec"].src_side.fresh_ckpts_for(b, &wrong_live);
4795        assert!(
4796            gate_result.is_none(),
4797            "freshness gate must reject a mismatched-norm live vector"
4798        );
4799
4800        // fresh_ckpts_for must succeed with the correct live vector.
4801        let correct_live = vec![1.0f64, 0.0, 0.0, 0.0, 0.0, 0.0];
4802        let gate_result = eng.indexes["vec"]
4803            .src_side
4804            .fresh_ckpts_for(b, &correct_live);
4805        assert!(
4806            gate_result.is_some(),
4807            "freshness gate must accept the matching live vector"
4808        );
4809    }
4810
4811    /// Razor test: dim=1536 pair with true cosine within 1e-12 of `min`.
4812    ///
4813    /// Purpose: with energy spread uniformly across all 1536 elements, each
4814    /// checkpoint boundary contributes a tiny slice of dot product.  Float
4815    /// rounding of suffix-norm accumulation can shift `cos_max` by O(dim × ε)
4816    /// ≈ 3.4 × 10⁻¹³ at dim=1536, inside the 1e-12 margin tested here.  The
4817    /// epsilon guard in `cosine_early_exit` absorbs this; ON/OFF/oracle must
4818    /// agree on all edges.
4819    #[test]
4820    fn vector_early_exit_razor_dim1536() {
4821        const MIN: f64 = 0.85;
4822        const DIM: usize = 1536;
4823        // target cosine = min + 5e-13: inside the dim-scale float-error zone.
4824        let target = MIN + 5e-13;
4825        let inv_sqrt = 1.0 / (DIM as f64).sqrt();
4826
4827        // a: unit-norm uniform vector — energy spread equally across all chunks.
4828        let a: Vec<f64> = vec![inv_sqrt; DIM];
4829
4830        // b = target * a + sqrt(1 - target^2) * e_perp
4831        // e_perp = [1, -1, 0, ..., 0] / sqrt(2) is perpendicular to uniform a:
4832        //   dot(a, e_perp) = inv_sqrt * (1 - 1) / sqrt(2) = 0  ✓
4833        // norm(b) = sqrt(target^2 + (1-target^2)) = 1            ✓
4834        // cos(a, b) = dot(a, b) = target * dot(a, a) = target    ✓
4835        let perp_scale = (1.0 - target * target).sqrt() / (2.0f64).sqrt();
4836        let mut b: Vec<f64> = vec![target * inv_sqrt; DIM];
4837        b[0] += perp_scale;
4838        b[1] -= perp_scale;
4839
4840        let def = RuleDef {
4841            name: "razor".into(),
4842            src_label: "Doc".into(),
4843            dst_label: "Doc".into(),
4844            predicate: Predicate::VectorSimilar {
4845                field: "emb".into(),
4846                min: MIN,
4847            },
4848            edge_type: "SIM".into(),
4849            weight_prop: None,
4850            max_edges: None,
4851            approximate: false,
4852            via_label: None,
4853            via_edge: None,
4854            via_dir: None,
4855        };
4856
4857        // Three independent fixtures with the same razor pair.
4858        let build_fx = || {
4859            let mut fx = Fx::new();
4860            let na = fx.add("Doc", "razor_a", vec![("emb", emb_val2(&a))]);
4861            let nb = fx.add("Doc", "razor_b", vec![("emb", emb_val2(&b))]);
4862            (fx, na, nb)
4863        };
4864
4865        let (mut fx_on, na, nb) = build_fx();
4866        let (mut fx_off, _, _) = build_fx();
4867        let (fx_oracle, _, _) = build_fx();
4868
4869        // ON
4870        let mut eng_on = RuleEngine::new();
4871        {
4872            let mut g = fx_on.g();
4873            eng_on.create_rule(def.clone(), &mut g).unwrap();
4874        }
4875        let edges_on = prov_pairs(&eng_on, "razor");
4876        assert!(
4877            edges_on.contains(&(na, nb)),
4878            "razor pair razor_a→razor_b must be present with early-exit ON (cos={target:.15}, min={MIN})"
4879        );
4880        assert!(
4881            edges_on.contains(&(nb, na)),
4882            "razor pair razor_b→razor_a must be present with early-exit ON"
4883        );
4884
4885        // OFF
4886        let mut eng_off = RuleEngine::new();
4887        {
4888            let mut g = fx_off.g();
4889            with_vector_early_exit(false, || {
4890                eng_off.create_rule(def.clone(), &mut g).unwrap();
4891            });
4892        }
4893        let edges_off = prov_pairs(&eng_off, "razor");
4894        assert_eq!(
4895            edges_on, edges_off,
4896            "razor dim=1536: early-exit ON vs OFF must produce identical edges"
4897        );
4898
4899        // Brute-force oracle.
4900        let ids = [na, nb];
4901        let mut oracle = BTreeSet::new();
4902        for &s in &ids {
4903            for &d in &ids {
4904                if s == d {
4905                    continue;
4906                }
4907                let skey = fx_oracle.ids.key_of(s).unwrap();
4908                let dkey = fx_oracle.ids.key_of(d).unwrap();
4909                let sg = |f: &str| fx_oracle.props.get(s, f).cloned();
4910                let dg = |f: &str| fx_oracle.props.get(d, f).cloned();
4911                if evaluate(
4912                    &def.predicate,
4913                    &NodeView {
4914                        key: skey,
4915                        props: &sg,
4916                    },
4917                    &NodeView {
4918                        key: dkey,
4919                        props: &dg,
4920                    },
4921                )
4922                .is_some()
4923                {
4924                    oracle.insert((s, d));
4925                }
4926            }
4927        }
4928        assert_eq!(
4929            edges_on, oracle,
4930            "razor dim=1536: early-exit ON vs brute-force oracle must be identical"
4931        );
4932    }
4933
4934    // -----------------------------------------------------------------------
4935    // Scale test: backfill must not materialise the full cross-product
4936    // -----------------------------------------------------------------------
4937    //
4938    // Step-1 analysis (flow read per brief):
4939    //
4940    // compute_desired (~246): returns a BTreeMap<(u32,u32),f64> for ONE source
4941    //   node against all matching candidates from the dst-side index.  For a
4942    //   FieldEqual rule with 400 Org dsts all sharing city="austin", each call
4943    //   returns at most 400 pairs.  The per-source map is dropped after
4944    //   filter_src_top_k consumes it.
4945    //
4946    // compute_desired_via (~452): similar per-anchor scope; not exercised here.
4947    //
4948    // compute_full_desired (~945): TEST-ONLY reference implementation.  Iterates
4949    //   every src node and calls compute_desired, extending a GLOBAL BTreeMap.
4950    //   For 400 Person × 400 Org this accumulates 160 000 pairs — the full
4951    //   cross-product — before returning.  This is the memory wall the streaming
4952    //   rewrite was designed to eliminate.
4953    //
4954    // apply_streaming_create_top_k (~1106): the production path for
4955    //   max_edges=Some(k).  Calls compute_desired per src (≤400 pairs), passes
4956    //   ownership to filter_src_top_k (truncates to k=5), then drops the map.
4957    //   The largest map alive at any instant is one per-src BTreeMap of ≤400
4958    //   entries — never the 160 000-pair global map.
4959    //
4960    // filter_src_top_k (~626): runs BEFORE compute_full_desired is ever called
4961    //   (compute_full_desired is dead code in the production path).  It truncates
4962    //   the per-source map to k entries BEFORE apply_per_src_top_k sees it.
4963    //   Conclusion: filter_src_top_k IS applied per-source before any global map
4964    //   extension; compute_full_desired does NOT participate in create_rule.
4965    //
4966    // Expected test behaviour:
4967    //   The production path (apply_streaming_create_top_k) yields peak ≈ 400
4968    //   (one per-src BTreeMap).  The assertion bound is 400*5*4 = 8 000 — well
4969    //   below the 160 000 cross-product.  The test therefore PASSES with the
4970    //   current streaming code, confirming the fix is in place.
4971    //
4972    //   If someone reverts the streaming path and re-introduces a global
4973    //   compute_full_desired call inside create_rule, peak would reach 160 000
4974    //   and the assertion would FAIL — which is the regression this test guards.
4975
4976    /// Helper: FieldEqual rule between two distinct labels with top-k cap.
4977    fn field_equal_rule(
4978        src_label: &str,
4979        dst_label: &str,
4980        field: &str,
4981        edge_type: &str,
4982        max_edges: Option<u64>,
4983    ) -> RuleDef {
4984        RuleDef {
4985            name: format!("{src_label}_{dst_label}_{field}"),
4986            src_label: src_label.into(),
4987            dst_label: dst_label.into(),
4988            predicate: Predicate::FieldEqual {
4989                field: field.into(),
4990            },
4991            edge_type: edge_type.into(),
4992            weight_prop: None,
4993            max_edges,
4994            approximate: false,
4995            via_label: None,
4996            via_edge: None,
4997            via_dir: None,
4998        }
4999    }
5000
5001    #[test]
5002    fn backfill_does_not_materialize_the_cross_product() {
5003        use std::sync::atomic::Ordering;
5004        // 400 Person + 400 Org, all city="austin"; rule FieldEqual{city},
5005        // max_edges=Some(5).  Correct behaviour: 400 × 5 = 2000 derived edges,
5006        // and peak simultaneous pairs ≤ 400*5*4 (generous headroom), NOT the
5007        // 160 000 cross-product.
5008        let mut fx = Fx::new();
5009        for i in 0..400u32 {
5010            fx.add(
5011                "Person",
5012                &format!("p{i}"),
5013                vec![("city", Value::Str("austin".into()))],
5014            );
5015        }
5016        for i in 0..400u32 {
5017            fx.add(
5018                "Org",
5019                &format!("o{i}"),
5020                vec![("city", Value::Str("austin".into()))],
5021            );
5022        }
5023
5024        let mut eng = RuleEngine::new();
5025        PEAK_DESIRED_PAIRS.store(0, Ordering::Relaxed);
5026        {
5027            let mut g = fx.g();
5028            eng.create_rule(
5029                field_equal_rule("Person", "Org", "city", "IN_CITY", Some(5)),
5030                &mut g,
5031            )
5032            .unwrap();
5033        }
5034
5035        let edges = fx.topo.edge_count();
5036        assert_eq!(
5037            edges,
5038            400 * 5,
5039            "per-source top-k must yield exactly k per source"
5040        );
5041
5042        let peak = PEAK_DESIRED_PAIRS.load(Ordering::Relaxed);
5043        assert!(
5044            peak <= 400 * 5 * 4, // generous headroom; NOT the 160_000 cross product
5045            "backfill must not materialize the full cross-product; peak was {peak}"
5046        );
5047    }
5048
5049    /// Regression guard for the `max_edges = None` (global-budget) backfill path.
5050    ///
5051    /// With `max_edges = None`, `create_rule` routes to `apply_streaming_create`
5052    /// (engine.rs:~1075), which calls `compute_desired` once per source node and
5053    /// applies edges immediately under a `prov.len() >= budget` latch
5054    /// (budget = DEFAULT_MAX_EDGES = 1_000_000).  For 400 Person × 400 Org nodes
5055    /// the cross-product is 160_000 — well below the budget — so ALL pairs are
5056    /// applied.  Crucially, no global desired-map is ever materialised: the
5057    /// per-source map is computed, iterated, and dropped before the next source
5058    /// is processed.
5059    ///
5060    /// The budget latch itself (edges capped at DEFAULT_MAX_EDGES when the
5061    /// cross-product exceeds it) is covered by the existing `#[ignore]`d
5062    /// `streaming_peak_transient_bound` test (~line 4436); this test guards
5063    /// peak desired-pair memory only.
5064    #[test]
5065    fn global_budget_backfill_stays_per_source_bounded() {
5066        use std::sync::atomic::Ordering;
5067        // Same 400 Person × 400 Org shared-value fixture as the Task 1 test,
5068        // but rule has max_edges = None (global-budget path).
5069        let mut fx = Fx::new();
5070        for i in 0..400u32 {
5071            fx.add(
5072                "Person",
5073                &format!("p{i}"),
5074                vec![("city", Value::Str("austin".into()))],
5075            );
5076        }
5077        for i in 0..400u32 {
5078            fx.add(
5079                "Org",
5080                &format!("o{i}"),
5081                vec![("city", Value::Str("austin".into()))],
5082            );
5083        }
5084
5085        let mut eng = RuleEngine::new();
5086        PEAK_DESIRED_PAIRS.store(0, Ordering::Relaxed);
5087        {
5088            let mut g = fx.g();
5089            eng.create_rule(
5090                field_equal_rule("Person", "Org", "city", "IN_CITY", None),
5091                &mut g,
5092            )
5093            .unwrap();
5094        }
5095
5096        // All 160_000 pairs are below DEFAULT_MAX_EDGES (1_000_000), so every
5097        // pair is applied — edge count equals the full cross-product.
5098        let edges = fx.topo.edge_count();
5099        assert_eq!(
5100            edges,
5101            400 * 400,
5102            "none-path must apply all pairs when under budget; got {edges}"
5103        );
5104
5105        // Peak simultaneous pairs must be bounded per-source (≤400 candidates),
5106        // NOT the full 160_000 cross-product.  Any reversion to global desired-map
5107        // accumulation would observe peak = 160_000 and trip this guard.
5108        let peak = PEAK_DESIRED_PAIRS.load(Ordering::Relaxed);
5109        assert!(
5110            peak <= 400 * 4, // one per-src map of ≤400 candidates, with headroom
5111            "none-path backfill must not accumulate a global desired-map; peak was {peak}"
5112        );
5113    }
5114}