Skip to main content

core_rules/
engine.rs

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