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