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