Skip to main content

core_rules/
engine.rs

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