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