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