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