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