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