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