Skip to main content

core_rules/
engine.rs

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