Skip to main content

core_rules/
engine.rs

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