Skip to main content

core_rules/
engine.rs

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