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