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