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