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