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