1use crate::def::{evaluate, is_keymatch_rooted, NodeView, Predicate, RuleDef};
2use crate::hnsw::HnswIndex;
3use crate::index::{
4 candidate_spec, candidate_spec_approx_with_k, ivf_drift_rebuild_threshold, CandidateSpec,
5 RuleIndex,
6};
7use core_storage::v8::encode::{decode_ivf_bytes, decode_provenance_bytes};
8use core_storage::v8::seam::ColumnsView;
9use core_storage::{EdgeProps, IdMap, Interner, Topology, Value};
10
11fn decode_ivf_bytes_to_export(bytes: &[u8]) -> BTreeMap<String, RuleIvfExport> {
14 decode_ivf_bytes(bytes)
15 .into_iter()
16 .map(|(name, ps)| {
17 (
18 name,
19 (
20 (ps.src.centroids, ps.src.clusters, ps.src.drift),
21 (ps.dst.centroids, ps.dst.clusters, ps.dst.drift),
22 ),
23 )
24 })
25 .collect()
26}
27use std::collections::{BTreeMap, BTreeSet};
28use std::sync::{Mutex, OnceLock};
29
30#[derive(Debug, Clone)]
37pub struct EngineEdgeDelta {
38 pub rule: String,
39 pub src_key: String,
41 pub dst_key: String,
43 pub edge_type: String,
45 pub etype_sym: u32,
47 pub src_id: u32,
49 pub dst_id: u32,
51 pub fired: bool,
53}
54
55#[cfg(test)]
56pub use crate::index::{with_ivf_drift_rebuild, with_vector_dim_reject, with_vector_early_exit};
57
58pub struct GraphMut<'a> {
60 pub ids: &'a IdMap,
61 pub syms: &'a mut Interner,
62 pub labels: &'a [u32],
63 pub props: ColumnsView<'a>,
64 pub topo: &'a mut Topology,
65 pub edge_props: &'a mut EdgeProps,
66}
67
68pub const DEFAULT_MAX_EDGES: u64 = 1_000_000;
70
71type Triple = (u32, u32, u32);
73type Touch = (u32, u32, u32, u32);
75
76pub type SideIvfExport = (Vec<Vec<f64>>, BTreeMap<u32, usize>, u64);
79pub type RuleIvfExport = (SideIvfExport, SideIvfExport);
81
82type HnswBlobMap = BTreeMap<String, (Vec<u8>, Vec<u8>)>;
84type LazyHnswMap = BTreeMap<String, (Option<HnswIndex>, Option<HnswIndex>)>;
86
87#[derive(Debug, Default)]
95struct LazyProvenance {
96 provenance: BTreeMap<String, BTreeSet<Triple>>,
97 by_node: BTreeMap<u32, BTreeSet<Touch>>,
98 intern_rule: Vec<String>,
99}
100
101#[derive(Debug, Default)]
102pub struct RuleEngine {
103 rules: BTreeMap<String, RuleDef>,
104 indexes: BTreeMap<String, RuleIndex>,
105 provenance: BTreeMap<String, BTreeSet<Triple>>,
106 owned: BTreeSet<Triple>,
107 by_node: BTreeMap<u32, BTreeSet<Touch>>,
110 rule_intern: BTreeMap<String, u32>,
115 intern_rule: Vec<String>,
116 tripped: BTreeMap<String, bool>,
117 fires: BTreeMap<String, u64>,
118 pending_deltas: Vec<EngineEdgeDelta>,
125 emit_deltas: bool,
138 rebuild_needed: BTreeSet<String>,
142 indexes_populated: bool,
148 retained_hnsw_blobs: Mutex<HnswBlobMap>,
157 retained_ivf_bytes: Mutex<Option<Vec<u8>>>,
165 retained_provenance_bytes: Mutex<Option<Vec<u8>>>,
174 lazy_provenance: OnceLock<LazyProvenance>,
182 lazy_hnsw: OnceLock<LazyHnswMap>,
189}
190
191fn candidate_spec_for(def: &RuleDef) -> CandidateSpec<'_> {
198 if def.approximate {
199 let k = def.max_edges.map(|me| me.max(64)).unwrap_or(64) as usize;
202 candidate_spec_approx_with_k(&def.predicate, k)
203 } else {
204 candidate_spec(&def.predicate)
205 }
206}
207
208fn src_lookup_spec_for(def: &RuleDef) -> CandidateSpec<'_> {
214 if is_keymatch_rooted(&def.predicate) {
215 let field =
216 keymatch_field(&def.predicate).expect("keymatch-rooted predicate has a KeyMatch field");
217 CandidateSpec::Scalar { field }
218 } else {
219 candidate_spec_for(def)
220 }
221}
222
223fn predicate_covers_field(p: &Predicate, field: &str) -> bool {
225 match p {
226 Predicate::VectorSimilar { field: f, .. } => f == field,
227 Predicate::All(parts) | Predicate::Any(parts) => {
228 parts.iter().any(|q| predicate_covers_field(q, field))
229 }
230 _ => false,
231 }
232}
233
234fn keymatch_field(p: &Predicate) -> Option<&str> {
236 match p {
237 Predicate::KeyMatch { field } => Some(field),
238 Predicate::All(parts) => parts.first().and_then(keymatch_field),
239 Predicate::Any(_) => None,
240 _ => None,
241 }
242}
243
244fn compute_desired(
247 def: &RuleDef,
248 index: &RuleIndex,
249 n: u32,
250 on_src_side: bool,
251 g: &GraphMut<'_>,
252) -> BTreeMap<(u32, u32), f64> {
253 let (my_label, other_label) = if on_src_side {
254 (&def.src_label, &def.dst_label)
255 } else {
256 (&def.dst_label, &def.src_label)
257 };
258
259 let Some(my_sym) = g.syms.get(my_label) else {
260 return BTreeMap::new();
261 };
262 if g.labels.get(n as usize).copied() != Some(my_sym) {
263 return BTreeMap::new();
264 }
265 let other_sym = g.syms.get(other_label);
266
267 let n_key = match g.ids.key_of(n) {
268 Some(k) => k,
269 None => return BTreeMap::new(),
270 };
271 let n_get = |f: &str| g.props.get(n, f).map(|vr| vr.into_value());
272
273 let spec = candidate_spec_for(def);
274 let candidates: BTreeSet<u32> = if on_src_side {
275 if is_keymatch_rooted(&def.predicate) {
276 let field = keymatch_field(&def.predicate).expect("ByKey always comes from KeyMatch");
280 match n_get(field) {
281 Some(Value::Str(ref target_key)) => match g.ids.get(target_key) {
282 Some(dst_id) => std::iter::once(dst_id).collect(),
283 None => BTreeSet::new(),
284 },
285 _ => BTreeSet::new(),
286 }
287 } else {
288 index.dst_side.candidates(&spec, &n_get)
289 }
290 } else {
291 let src_spec = src_lookup_spec_for(def);
293 if is_keymatch_rooted(&def.predicate) {
294 let key_getter = |_: &str| Some(Value::Str(n_key.to_string()));
297 index.src_side.candidates(&src_spec, &key_getter)
298 } else {
299 index.src_side.candidates(&src_spec, &n_get)
300 }
301 };
302
303 let n_early_exit_hint: Option<(Vec<f64>, f64, [f64; 8])> = if !def.approximate {
318 if let Predicate::VectorSimilar { field, .. } = &def.predicate {
319 if crate::index::vector_early_exit_enabled() {
320 let n_side = if on_src_side {
321 &index.src_side
322 } else {
323 &index.dst_side
324 };
325 if let Some(vn_v) = n_get(field) {
326 if let Some(vn) = crate::index::as_numeric_list(&vn_v) {
327 if let Some((norm_n, ckpts_n)) = n_side.fresh_ckpts_for(n, &vn) {
328 Some((vn, norm_n, *ckpts_n))
329 } else {
330 None
331 }
332 } else {
333 None
334 }
335 } else {
336 None
337 }
338 } else {
339 None
340 }
341 } else {
342 None
343 }
344 } else {
345 None
346 };
347
348 let mut out = BTreeMap::new();
349 for m in candidates {
350 if m == n {
351 continue; }
353 if g.labels.get(m as usize).copied() != other_sym {
354 continue; }
356 let m_key = match g.ids.key_of(m) {
357 Some(k) => k,
358 None => continue,
359 };
360 let m_get = |f: &str| g.props.get(m, f).map(|vr| vr.into_value());
361 let (s_view, d_view, s_id, d_id) = if on_src_side {
362 (
363 NodeView {
364 key: n_key,
365 props: &n_get,
366 },
367 NodeView {
368 key: m_key,
369 props: &m_get,
370 },
371 n,
372 m,
373 )
374 } else {
375 (
376 NodeView {
377 key: m_key,
378 props: &m_get,
379 },
380 NodeView {
381 key: n_key,
382 props: &n_get,
383 },
384 m,
385 n,
386 )
387 };
388
389 if let (Some((ref vn, norm_n, ckpts_n)), Predicate::VectorSimilar { field, min }) =
391 (&n_early_exit_hint, &def.predicate)
392 {
393 let m_side = if on_src_side {
394 &index.dst_side
395 } else {
396 &index.src_side
397 };
398 if let Some(vm_v) = m_get(field) {
399 if let Some(vm) = crate::index::as_numeric_list(&vm_v) {
400 if let Some((norm_m, ckpts_m)) = m_side.fresh_ckpts_for(m, &vm) {
401 let (va, ckpts_a, na, vb, ckpts_b, nb) = if on_src_side {
402 (
403 vn.as_slice(),
404 ckpts_n,
405 *norm_n,
406 vm.as_slice(),
407 ckpts_m,
408 norm_m,
409 )
410 } else {
411 (
412 vm.as_slice(),
413 ckpts_m,
414 norm_m,
415 vn.as_slice(),
416 ckpts_n,
417 *norm_n,
418 )
419 };
420 match crate::def::cosine_early_exit(va, vb, ckpts_a, ckpts_b, na, nb, *min)
421 {
422 None => continue, Some(score) => {
424 out.insert((s_id, d_id), score);
425 continue; }
427 }
428 }
429 }
430 }
431 }
432
433 if let Some(score) = evaluate(&def.predicate, &s_view, &d_view) {
434 out.insert((s_id, d_id), score);
435 }
436 }
437 out
438}
439
440fn compute_desired_via(
453 def: &RuleDef,
454 anchor: ViaAnchor,
455 g: &GraphMut<'_>,
456) -> BTreeMap<(u32, u32), f64> {
457 let via_label = def.via_label.as_deref().unwrap();
458 let via_edge_str = def.via_edge.as_deref().unwrap();
459 let via_dir = def.via_dir.unwrap_or(core_storage::Direction::Out);
460
461 let src_sym = match g.syms.get(&def.src_label) {
462 Some(s) => s,
463 None => return BTreeMap::new(),
464 };
465 let via_sym = match g.syms.get(via_label) {
466 Some(s) => s,
467 None => return BTreeMap::new(),
468 };
469 let dst_sym = match g.syms.get(&def.dst_label) {
470 Some(s) => s,
471 None => return BTreeMap::new(),
472 };
473 let via_etype = match g.syms.get(via_edge_str) {
474 Some(e) => e,
475 None => return BTreeMap::new(),
476 };
477
478 let srcs: Vec<u32> = match anchor {
480 ViaAnchor::Src(src_id) => {
481 if g.labels.get(src_id as usize).copied() == Some(src_sym) {
482 vec![src_id]
483 } else {
484 return BTreeMap::new();
485 }
486 }
487 ViaAnchor::Dst(_) => {
488 (0..g.ids.len() as u32)
490 .filter(|&id| {
491 matches!(
492 g.labels.get(id as usize).copied(),
493 Some(s) if s != u32::MAX && s == src_sym
494 )
495 })
496 .collect()
497 }
498 };
499
500 let anchored_dst: Option<u32> = match anchor {
502 ViaAnchor::Dst(dst_id) => {
503 if g.labels.get(dst_id as usize).copied() == Some(dst_sym) {
504 Some(dst_id)
505 } else {
506 return BTreeMap::new();
507 }
508 }
509 _ => None,
510 };
511
512 let mut out = BTreeMap::new();
513
514 for src in srcs {
515 let _src_key = match g.ids.key_of(src) {
516 Some(k) => k,
517 None => continue,
518 };
519 let via_neighbors: Vec<u32> = g
521 .topo
522 .neighbors(via_etype, via_dir, src)
523 .iter()
524 .copied()
525 .filter(|&v| g.labels.get(v as usize).copied() == Some(via_sym))
526 .collect();
527
528 if via_neighbors.is_empty() {
529 continue;
530 }
531
532 let dsts: Vec<u32> = if let Some(dst_id) = anchored_dst {
534 vec![dst_id]
535 } else {
536 (0..g.ids.len() as u32)
537 .filter(|&id| {
538 id != src
539 && matches!(
540 g.labels.get(id as usize).copied(),
541 Some(s) if s != u32::MAX && s == dst_sym
542 )
543 })
544 .collect()
545 };
546
547 for dst in dsts {
548 if dst == src {
549 continue; }
551 let dst_key = match g.ids.key_of(dst) {
552 Some(k) => k,
553 None => continue,
554 };
555 let dst_get = |f: &str| g.props.get(dst, f).map(|vr| vr.into_value());
556 let dst_view = NodeView {
557 key: dst_key,
558 props: &dst_get,
559 };
560
561 let mut best: Option<f64> = None;
563 for &via_id in &via_neighbors {
564 let via_key = match g.ids.key_of(via_id) {
565 Some(k) => k,
566 None => continue,
567 };
568 let via_get = |f: &str| g.props.get(via_id, f).map(|vr| vr.into_value());
569 let via_view = NodeView {
570 key: via_key,
571 props: &via_get,
572 };
573 if let Some(score) = evaluate(&def.predicate, &via_view, &dst_view) {
574 best = Some(match best {
575 None => score,
576 Some(prev) => prev.max(score),
577 });
578 }
579 }
580
581 if let Some(score) = best {
582 out.insert((src, dst), score);
583 }
584 }
585 }
586
587 out
588}
589
590enum ViaAnchor {
592 Src(u32),
594 Dst(u32),
597}
598
599fn edge_budget(def: &RuleDef) -> u64 {
600 def.max_edges.unwrap_or(DEFAULT_MAX_EDGES)
603}
604
605pub(crate) fn filter_src_top_k(
627 per_src: BTreeMap<(u32, u32), f64>,
628 k: u64,
629 ids: &core_storage::IdMap,
630) -> BTreeMap<(u32, u32), f64> {
631 if per_src.len() as u64 <= k {
632 return per_src;
633 }
634 let mut candidates: Vec<((u32, u32), f64)> = per_src.into_iter().collect();
635 candidates.sort_by(|&((_, da), sa), &((_, db), sb)| {
637 sb.total_cmp(&sa).then_with(|| {
638 let ka = ids.key_of(da).unwrap_or("");
639 let kb = ids.key_of(db).unwrap_or("");
640 ka.cmp(kb)
641 })
642 });
643 candidates.truncate(k as usize);
644 candidates.into_iter().collect()
645}
646
647fn apply_per_src_top_k(
654 def: &RuleDef,
655 src: u32,
656 desired_from_src: BTreeMap<(u32, u32), f64>,
657 prov: &mut ProvSets<'_>,
658 g: &mut GraphMut<'_>,
659) {
660 let et = g.syms.intern(&def.edge_type);
661
662 let current: Vec<Triple> = {
666 let rid = prov.rule_intern.get(&def.name).copied();
667 prov.by_node
668 .get(&src)
669 .into_iter()
670 .flatten()
671 .filter(|(r, t, s, _d)| Some(*r) == rid && *t == et && *s == src)
672 .map(|(_, t, s, d)| (*t, *s, *d))
673 .collect()
674 };
675
676 for (t, s, d) in current {
678 if !desired_from_src.contains_key(&(s, d)) {
679 g.topo.remove_edge(t, s, d);
680 g.edge_props.remove_edge(t, s, d);
681 prov.remove(&def.name, (t, s, d), g.ids, g.syms);
682 }
683 }
684
685 for ((s, d), score) in &desired_from_src {
687 let triple = (et, *s, *d);
688 let already = prov.contains(&triple);
689 if !already {
690 let newly = g.topo.add_edge(et, *s, *d);
691 if newly {
692 prov.insert(&def.name, triple, g.ids, g.syms);
693 }
694 }
695 let is_owned = already || prov.contains(&triple);
696 if is_owned {
697 if let Some(p) = &def.weight_prop {
698 g.edge_props.set(et, *s, *d, p, Value::Float(*score));
699 }
700 }
701 }
702}
703
704fn intern_rule(intern: &mut BTreeMap<String, u32>, names: &mut Vec<String>, rule: &str) -> u32 {
706 if let Some(&id) = intern.get(rule) {
707 return id;
708 }
709 let id = names.len() as u32;
710 intern.insert(rule.to_string(), id);
711 names.push(rule.to_string());
712 id
713}
714
715type ByNodeRebuild = (
716 BTreeMap<u32, BTreeSet<Touch>>,
717 BTreeMap<String, u32>,
718 Vec<String>,
719);
720
721fn rebuild_by_node(provenance: &BTreeMap<String, BTreeSet<Triple>>) -> ByNodeRebuild {
722 let mut by_node = BTreeMap::new();
723 let mut intern = BTreeMap::new();
724 let mut names = Vec::new();
725 for (rule, set) in provenance {
726 let rid = intern_rule(&mut intern, &mut names, rule);
727 for &triple in set {
728 touch_insert(&mut by_node, rid, triple);
729 }
730 }
731 (by_node, intern, names)
732}
733
734fn touch_insert(by_node: &mut BTreeMap<u32, BTreeSet<Touch>>, rid: u32, triple: Triple) {
735 let (t, s, d) = triple;
736 let entry = (rid, t, s, d);
737 by_node.entry(s).or_default().insert(entry);
738 if s != d {
739 by_node.entry(d).or_default().insert(entry);
740 }
741}
742
743fn touch_remove(by_node: &mut BTreeMap<u32, BTreeSet<Touch>>, rid: u32, triple: Triple) {
744 let (t, s, d) = triple;
745 let entry = (rid, t, s, d);
746 if let Some(set) = by_node.get_mut(&s) {
747 set.remove(&entry);
748 if set.is_empty() {
749 by_node.remove(&s);
750 }
751 }
752 if s != d {
753 if let Some(set) = by_node.get_mut(&d) {
754 set.remove(&entry);
755 if set.is_empty() {
756 by_node.remove(&d);
757 }
758 }
759 }
760}
761
762#[cfg(test)]
763fn resolve_by_node(
764 by_node: &BTreeMap<u32, BTreeSet<Touch>>,
765 names: &[String],
766) -> BTreeMap<u32, BTreeSet<(String, Triple)>> {
767 by_node
768 .iter()
769 .map(|(&n, set)| {
770 let resolved = set
771 .iter()
772 .map(|&(rid, t, s, d)| (names[rid as usize].clone(), (t, s, d)))
773 .collect();
774 (n, resolved)
775 })
776 .collect()
777}
778
779struct ProvSets<'a> {
782 set: &'a mut BTreeSet<Triple>,
783 owned: &'a mut BTreeSet<Triple>,
784 by_node: &'a mut BTreeMap<u32, BTreeSet<Touch>>,
785 rule_intern: &'a mut BTreeMap<String, u32>,
786 intern_rule: &'a mut Vec<String>,
787 deltas: &'a mut Vec<EngineEdgeDelta>,
791 emit: bool,
794}
795
796impl ProvSets<'_> {
797 fn insert(&mut self, rule: &str, triple: Triple, ids: &IdMap, syms: &Interner) -> bool {
801 if !self.set.insert(triple) {
802 return false;
803 }
804 self.owned.insert(triple);
805 let rid = intern_rule(self.rule_intern, self.intern_rule, rule);
806 touch_insert(self.by_node, rid, triple);
807 let (etype, src, dst) = triple;
808 if self.emit {
809 if let (Some(sk), Some(dk), Some(et)) =
810 (ids.key_of(src), ids.key_of(dst), syms.resolve(etype))
811 {
812 self.deltas.push(EngineEdgeDelta {
813 rule: rule.to_string(),
814 src_key: sk.to_string(),
815 dst_key: dk.to_string(),
816 edge_type: et.to_string(),
817 etype_sym: etype,
818 src_id: src,
819 dst_id: dst,
820 fired: true,
821 });
822 }
823 }
824 true
825 }
826
827 fn remove(&mut self, rule: &str, triple: Triple, ids: &IdMap, syms: &Interner) -> bool {
828 if !self.set.remove(&triple) {
829 return false;
830 }
831 self.owned.remove(&triple);
832 let rid = intern_rule(self.rule_intern, self.intern_rule, rule);
833 touch_remove(self.by_node, rid, triple);
834 let (etype, src, dst) = triple;
835 if self.emit {
836 if let (Some(sk), Some(dk), Some(et)) =
837 (ids.key_of(src), ids.key_of(dst), syms.resolve(etype))
838 {
839 self.deltas.push(EngineEdgeDelta {
840 rule: rule.to_string(),
841 src_key: sk.to_string(),
842 dst_key: dk.to_string(),
843 edge_type: et.to_string(),
844 etype_sym: etype,
845 src_id: src,
846 dst_id: dst,
847 fired: false,
848 });
849 }
850 }
851 true
852 }
853
854 fn contains(&self, triple: &Triple) -> bool {
855 self.set.contains(triple)
856 }
857
858 fn len(&self) -> usize {
859 self.set.len()
860 }
861}
862
863fn apply_desired(
874 def: &RuleDef,
875 desired: BTreeMap<(u32, u32), f64>,
876 retract_touching: Option<u32>,
877 prov: &mut ProvSets<'_>,
878 tripped: &mut bool,
879 g: &mut GraphMut<'_>,
880) {
881 let budget = edge_budget(def);
882 let et = g.syms.intern(&def.edge_type);
883
884 let current: Vec<Triple> = match retract_touching {
885 None => prov
886 .set
887 .iter()
888 .filter(|(t, _, _)| *t == et)
889 .copied()
890 .collect(),
891 Some(n) => {
892 let rid = prov.rule_intern.get(&def.name).copied();
893 prov.by_node
894 .get(&n)
895 .into_iter()
896 .flatten()
897 .filter(|(r, t, _, _)| Some(*r) == rid && *t == et)
898 .map(|(_, t, s, d)| (*t, *s, *d))
899 .collect()
900 }
901 };
902
903 for (t, s, d) in current {
904 if !desired.contains_key(&(s, d)) {
905 g.topo.remove_edge(t, s, d);
906 g.edge_props.remove_edge(t, s, d);
907 prov.remove(&def.name, (t, s, d), g.ids, g.syms);
908 }
909 }
910
911 for ((s, d), score) in desired {
912 let triple = (et, s, d);
913 let already = prov.contains(&triple);
914 if !already {
915 if *tripped || prov.len() as u64 >= budget {
916 *tripped = true;
917 continue;
918 }
919 let newly = g.topo.add_edge(et, s, d);
920 if newly {
921 prov.insert(&def.name, triple, g.ids, g.syms);
922 }
923 }
924 let is_owned_here = already || prov.contains(&triple);
928 if is_owned_here {
929 if let Some(p) = &def.weight_prop {
930 g.edge_props.set(et, s, d, p, Value::Float(score));
931 }
932 }
933 }
934}
935
936#[cfg(test)]
944#[allow(dead_code)]
945fn compute_full_desired(
946 def: &RuleDef,
947 index: &RuleIndex,
948 g: &GraphMut<'_>,
949) -> BTreeMap<(u32, u32), f64> {
950 let mut desired = BTreeMap::new();
951 let src_sym = g.syms.get(&def.src_label);
952 for id in 0..g.ids.len() as u32 {
953 let label_sym = match g.labels.get(id as usize).copied() {
954 Some(s) if s != u32::MAX => s,
955 _ => continue,
956 };
957 if src_sym == Some(label_sym) {
958 desired.extend(compute_desired(def, index, id, true, g));
959 }
960 }
961 desired
962}
963
964fn pair_still_desired(def: &RuleDef, s: u32, d: u32, g: &GraphMut<'_>) -> bool {
971 let src_sym = match g.syms.get(&def.src_label) {
972 Some(sym) => sym,
973 None => return false,
974 };
975 let dst_sym = match g.syms.get(&def.dst_label) {
976 Some(sym) => sym,
977 None => return false,
978 };
979 if g.labels.get(s as usize).copied() != Some(src_sym) {
980 return false;
981 }
982 if g.labels.get(d as usize).copied() != Some(dst_sym) {
983 return false;
984 }
985 let s_key = match g.ids.key_of(s) {
986 Some(k) => k,
987 None => return false,
988 };
989 let d_key = match g.ids.key_of(d) {
990 Some(k) => k,
991 None => return false,
992 };
993 let s_get = |f: &str| g.props.get(s, f).map(|vr| vr.into_value());
994 let d_get = |f: &str| g.props.get(d, f).map(|vr| vr.into_value());
995 evaluate(
996 &def.predicate,
997 &NodeView {
998 key: s_key,
999 props: &s_get,
1000 },
1001 &NodeView {
1002 key: d_key,
1003 props: &d_get,
1004 },
1005 )
1006 .is_some()
1007}
1008
1009fn count_desired_up_to(def: &RuleDef, index: &RuleIndex, limit: u64, g: &GraphMut<'_>) -> u64 {
1014 let mut count = 0u64;
1015 let src_sym = g.syms.get(&def.src_label);
1016 for id in 0..g.ids.len() as u32 {
1017 let label_sym = match g.labels.get(id as usize).copied() {
1018 Some(s) if s != u32::MAX => s,
1019 _ => continue,
1020 };
1021 if src_sym != Some(label_sym) {
1022 continue;
1023 }
1024 count += compute_desired(def, index, id, true, g).len() as u64;
1025 if count > limit {
1026 return count;
1027 }
1028 }
1029 count
1030}
1031
1032fn apply_streaming_create(
1054 def: &RuleDef,
1055 index: &RuleIndex,
1056 prov: &mut ProvSets<'_>,
1057 tripped: &mut bool,
1058 g: &mut GraphMut<'_>,
1059) {
1060 let budget = edge_budget(def);
1061 let et = g.syms.intern(&def.edge_type);
1062 let src_sym = g.syms.get(&def.src_label);
1063
1064 'outer: for id in 0..g.ids.len() as u32 {
1065 let label_sym = match g.labels.get(id as usize).copied() {
1066 Some(s) if s != u32::MAX => s,
1067 _ => continue,
1068 };
1069 if src_sym != Some(label_sym) {
1070 continue;
1071 }
1072 let per_src = compute_desired(def, index, id, true, g);
1073 for ((s, d), score) in per_src {
1074 let triple = (et, s, d);
1075 let already = prov.contains(&triple);
1080 if !already {
1081 if *tripped || prov.len() as u64 >= budget {
1082 *tripped = true;
1083 break 'outer;
1084 }
1085 let newly = g.topo.add_edge(et, s, d);
1086 if newly {
1087 prov.insert(&def.name, triple, g.ids, g.syms);
1088 }
1089 }
1090 let is_owned_here = already || prov.contains(&triple);
1091 if is_owned_here {
1092 if let Some(p) = &def.weight_prop {
1093 g.edge_props.set(et, s, d, p, Value::Float(score));
1094 }
1095 }
1096 }
1097 }
1098}
1099
1100fn apply_streaming_create_top_k(
1107 def: &RuleDef,
1108 k: u64,
1109 index: &RuleIndex,
1110 prov: &mut ProvSets<'_>,
1111 g: &mut GraphMut<'_>,
1112) {
1113 let src_sym = g.syms.get(&def.src_label);
1114 for id in 0..g.ids.len() as u32 {
1115 let label_sym = match g.labels.get(id as usize).copied() {
1116 Some(s) if s != u32::MAX => s,
1117 _ => continue,
1118 };
1119 if src_sym != Some(label_sym) {
1120 continue;
1121 }
1122 let per_src = compute_desired(def, index, id, true, g);
1123 let top_k = filter_src_top_k(per_src, k, g.ids);
1124 apply_per_src_top_k(def, id, top_k, prov, g);
1125 }
1126}
1127
1128fn apply_streaming_rebuild_top_k(
1135 def: &RuleDef,
1136 k: u64,
1137 index: &RuleIndex,
1138 prov: &mut ProvSets<'_>,
1139 g: &mut GraphMut<'_>,
1140) {
1141 let et = g.syms.intern(&def.edge_type);
1142
1143 let existing_srcs: BTreeSet<u32> = prov
1146 .set
1147 .iter()
1148 .filter(|(t, _, _)| *t == et)
1149 .map(|(_, s, _)| *s)
1150 .collect();
1151
1152 let src_sym = g.syms.get(&def.src_label);
1153 let mut all_srcs: BTreeSet<u32> = existing_srcs;
1154 for id in 0..g.ids.len() as u32 {
1155 let label_sym = match g.labels.get(id as usize).copied() {
1156 Some(s) if s != u32::MAX => s,
1157 _ => continue,
1158 };
1159 if src_sym == Some(label_sym) {
1160 all_srcs.insert(id);
1161 }
1162 }
1163
1164 for src in all_srcs {
1165 let desired_src = compute_desired(def, index, src, true, g);
1166 let top_k = filter_src_top_k(desired_src, k, g.ids);
1167 apply_per_src_top_k(def, src, top_k, prov, g);
1168 }
1169}
1170
1171fn apply_streaming_rebuild(
1184 def: &RuleDef,
1185 index: &RuleIndex,
1186 prov: &mut ProvSets<'_>,
1187 tripped: &mut bool,
1188 g: &mut GraphMut<'_>,
1189) {
1190 let budget = edge_budget(def);
1191 let et = g.syms.intern(&def.edge_type);
1192
1193 let total = count_desired_up_to(def, index, budget, g);
1195 if total > budget {
1196 *tripped = true;
1197 return; }
1199
1200 *tripped = false;
1202
1203 let current: Vec<Triple> = prov
1206 .set
1207 .iter()
1208 .filter(|(t, _, _)| *t == et)
1209 .copied()
1210 .collect();
1211 for (t, s, d) in current {
1212 if !pair_still_desired(def, s, d, g) {
1213 g.topo.remove_edge(t, s, d);
1214 g.edge_props.remove_edge(t, s, d);
1215 prov.remove(&def.name, (t, s, d), g.ids, g.syms);
1216 }
1217 }
1218
1219 let src_sym = g.syms.get(&def.src_label);
1222 for id in 0..g.ids.len() as u32 {
1223 let label_sym = match g.labels.get(id as usize).copied() {
1224 Some(s) if s != u32::MAX => s,
1225 _ => continue,
1226 };
1227 if src_sym != Some(label_sym) {
1228 continue;
1229 }
1230 let per_src = compute_desired(def, index, id, true, g);
1231 for ((s, d), score) in per_src {
1232 let triple = (et, s, d);
1233 let already = prov.contains(&triple);
1234 if !already {
1235 let newly = g.topo.add_edge(et, s, d);
1236 if newly {
1237 prov.insert(&def.name, triple, g.ids, g.syms);
1238 }
1239 }
1240 let is_owned_here = already || prov.contains(&triple);
1241 if is_owned_here {
1242 if let Some(p) = &def.weight_prop {
1243 g.edge_props.set(et, s, d, p, Value::Float(score));
1244 }
1245 }
1246 }
1247 }
1248}
1249
1250fn bump_fires_for_participants(def: &RuleDef, g: &GraphMut<'_>, fires: &mut u64) {
1253 let src_sym = g.syms.get(&def.src_label);
1254 let dst_sym = g.syms.get(&def.dst_label);
1255 for id in 0..g.ids.len() as u32 {
1256 let label_sym = match g.labels.get(id as usize).copied() {
1257 Some(s) if s != u32::MAX => s,
1258 _ => continue,
1259 };
1260 if src_sym == Some(label_sym) || dst_sym == Some(label_sym) {
1261 *fires += 1;
1262 }
1263 }
1264}
1265
1266fn index_node_for_rule(
1268 id: u32,
1269 label_sym: u32,
1270 def: &RuleDef,
1271 index: &mut RuleIndex,
1272 syms: &Interner,
1273 props: ColumnsView<'_>,
1274) {
1275 let get = |f: &str| props.get(id, f).map(|vr| vr.into_value());
1276 if syms.get(&def.src_label) == Some(label_sym) {
1277 let spec = src_lookup_spec_for(def);
1278 index.src_side.insert(&spec, id, &get);
1279 }
1280 if syms.get(&def.dst_label) == Some(label_sym) {
1281 let spec = candidate_spec_for(def);
1282 index.dst_side.insert(&spec, id, &get);
1283 }
1284}
1285
1286impl RuleEngine {
1291 pub fn new() -> Self {
1292 Self::default()
1293 }
1294
1295 pub fn rules(&self) -> impl Iterator<Item = &RuleDef> {
1296 self.rules.values()
1297 }
1298
1299 pub fn is_owned(&self, etype: u32, src: u32, dst: u32) -> bool {
1300 self.owned.contains(&(etype, src, dst))
1301 }
1302
1303 fn provenance_is_retained(&self) -> bool {
1308 self.retained_provenance_bytes
1309 .lock()
1310 .expect("lock poisoned")
1311 .is_some()
1312 }
1313
1314 pub fn provenance(&self) -> &BTreeMap<String, BTreeSet<(u32, u32, u32)>> {
1319 if self.provenance_is_retained() {
1320 self.ensure_provenance_loaded();
1321 &self.lazy_provenance.get().unwrap().provenance
1322 } else {
1323 &self.provenance
1324 }
1325 }
1326
1327 pub fn provenance_touching(
1332 &self,
1333 node: u32,
1334 ) -> impl Iterator<Item = (&str, u32, u32, u32)> + '_ {
1335 let use_lazy = self.provenance_is_retained();
1336 let (by_node, intern_rule): (&BTreeMap<u32, BTreeSet<Touch>>, &Vec<String>) = if use_lazy {
1337 self.ensure_provenance_loaded();
1338 let lp = self.lazy_provenance.get().unwrap();
1339 (&lp.by_node, &lp.intern_rule)
1340 } else {
1341 (&self.by_node, &self.intern_rule)
1342 };
1343 by_node
1344 .get(&node)
1345 .into_iter()
1346 .flatten()
1347 .map(move |&(rid, t, s, d)| (intern_rule[rid as usize].as_str(), t, s, d))
1348 }
1349
1350 pub fn provenance_touching_len(&self, node: u32) -> usize {
1352 if self.provenance_is_retained() {
1353 self.ensure_provenance_loaded();
1354 self.lazy_provenance
1355 .get()
1356 .unwrap()
1357 .by_node
1358 .get(&node)
1359 .map_or(0, BTreeSet::len)
1360 } else {
1361 self.by_node.get(&node).map_or(0, BTreeSet::len)
1362 }
1363 }
1364
1365 pub fn is_tripped(&self, name: &str) -> bool {
1368 self.tripped.get(name).copied().unwrap_or(false)
1369 }
1370
1371 pub fn fire_count(&self, name: &str) -> u64 {
1375 self.fires.get(name).copied().unwrap_or(0)
1376 }
1377
1378 pub fn drain_deltas(&mut self) -> Vec<EngineEdgeDelta> {
1392 std::mem::take(&mut self.pending_deltas)
1393 }
1394
1395 pub fn pending_delta_count(&self) -> usize {
1398 self.pending_deltas.len()
1399 }
1400
1401 pub fn pending_deltas_since(&self, cursor: usize) -> &[EngineEdgeDelta] {
1410 &self.pending_deltas[cursor..]
1411 }
1412
1413 #[allow(clippy::type_complexity)]
1417 pub fn to_persist(
1418 &self,
1419 ) -> (
1420 Vec<RuleDef>,
1421 BTreeMap<String, BTreeSet<(u32, u32, u32)>>,
1422 BTreeMap<String, bool>,
1423 BTreeMap<String, u64>,
1424 ) {
1425 (
1426 self.rules.values().cloned().collect(),
1427 self.provenance.clone(),
1428 self.tripped.clone(),
1429 self.fires.clone(),
1430 )
1431 }
1432
1433 pub fn from_persist(
1435 rules: Vec<RuleDef>,
1436 prov: BTreeMap<String, BTreeSet<(u32, u32, u32)>>,
1437 tripped: BTreeMap<String, bool>,
1438 fires: BTreeMap<String, u64>,
1439 ) -> Self {
1440 let mut owned = BTreeSet::new();
1441 for set in prov.values() {
1442 owned.extend(set.iter().copied());
1443 }
1444 let indexes = rules
1445 .iter()
1446 .map(|r| (r.name.clone(), RuleIndex::default()))
1447 .collect();
1448 let rules: BTreeMap<String, RuleDef> =
1449 rules.into_iter().map(|r| (r.name.clone(), r)).collect();
1450 let mut tripped = tripped;
1452 let mut fires = fires;
1453 for name in rules.keys() {
1454 tripped.entry(name.clone()).or_insert(false);
1455 fires.entry(name.clone()).or_insert(0);
1456 }
1457 let (by_node, rule_intern, intern_rule) = rebuild_by_node(&prov);
1458 Self {
1459 rules,
1460 indexes,
1461 provenance: prov,
1462 owned,
1463 by_node,
1464 rule_intern,
1465 intern_rule,
1466 tripped,
1467 fires,
1468 pending_deltas: Vec::new(),
1469 emit_deltas: false,
1470 rebuild_needed: BTreeSet::new(),
1471 indexes_populated: false,
1475 retained_hnsw_blobs: Mutex::new(BTreeMap::new()),
1476 retained_ivf_bytes: Mutex::new(None),
1477 retained_provenance_bytes: Mutex::new(None),
1478 lazy_provenance: OnceLock::new(),
1479 lazy_hnsw: OnceLock::new(),
1480 }
1481 }
1482
1483 pub fn set_emit_deltas(&mut self, emit: bool) {
1489 self.emit_deltas = emit;
1490 }
1491
1492 pub fn emit_deltas(&self) -> bool {
1494 self.emit_deltas
1495 }
1496
1497 pub fn take_rebuild_needed(&mut self) -> Vec<String> {
1500 std::mem::take(&mut self.rebuild_needed)
1501 .into_iter()
1502 .collect()
1503 }
1504
1505 pub fn queue_rebuild_needed(&mut self, name: String) {
1509 self.rebuild_needed.insert(name);
1510 }
1511
1512 fn maybe_queue_ivf_rebuild(&mut self, rule_name: &str, def: &RuleDef) {
1513 if !def.approximate {
1514 return;
1515 }
1516 let Some(idx) = self.indexes.get(rule_name) else {
1517 return;
1518 };
1519 if idx.dst_side.ivf_drift > ivf_drift_rebuild_threshold() {
1520 self.rebuild_needed.insert(rule_name.to_string());
1521 }
1522 }
1523
1524 pub fn export_ivf_state(&self) -> BTreeMap<String, RuleIvfExport> {
1528 let mut out = BTreeMap::new();
1529 for (name, def) in &self.rules {
1530 if def.approximate {
1531 if let Some(idx) = self.indexes.get(name) {
1532 out.insert(
1533 name.clone(),
1534 (
1535 idx.src_side.export_ivf_state(),
1536 idx.dst_side.export_ivf_state(),
1537 ),
1538 );
1539 }
1540 }
1541 }
1542 out
1543 }
1544
1545 pub fn reindex_all(
1547 &mut self,
1548 ids: &IdMap,
1549 syms: &Interner,
1550 labels: &[u32],
1551 props: ColumnsView<'_>,
1552 ) {
1553 for idx in self.indexes.values_mut() {
1554 *idx = RuleIndex::default();
1555 }
1556 let rule_names: Vec<String> = self.rules.keys().cloned().collect();
1559
1560 for name in &rule_names {
1562 if self.rules[name].approximate {
1563 let idx = self.indexes.get_mut(name).unwrap();
1564 idx.src_side.init_hnsw(name);
1565 idx.dst_side.init_hnsw(name);
1566 }
1567 }
1568
1569 for id in 0..ids.len() as u32 {
1570 let label_sym = match labels.get(id as usize).copied() {
1571 Some(s) if s != u32::MAX => s,
1572 _ => continue,
1573 };
1574 for name in &rule_names {
1575 let def = self.rules[name].clone();
1576 let idx = self.indexes.get_mut(name).unwrap();
1577 index_node_for_rule(id, label_sym, &def, idx, syms, props);
1578 }
1579 }
1580 for name in &rule_names {
1583 if self.rules[name].approximate {
1584 let idx = self.indexes.get_mut(name).unwrap();
1585 idx.src_side.fit_ivf_clusters(name);
1586 idx.dst_side.fit_ivf_clusters(name);
1587 }
1588 }
1589 self.indexes_populated = true;
1590 }
1591
1592 pub fn reindex_all_load_ivf(
1602 &mut self,
1603 ids: &IdMap,
1604 syms: &Interner,
1605 labels: &[u32],
1606 props: ColumnsView<'_>,
1607 ivf_state: BTreeMap<String, RuleIvfExport>,
1608 ) {
1609 for idx in self.indexes.values_mut() {
1610 *idx = RuleIndex::default();
1611 }
1612 let rule_names: Vec<String> = self.rules.keys().cloned().collect();
1613
1614 for name in &rule_names {
1617 if self.rules[name].approximate {
1618 let idx = self.indexes.get_mut(name).unwrap();
1619 idx.src_side.init_hnsw(name);
1620 idx.dst_side.init_hnsw(name);
1621 }
1622 }
1623
1624 for id in 0..ids.len() as u32 {
1625 let label_sym = match labels.get(id as usize).copied() {
1626 Some(s) if s != u32::MAX => s,
1627 _ => continue,
1628 };
1629 for name in &rule_names {
1630 let def = self.rules[name].clone();
1631 let idx = self.indexes.get_mut(name).unwrap();
1632 index_node_for_rule(id, label_sym, &def, idx, syms, props);
1633 }
1634 }
1635 for name in &rule_names {
1639 if !self.rules[name].approximate {
1640 continue;
1641 }
1642 let idx = self.indexes.get_mut(name).unwrap();
1643 if let Some(((sc, sa, sd), (dc, da, dd))) = ivf_state.get(name) {
1644 idx.src_side.load_ivf_state(sc.clone(), sa.clone(), *sd);
1645 idx.dst_side.load_ivf_state(dc.clone(), da.clone(), *dd);
1646 } else {
1647 idx.src_side.fit_ivf_clusters(name);
1649 idx.dst_side.fit_ivf_clusters(name);
1650 }
1651 }
1652 self.indexes_populated = true;
1653 }
1654
1655 pub fn store_snapshot_state(
1663 &self,
1664 hnsw_blobs: BTreeMap<String, (Vec<u8>, Vec<u8>)>,
1665 ivf_bytes: Vec<u8>,
1666 ) {
1667 *self
1668 .retained_hnsw_blobs
1669 .lock()
1670 .expect("retained_hnsw_blobs lock poisoned") = hnsw_blobs;
1671 *self
1672 .retained_ivf_bytes
1673 .lock()
1674 .expect("retained_ivf_bytes lock poisoned") = if ivf_bytes.is_empty() {
1675 None
1676 } else {
1677 Some(ivf_bytes)
1678 };
1679 }
1681
1682 pub fn store_provenance_bytes(&self, bytes: Vec<u8>) {
1689 *self
1690 .retained_provenance_bytes
1691 .lock()
1692 .expect("lock poisoned") = if bytes.is_empty() { None } else { Some(bytes) };
1693 }
1694
1695 pub fn ensure_provenance_loaded(&self) {
1702 self.lazy_provenance.get_or_init(|| {
1703 let guard = self
1706 .retained_provenance_bytes
1707 .lock()
1708 .expect("retained_provenance_bytes lock poisoned");
1709 let bytes = match &*guard {
1710 Some(b) if !b.is_empty() => b,
1711 _ => return LazyProvenance::default(),
1712 };
1713 let prov = decode_provenance_bytes(bytes);
1714 let (by_node, _rule_intern, intern_rule) = rebuild_by_node(&prov);
1715 LazyProvenance {
1716 provenance: prov,
1717 by_node,
1718 intern_rule,
1719 }
1720 });
1721 }
1722
1723 pub fn ensure_provenance_loaded_mut(&mut self) {
1729 let bytes = match self
1730 .retained_provenance_bytes
1731 .lock()
1732 .expect("lock poisoned")
1733 .take()
1734 {
1735 Some(b) => b,
1736 None => return,
1737 };
1738 let prov = decode_provenance_bytes(&bytes);
1739 for set in prov.values() {
1740 self.owned.extend(set.iter().copied());
1741 }
1742 let (by_node, rule_intern, intern_rule) = rebuild_by_node(&prov);
1743 self.provenance = prov;
1744 self.by_node = by_node;
1745 self.rule_intern = rule_intern;
1746 self.intern_rule = intern_rule;
1747 }
1748
1749 pub fn consume_retained_state_eager(
1755 &mut self,
1756 ids: &IdMap,
1757 syms: &Interner,
1758 labels: &[u32],
1759 props: ColumnsView<'_>,
1760 ) {
1761 if self.indexes_populated {
1762 return;
1763 }
1764 self.ensure_provenance_loaded_mut();
1767 let hnsw = std::mem::take(
1768 &mut *self
1769 .retained_hnsw_blobs
1770 .lock()
1771 .expect("retained_hnsw_blobs lock poisoned"),
1772 );
1773 let ivf_bytes = self
1774 .retained_ivf_bytes
1775 .lock()
1776 .expect("retained_ivf_bytes lock poisoned")
1777 .take()
1778 .unwrap_or_default();
1779 let ivf = decode_ivf_bytes_to_export(&ivf_bytes);
1780 self.reindex_all_load_ivf(ids, syms, labels, props, ivf);
1781 self.load_hnsw_state(hnsw);
1783 }
1784
1785 pub fn ensure_hnsw_loaded(&self) {
1796 self.lazy_hnsw.get_or_init(|| {
1797 let snapshot: Vec<(String, Vec<u8>, Vec<u8>)> = {
1801 let guard = self
1802 .retained_hnsw_blobs
1803 .lock()
1804 .expect("retained_hnsw_blobs lock poisoned");
1805 if guard.is_empty() {
1806 return BTreeMap::new();
1807 }
1808 guard
1809 .iter()
1810 .map(|(name, (sb, db))| (name.clone(), sb.clone(), db.clone()))
1811 .collect()
1812 }; snapshot
1814 .into_iter()
1815 .map(|(name, sb, db)| {
1816 let src = if !sb.is_empty() {
1817 bincode::deserialize::<HnswIndex>(&sb).ok()
1818 } else {
1819 None
1820 };
1821 let dst = if !db.is_empty() {
1822 bincode::deserialize::<HnswIndex>(&db).ok()
1823 } else {
1824 None
1825 };
1826 (name, (src, dst))
1827 })
1828 .collect()
1829 });
1830 }
1831
1832 pub fn indexes_populated(&self) -> bool {
1835 self.indexes_populated
1836 }
1837
1838 pub fn export_hnsw_state(&self) -> BTreeMap<String, (Vec<u8>, Vec<u8>)> {
1843 let mut out = BTreeMap::new();
1844 for (name, def) in &self.rules {
1845 if def.approximate {
1846 if let Some(idx) = self.indexes.get(name) {
1847 out.insert(
1848 name.clone(),
1849 (
1850 idx.src_side.export_hnsw_blob(),
1851 idx.dst_side.export_hnsw_blob(),
1852 ),
1853 );
1854 }
1855 }
1856 }
1857 out
1858 }
1859
1860 pub fn export_hnsw_state_passthrough(&self) -> BTreeMap<String, (Vec<u8>, Vec<u8>)> {
1864 if !self.indexes_populated {
1865 let guard = self
1866 .retained_hnsw_blobs
1867 .lock()
1868 .expect("retained_hnsw_blobs lock poisoned");
1869 if !guard.is_empty() {
1870 return guard.clone();
1871 }
1872 }
1873 self.export_hnsw_state()
1874 }
1875
1876 pub fn retained_ivf_bytes_clone(&self) -> Option<Vec<u8>> {
1882 self.retained_ivf_bytes
1883 .lock()
1884 .expect("retained_ivf_bytes lock poisoned")
1885 .clone()
1886 }
1887
1888 pub fn load_hnsw_state(&mut self, blobs: BTreeMap<String, (Vec<u8>, Vec<u8>)>) {
1893 for (name, (src_blob, dst_blob)) in blobs {
1894 if let Some(idx) = self.indexes.get_mut(&name) {
1895 if !src_blob.is_empty() {
1896 idx.src_side.load_hnsw_blob(&src_blob);
1897 }
1898 if !dst_blob.is_empty() {
1899 idx.dst_side.load_hnsw_blob(&dst_blob);
1900 }
1901 }
1902 }
1903 }
1904
1905 pub fn hnsw_search_dst(
1910 &self,
1911 field: &str,
1912 dst_label: &str,
1913 q: &[f64],
1914 k: usize,
1915 ) -> Option<Vec<(u32, f64)>> {
1916 for (name, def) in &self.rules {
1917 if !def.approximate || def.dst_label != dst_label {
1918 continue;
1919 }
1920 if !predicate_covers_field(&def.predicate, field) {
1922 continue;
1923 }
1924 if let Some(idx) = self.indexes.get(name) {
1925 if let Some(h) = idx.dst_side.hnsw_ref() {
1926 if !h.is_empty() {
1927 return Some(h.search(q, k));
1928 }
1929 }
1930 }
1931 if let Some(lazy) = self.lazy_hnsw.get() {
1934 if let Some((_, Some(h))) = lazy.get(name) {
1935 if !h.is_empty() {
1936 return Some(h.search(q, k));
1937 }
1938 }
1939 }
1940 }
1941 None
1942 }
1943
1944 pub fn create_rule(&mut self, def: RuleDef, g: &mut GraphMut<'_>) -> Result<(), String> {
1947 def.validate()?;
1948 if self.rules.contains_key(&def.name) {
1949 return Err(format!("rule {:?} already exists", def.name));
1950 }
1951 let name = def.name.clone();
1952 self.rules.insert(name.clone(), def);
1953 self.indexes.insert(name.clone(), RuleIndex::default());
1954 self.provenance.entry(name.clone()).or_default();
1955 self.tripped.insert(name.clone(), false);
1956 self.fires.insert(name.clone(), 0);
1957
1958 let n_total = g.ids.len() as u32;
1960 let def = self.rules[&name].clone();
1961
1962 if def.approximate {
1965 let idx = self.indexes.get_mut(&name).unwrap();
1966 idx.src_side.init_hnsw(&name);
1967 idx.dst_side.init_hnsw(&name);
1968 }
1969
1970 for id in 0..n_total {
1971 let label_sym = match g.labels.get(id as usize).copied() {
1972 Some(s) if s != u32::MAX => s,
1973 _ => continue,
1974 };
1975 let idx = self.indexes.get_mut(&name).unwrap();
1976 index_node_for_rule(id, label_sym, &def, idx, g.syms, g.props);
1977 }
1978
1979 if def.approximate {
1982 let idx = self.indexes.get_mut(&name).unwrap();
1983 idx.src_side.fit_ivf_clusters(&name);
1984 idx.dst_side.fit_ivf_clusters(&name);
1985 }
1986
1987 let mut prov = ProvSets {
1992 set: self.provenance.get_mut(&name).unwrap(),
1993 owned: &mut self.owned,
1994 by_node: &mut self.by_node,
1995 rule_intern: &mut self.rule_intern,
1996 intern_rule: &mut self.intern_rule,
1997 deltas: &mut self.pending_deltas,
1998 emit: self.emit_deltas,
1999 };
2000 if def.via_label.is_some() {
2001 let budget = edge_budget(&def);
2003 let et = g.syms.intern(&def.edge_type);
2004 let src_sym = g.syms.get(&def.src_label);
2005 let tripped = self.tripped.get_mut(&name).unwrap();
2006 'via_outer: for id in 0..g.ids.len() as u32 {
2007 let label_sym = match g.labels.get(id as usize).copied() {
2008 Some(s) if s != u32::MAX => s,
2009 _ => continue,
2010 };
2011 if src_sym != Some(label_sym) {
2012 continue;
2013 }
2014 let per_src = compute_desired_via(&def, ViaAnchor::Src(id), g);
2015 if let Some(k) = def.max_edges {
2016 let top_k = filter_src_top_k(per_src, k, g.ids);
2017 apply_per_src_top_k(&def, id, top_k, &mut prov, g);
2018 } else {
2019 for ((s, d), score) in per_src {
2020 let triple = (et, s, d);
2021 let already = prov.contains(&triple);
2022 if !already {
2023 if *tripped || prov.len() as u64 >= budget {
2024 *tripped = true;
2025 break 'via_outer;
2026 }
2027 let newly = g.topo.add_edge(et, s, d);
2028 if newly {
2029 prov.insert(&name, triple, g.ids, g.syms);
2030 }
2031 }
2032 let is_owned_here = already || prov.contains(&triple);
2033 if is_owned_here {
2034 if let Some(p) = &def.weight_prop {
2035 g.edge_props.set(et, s, d, p, Value::Float(score));
2036 }
2037 }
2038 }
2039 }
2040 }
2041 } else if let Some(k) = def.max_edges {
2042 apply_streaming_create_top_k(&def, k, &self.indexes[&name], &mut prov, g);
2043 } else {
2044 let tripped = self.tripped.get_mut(&name).unwrap();
2045 apply_streaming_create(&def, &self.indexes[&name], &mut prov, tripped, g);
2046 }
2047 let fires = self.fires.get_mut(&name).unwrap();
2050 bump_fires_for_participants(&def, g, fires);
2051
2052 self.indexes_populated = true;
2056
2057 Ok(())
2058 }
2059
2060 pub fn delete_rule(&mut self, name: &str, g: &mut GraphMut<'_>) -> Result<(), String> {
2062 if !self.rules.contains_key(name) {
2063 return Err(format!("rule {:?} not found", name));
2064 }
2065 let def = self.rules.remove(name).unwrap();
2066 self.indexes.remove(name);
2067 self.tripped.remove(name);
2068 self.fires.remove(name);
2069 let mut leftover = self.provenance.remove(name).unwrap_or_default();
2070 let _et = g.syms.intern(&def.edge_type);
2072 let triples: Vec<Triple> = leftover.iter().copied().collect();
2073 let mut sets = ProvSets {
2074 set: &mut leftover,
2075 owned: &mut self.owned,
2076 by_node: &mut self.by_node,
2077 rule_intern: &mut self.rule_intern,
2078 intern_rule: &mut self.intern_rule,
2079 deltas: &mut self.pending_deltas,
2080 emit: self.emit_deltas,
2081 };
2082 for triple in triples {
2083 let (t, s, d) = triple;
2084 g.topo.remove_edge(t, s, d);
2085 g.edge_props.remove_edge(t, s, d);
2086 sets.remove(name, triple, g.ids, g.syms);
2087 }
2088 let same_etype_survivors: Vec<String> = self
2094 .rules
2095 .values()
2096 .filter(|r| r.edge_type == def.edge_type)
2097 .map(|r| r.name.clone())
2098 .collect();
2099 for survivor in same_etype_survivors {
2100 let _ = self.rebuild(&survivor, g);
2102 }
2103 Ok(())
2104 }
2105
2106 pub fn on_node_changed(
2116 &mut self,
2117 n: u32,
2118 changed: Option<(&str, Option<Value>)>,
2119 g: &mut GraphMut<'_>,
2120 ) {
2121 self.ensure_provenance_loaded_mut();
2123 if !self.indexes_populated && !self.rules.is_empty() {
2129 let hnsw = std::mem::take(
2130 &mut *self
2131 .retained_hnsw_blobs
2132 .lock()
2133 .expect("retained_hnsw_blobs lock poisoned"),
2134 );
2135 let ivf_bytes = self
2136 .retained_ivf_bytes
2137 .lock()
2138 .expect("retained_ivf_bytes lock poisoned")
2139 .take()
2140 .unwrap_or_default();
2141 let ivf = decode_ivf_bytes_to_export(&ivf_bytes);
2142 self.reindex_all_load_ivf(g.ids, g.syms, g.labels, g.props, ivf);
2143 self.load_hnsw_state(hnsw);
2144 }
2145
2146 let n_label = g.labels.get(n as usize).copied();
2147 let rule_names: Vec<String> = self.rules.keys().cloned().collect();
2148
2149 for rule_name in rule_names {
2150 let def = self.rules[&rule_name].clone();
2151
2152 if def.via_label.is_some() {
2153 self.on_node_changed_via(&rule_name, &def, n, n_label, changed.clone(), g);
2155 } else {
2156 let src_sym = g.syms.get(&def.src_label);
2158 let dst_sym = g.syms.get(&def.dst_label);
2159 let as_src = src_sym.is_some() && n_label == src_sym;
2160 let as_dst = dst_sym.is_some() && n_label == dst_sym;
2161
2162 let fires = match changed {
2163 None => as_src || as_dst,
2164 Some((field, _)) => def.watched_fields().contains(field) && (as_src || as_dst),
2165 };
2166 if !fires {
2167 continue;
2168 }
2169 *self.fires.entry(rule_name.clone()).or_default() += 1;
2170
2171 if let Some((field, ref old_val)) = changed {
2173 let old_val_cloned = old_val.clone();
2174 let old_getter = |f: &str| {
2175 if f == field {
2176 old_val_cloned.clone()
2177 } else {
2178 g.props.get(n, f).map(|vr| vr.into_value())
2179 }
2180 };
2181 let idx = self.indexes.get_mut(&rule_name).unwrap();
2182 if as_src {
2183 let spec = src_lookup_spec_for(&def);
2184 idx.src_side.remove(&spec, n, &old_getter);
2185 }
2186 if as_dst {
2187 let spec = candidate_spec_for(&def);
2188 idx.dst_side.remove(&spec, n, &old_getter);
2189 }
2190 }
2191
2192 {
2193 let cur_getter = |f: &str| g.props.get(n, f).map(|vr| vr.into_value());
2194 let idx = self.indexes.get_mut(&rule_name).unwrap();
2195 if as_src {
2196 let spec = src_lookup_spec_for(&def);
2197 idx.src_side.insert(&spec, n, &cur_getter);
2198 }
2199 if as_dst {
2200 let spec = candidate_spec_for(&def);
2201 idx.dst_side.insert(&spec, n, &cur_getter);
2202 }
2203 }
2204
2205 self.maybe_queue_ivf_rebuild(&rule_name, &def);
2206
2207 if let Some(k) = def.max_edges {
2209 let et = g.syms.intern(&def.edge_type);
2210 let affected_srcs_for_n_dst: BTreeSet<u32> = if as_dst {
2211 let rid = self.rule_intern.get(&def.name).copied();
2212 self.by_node
2213 .get(&n)
2214 .into_iter()
2215 .flatten()
2216 .filter(|(r, t, _s, d)| Some(*r) == rid && *t == et && *d == n)
2217 .map(|(_, _, s, _)| *s)
2218 .collect()
2219 } else {
2220 BTreeSet::new()
2221 };
2222
2223 let mut prov = ProvSets {
2224 set: self.provenance.entry(rule_name.clone()).or_default(),
2225 owned: &mut self.owned,
2226 by_node: &mut self.by_node,
2227 rule_intern: &mut self.rule_intern,
2228 intern_rule: &mut self.intern_rule,
2229 deltas: &mut self.pending_deltas,
2230 emit: self.emit_deltas,
2231 };
2232
2233 if as_src {
2234 let desired_n_src =
2235 compute_desired(&def, &self.indexes[&rule_name], n, true, g);
2236 let top_k = filter_src_top_k(desired_n_src, k, g.ids);
2237 apply_per_src_top_k(&def, n, top_k, &mut prov, g);
2238 }
2239
2240 if as_dst {
2241 let new_desired =
2242 compute_desired(&def, &self.indexes[&rule_name], n, false, g);
2243 let new_srcs: BTreeSet<u32> = new_desired.keys().map(|(s, _)| *s).collect();
2244 let affected_srcs: BTreeSet<u32> =
2245 affected_srcs_for_n_dst.union(&new_srcs).copied().collect();
2246 for src in affected_srcs {
2247 if src == n {
2248 continue;
2249 }
2250 let desired_src =
2251 compute_desired(&def, &self.indexes[&rule_name], src, true, g);
2252 let top_k = filter_src_top_k(desired_src, k, g.ids);
2253 apply_per_src_top_k(&def, src, top_k, &mut prov, g);
2254 }
2255 }
2256 } else {
2257 let mut desired = BTreeMap::new();
2258 if as_src {
2259 desired.extend(compute_desired(
2260 &def,
2261 &self.indexes[&rule_name],
2262 n,
2263 true,
2264 g,
2265 ));
2266 }
2267 if as_dst {
2268 desired.extend(compute_desired(
2269 &def,
2270 &self.indexes[&rule_name],
2271 n,
2272 false,
2273 g,
2274 ));
2275 }
2276 let tripped = self.tripped.entry(rule_name.clone()).or_default();
2277 apply_desired(
2278 &def,
2279 desired,
2280 Some(n),
2281 &mut ProvSets {
2282 set: self.provenance.entry(rule_name).or_default(),
2283 owned: &mut self.owned,
2284 by_node: &mut self.by_node,
2285 rule_intern: &mut self.rule_intern,
2286 intern_rule: &mut self.intern_rule,
2287 deltas: &mut self.pending_deltas,
2288 emit: self.emit_deltas,
2289 },
2290 tripped,
2291 g,
2292 );
2293 }
2294 }
2295 }
2296 }
2297
2298 fn on_node_changed_via(
2313 &mut self,
2314 rule_name: &str,
2315 def: &RuleDef,
2316 n: u32,
2317 n_label: Option<u32>,
2318 changed: Option<(&str, Option<Value>)>,
2319 g: &mut GraphMut<'_>,
2320 ) {
2321 let src_sym = g.syms.get(&def.src_label);
2322 let dst_sym = g.syms.get(&def.dst_label);
2323 let via_sym = def.via_label.as_deref().and_then(|l| g.syms.get(l));
2324
2325 let as_src = src_sym.is_some() && n_label == src_sym;
2326 let as_dst = dst_sym.is_some() && n_label == dst_sym;
2327 let as_via = via_sym.is_some() && n_label == via_sym;
2328
2329 let fires = match changed {
2333 None => as_src || as_via || as_dst,
2334 Some((field, _)) => {
2335 let wf = def.watched_fields();
2336 (wf.contains(field)) && (as_src || as_via || as_dst)
2337 }
2338 };
2339 if !fires {
2340 return;
2341 }
2342 *self.fires.entry(rule_name.to_string()).or_default() += 1;
2343
2344 let mut affected_srcs: BTreeSet<u32> = BTreeSet::new();
2346 if as_src {
2347 affected_srcs.insert(n);
2348 }
2349 if as_via {
2350 let via_edge_str = def.via_edge.as_deref().unwrap();
2352 let via_dir = def.via_dir.unwrap_or(core_storage::Direction::Out);
2353 let rev_dir = match via_dir {
2354 core_storage::Direction::Out => core_storage::Direction::In,
2355 core_storage::Direction::In => core_storage::Direction::Out,
2356 };
2357 if let (Some(via_etype), Some(s_sym)) = (g.syms.get(via_edge_str), src_sym) {
2358 for &src in g.topo.neighbors(via_etype, rev_dir, n).as_ref() {
2359 if g.labels.get(src as usize).copied() == Some(s_sym) {
2360 affected_srcs.insert(src);
2361 }
2362 }
2363 }
2364 }
2365 if as_dst {
2366 let desired_touching_n = compute_desired_via(def, ViaAnchor::Dst(n), g);
2368 for (src, _dst) in desired_touching_n.keys() {
2369 affected_srcs.insert(*src);
2370 }
2371 let et = g.syms.intern(&def.edge_type);
2373 let rid = self.rule_intern.get(rule_name).copied();
2374 let old_srcs: Vec<u32> = self
2375 .by_node
2376 .get(&n)
2377 .into_iter()
2378 .flatten()
2379 .filter(|(r, t, _s, d)| Some(*r) == rid && *t == et && *d == n)
2380 .map(|(_, _, s, _)| *s)
2381 .collect();
2382 affected_srcs.extend(old_srcs);
2383 }
2384
2385 let affected_srcs: Vec<u32> = affected_srcs.into_iter().collect();
2387
2388 if let Some(k) = def.max_edges {
2389 let mut prov = ProvSets {
2390 set: self.provenance.entry(rule_name.to_string()).or_default(),
2391 owned: &mut self.owned,
2392 by_node: &mut self.by_node,
2393 rule_intern: &mut self.rule_intern,
2394 intern_rule: &mut self.intern_rule,
2395 deltas: &mut self.pending_deltas,
2396 emit: self.emit_deltas,
2397 };
2398 for src in affected_srcs {
2399 let desired_src = compute_desired_via(def, ViaAnchor::Src(src), g);
2400 let top_k = filter_src_top_k(desired_src, k, g.ids);
2401 apply_per_src_top_k(def, src, top_k, &mut prov, g);
2402 }
2403 } else {
2404 let tripped = self.tripped.entry(rule_name.to_string()).or_default();
2405 let budget = edge_budget(def);
2406 for src in affected_srcs {
2409 let desired_src = compute_desired_via(def, ViaAnchor::Src(src), g);
2410 if !*tripped {
2411 let mut prov = ProvSets {
2412 set: self.provenance.entry(rule_name.to_string()).or_default(),
2413 owned: &mut self.owned,
2414 by_node: &mut self.by_node,
2415 rule_intern: &mut self.rule_intern,
2416 intern_rule: &mut self.intern_rule,
2417 deltas: &mut self.pending_deltas,
2418 emit: self.emit_deltas,
2419 };
2420 apply_desired(def, desired_src, Some(src), &mut prov, tripped, g);
2421 }
2422 let _ = budget;
2426 }
2427 }
2428 }
2429
2430 pub fn on_edge_changed(
2443 &mut self,
2444 etype_str: &str,
2445 src_id: u32,
2446 dst_id: u32,
2447 g: &mut GraphMut<'_>,
2448 ) {
2449 self.ensure_provenance_loaded_mut();
2451 if !self.indexes_populated && !self.rules.is_empty() {
2454 let hnsw = std::mem::take(
2455 &mut *self
2456 .retained_hnsw_blobs
2457 .lock()
2458 .expect("retained_hnsw_blobs lock poisoned"),
2459 );
2460 let ivf_bytes = self
2461 .retained_ivf_bytes
2462 .lock()
2463 .expect("retained_ivf_bytes lock poisoned")
2464 .take()
2465 .unwrap_or_default();
2466 let ivf = decode_ivf_bytes_to_export(&ivf_bytes);
2467 self.reindex_all_load_ivf(g.ids, g.syms, g.labels, g.props, ivf);
2468 self.load_hnsw_state(hnsw);
2469 }
2470
2471 let rule_names: Vec<String> = self.rules.keys().cloned().collect();
2472 for rule_name in rule_names {
2473 let def = self.rules[&rule_name].clone();
2474 let Some(ref via_edge) = def.via_edge else {
2475 continue; };
2477 if via_edge != etype_str {
2478 continue; }
2480
2481 let src_sym = match g.syms.get(&def.src_label) {
2483 Some(s) => s,
2484 None => continue,
2485 };
2486 let via_sym = match def.via_label.as_deref().and_then(|l| g.syms.get(l)) {
2487 Some(s) => s,
2488 None => continue,
2489 };
2490 let via_dir = def.via_dir.unwrap_or(core_storage::Direction::Out);
2494 let (rule_src, rule_via) = match via_dir {
2495 core_storage::Direction::Out => (src_id, dst_id),
2496 core_storage::Direction::In => (dst_id, src_id),
2497 };
2498
2499 if g.labels.get(rule_src as usize).copied() != Some(src_sym) {
2500 continue;
2501 }
2502 if g.labels.get(rule_via as usize).copied() != Some(via_sym) {
2503 continue;
2504 }
2505
2506 *self.fires.entry(rule_name.clone()).or_default() += 1;
2508 let desired_src = compute_desired_via(&def, ViaAnchor::Src(rule_src), g);
2509
2510 if let Some(k) = def.max_edges {
2511 let mut prov = ProvSets {
2512 set: self.provenance.entry(rule_name).or_default(),
2513 owned: &mut self.owned,
2514 by_node: &mut self.by_node,
2515 rule_intern: &mut self.rule_intern,
2516 intern_rule: &mut self.intern_rule,
2517 deltas: &mut self.pending_deltas,
2518 emit: self.emit_deltas,
2519 };
2520 let top_k = filter_src_top_k(desired_src, k, g.ids);
2521 apply_per_src_top_k(&def, rule_src, top_k, &mut prov, g);
2522 } else {
2523 let tripped = self.tripped.entry(rule_name.clone()).or_default();
2524 let mut prov = ProvSets {
2525 set: self.provenance.entry(rule_name).or_default(),
2526 owned: &mut self.owned,
2527 by_node: &mut self.by_node,
2528 rule_intern: &mut self.rule_intern,
2529 intern_rule: &mut self.intern_rule,
2530 deltas: &mut self.pending_deltas,
2531 emit: self.emit_deltas,
2532 };
2533 apply_desired(&def, desired_src, Some(rule_src), &mut prov, tripped, g);
2534 }
2535 }
2536 }
2537
2538 pub fn on_node_removed(&mut self, n: u32, g: &mut GraphMut<'_>) {
2546 self.ensure_provenance_loaded_mut();
2548 if !self.indexes_populated && !self.rules.is_empty() {
2553 let hnsw = std::mem::take(
2554 &mut *self
2555 .retained_hnsw_blobs
2556 .lock()
2557 .expect("retained_hnsw_blobs lock poisoned"),
2558 );
2559 let ivf_bytes = self
2560 .retained_ivf_bytes
2561 .lock()
2562 .expect("retained_ivf_bytes lock poisoned")
2563 .take()
2564 .unwrap_or_default();
2565 let ivf = decode_ivf_bytes_to_export(&ivf_bytes);
2566 self.reindex_all_load_ivf(g.ids, g.syms, g.labels, g.props, ivf);
2567 self.load_hnsw_state(hnsw);
2568 }
2569
2570 let n_label = g.labels.get(n as usize).copied();
2571 let rule_names: Vec<String> = self.rules.keys().cloned().collect();
2572
2573 for rule_name in rule_names {
2574 let def = self.rules[&rule_name].clone();
2575 let src_sym = g.syms.get(&def.src_label);
2576 let dst_sym = g.syms.get(&def.dst_label);
2577 let as_src = src_sym.is_some() && n_label == src_sym;
2578 let as_dst = dst_sym.is_some() && n_label == dst_sym;
2579
2580 {
2581 let cur_getter = |f: &str| g.props.get(n, f).map(|vr| vr.into_value());
2582 let idx = self.indexes.get_mut(&rule_name).unwrap();
2583 if as_src {
2584 let spec = src_lookup_spec_for(&def);
2585 idx.src_side.remove(&spec, n, &cur_getter);
2586 }
2587 if as_dst {
2588 let spec = candidate_spec_for(&def);
2589 idx.dst_side.remove(&spec, n, &cur_getter);
2590 }
2591 }
2592
2593 self.maybe_queue_ivf_rebuild(&rule_name, &def);
2594 }
2595
2596 let touching: Vec<(String, Triple)> = self
2597 .by_node
2598 .get(&n)
2599 .into_iter()
2600 .flatten()
2601 .map(|&(rid, t, s, d)| (self.intern_rule[rid as usize].clone(), (t, s, d)))
2602 .collect();
2603
2604 let topk_backfill: Vec<(String, u32)> = touching
2608 .iter()
2609 .filter_map(|(rule_name, triple)| {
2610 let &(_, s, d) = triple;
2611 let def = self.rules.get(rule_name)?;
2612 def.max_edges?; if d == n && s != n {
2614 Some((rule_name.clone(), s))
2615 } else {
2616 None
2617 }
2618 })
2619 .collect();
2620
2621 for (rule_name, triple) in touching {
2622 let (t, s, d) = triple;
2623 g.topo.remove_edge(t, s, d);
2624 g.edge_props.remove_edge(t, s, d);
2625 if let Some(set) = self.provenance.get_mut(&rule_name) {
2626 ProvSets {
2627 set,
2628 owned: &mut self.owned,
2629 by_node: &mut self.by_node,
2630 rule_intern: &mut self.rule_intern,
2631 intern_rule: &mut self.intern_rule,
2632 deltas: &mut self.pending_deltas,
2633 emit: self.emit_deltas,
2634 }
2635 .remove(&rule_name, triple, g.ids, g.syms);
2636 }
2637 }
2638
2639 for (rule_name, src) in topk_backfill {
2644 let def = self.rules[&rule_name].clone();
2645 let k = def.max_edges.unwrap(); let desired_src = compute_desired(&def, &self.indexes[&rule_name], src, true, g);
2647 let top_k = filter_src_top_k(desired_src, k, g.ids);
2648 let mut prov = ProvSets {
2649 set: self.provenance.entry(rule_name.clone()).or_default(),
2650 owned: &mut self.owned,
2651 by_node: &mut self.by_node,
2652 rule_intern: &mut self.rule_intern,
2653 intern_rule: &mut self.intern_rule,
2654 deltas: &mut self.pending_deltas,
2655 emit: self.emit_deltas,
2656 };
2657 apply_per_src_top_k(&def, src, top_k, &mut prov, g);
2658 }
2659 }
2660
2661 pub fn rebuild(&mut self, name: &str, g: &mut GraphMut<'_>) -> Result<(), String> {
2669 if !self.rules.contains_key(name) {
2670 return Err(format!("rule {:?} not found", name));
2671 }
2672 self.rebuild_needed.remove(name);
2673 let def = self.rules[name].clone();
2674
2675 *self.indexes.get_mut(name).unwrap() = RuleIndex::default();
2677
2678 if def.approximate {
2680 let idx = self.indexes.get_mut(name).unwrap();
2681 idx.src_side.init_hnsw(name);
2682 idx.dst_side.init_hnsw(name);
2683 }
2684
2685 let n_total = g.ids.len() as u32;
2686 for id in 0..n_total {
2687 let label_sym = match g.labels.get(id as usize).copied() {
2688 Some(s) if s != u32::MAX => s,
2689 _ => continue,
2690 };
2691 let idx = self.indexes.get_mut(name).unwrap();
2692 index_node_for_rule(id, label_sym, &def, idx, g.syms, g.props);
2693 }
2694
2695 if def.approximate {
2698 let idx = self.indexes.get_mut(name).unwrap();
2699 idx.src_side.fit_ivf_clusters(name);
2700 idx.dst_side.fit_ivf_clusters(name);
2701 }
2702
2703 let mut prov = ProvSets {
2707 set: self.provenance.get_mut(name).unwrap(),
2708 owned: &mut self.owned,
2709 by_node: &mut self.by_node,
2710 rule_intern: &mut self.rule_intern,
2711 intern_rule: &mut self.intern_rule,
2712 deltas: &mut self.pending_deltas,
2713 emit: self.emit_deltas,
2714 };
2715 if let Some(k) = def.max_edges {
2716 apply_streaming_rebuild_top_k(&def, k, &self.indexes[name], &mut prov, g);
2717 } else {
2718 let tripped = self.tripped.get_mut(name).unwrap();
2719 apply_streaming_rebuild(&def, &self.indexes[name], &mut prov, tripped, g);
2720 }
2721 let fires = self.fires.entry(name.to_string()).or_default();
2722 bump_fires_for_participants(&def, g, fires);
2723
2724 Ok(())
2725 }
2726
2727 #[cfg(test)]
2728 fn by_node_consistent(&self) -> bool {
2729 let (rebuilt, intern, names) = rebuild_by_node(&self.provenance);
2730 resolve_by_node(&self.by_node, &self.intern_rule) == resolve_by_node(&rebuilt, &names)
2731 && intern.len() == names.len()
2732 }
2733}
2734
2735#[cfg(test)]
2740mod tests {
2741 use super::*;
2742 use crate::def::{evaluate, NodeView, Predicate, RuleDef};
2743 use core_storage::{ColumnStore, Direction, EdgeProps, IdMap, Interner, Topology, Value};
2744
2745 struct Fx {
2746 ids: IdMap,
2747 syms: Interner,
2748 labels: Vec<u32>,
2749 props: ColumnStore,
2750 topo: Topology,
2751 eprops: EdgeProps,
2752 }
2753 impl Fx {
2754 fn new() -> Self {
2755 Fx {
2756 ids: IdMap::new(),
2757 syms: Interner::new(),
2758 labels: vec![],
2759 props: ColumnStore::new(),
2760 topo: Topology::new(),
2761 eprops: EdgeProps::new(),
2762 }
2763 }
2764 fn add(&mut self, label: &str, key: &str, props: Vec<(&str, Value)>) -> u32 {
2765 let id = self.ids.get_or_insert(key);
2766 let sym = self.syms.intern(label);
2767 self.labels.resize(id as usize + 1, u32::MAX);
2768 self.labels[id as usize] = sym;
2769 for (f, v) in props {
2770 self.props.set(id, f, v);
2771 }
2772 id
2773 }
2774 fn g(&mut self) -> GraphMut<'_> {
2775 GraphMut {
2776 ids: &self.ids,
2777 syms: &mut self.syms,
2778 labels: &self.labels,
2779 props: ColumnsView::owned(&self.props),
2780 topo: &mut self.topo,
2781 edge_props: &mut self.eprops,
2782 }
2783 }
2784 }
2785
2786 fn tags(items: &[&str]) -> Value {
2787 Value::List(items.iter().map(|s| Value::Str((*s).into())).collect())
2788 }
2789
2790 fn overlap_rule() -> RuleDef {
2791 RuleDef {
2792 name: "rel".into(),
2793 src_label: "A".into(),
2794 dst_label: "A".into(),
2795 predicate: Predicate::Overlap {
2796 field: "tags".into(),
2797 min: 0.4,
2798 },
2799 edge_type: "REL".into(),
2800 weight_prop: Some("score".into()),
2801 max_edges: None,
2802 approximate: false,
2803 via_label: None,
2804 via_edge: None,
2805 via_dir: None,
2806 }
2807 }
2808
2809 fn emb(xs: &[f64]) -> Value {
2810 Value::List(xs.iter().copied().map(Value::Float).collect())
2811 }
2812
2813 fn approx_vec_rule() -> RuleDef {
2814 RuleDef {
2815 name: "sim".into(),
2816 src_label: "V".into(),
2817 dst_label: "V".into(),
2818 predicate: Predicate::VectorSimilar {
2819 field: "emb".into(),
2820 min: 0.5,
2821 },
2822 edge_type: "SIM".into(),
2823 weight_prop: None,
2824 max_edges: None,
2825 approximate: true,
2826 via_label: None,
2827 via_edge: None,
2828 via_dir: None,
2829 }
2830 }
2831
2832 #[test]
2833 fn approximate_rule_rebuilds_after_drift_threshold() {
2834 with_ivf_drift_rebuild(1, || {
2835 let mut fx = Fx::new();
2836 let mut ids = Vec::new();
2837 for i in 0..6 {
2838 let x = i as f64 * 0.2;
2839 ids.push(fx.add("V", &format!("v{i}"), vec![("emb", emb(&[x, 1.0 - x]))]));
2840 }
2841 let mut eng = RuleEngine::new();
2842 {
2843 let mut g = fx.g();
2844 eng.create_rule(approx_vec_rule(), &mut g).unwrap();
2845 }
2846 assert!(eng.take_rebuild_needed().is_empty());
2847 {
2848 let mut g = fx.g();
2849 eng.on_node_removed(ids[0], &mut g);
2850 }
2851 assert!(
2852 eng.take_rebuild_needed().is_empty(),
2853 "drift=1 is not > threshold 1"
2854 );
2855 {
2856 let mut g = fx.g();
2857 eng.on_node_removed(ids[1], &mut g);
2858 }
2859 assert_eq!(eng.take_rebuild_needed(), vec!["sim".to_string()]);
2860 {
2861 let mut g = fx.g();
2862 eng.rebuild("sim", &mut g).unwrap();
2863 }
2864 assert!(
2865 eng.take_rebuild_needed().is_empty(),
2866 "rebuild must reset drift and not re-queue itself"
2867 );
2868 let drift = eng
2869 .export_ivf_state()
2870 .get("sim")
2871 .map(|(_, dst)| dst.2)
2872 .unwrap();
2873 assert_eq!(drift, 0, "rebuild resets dst-side IVF drift");
2874 });
2875 }
2876
2877 #[test]
2878 fn backfill_creates_edges_with_scores_and_delete_removes_exactly_them() {
2879 let mut fx = Fx::new();
2880 let a = fx.add("A", "a", vec![("tags", tags(&["x", "y"]))]);
2881 let b = fx.add("A", "b", vec![("tags", tags(&["x", "y"]))]);
2882 let _c = fx.add("A", "c", vec![("tags", tags(&["q"]))]);
2883 let et = fx.syms.intern("REL");
2885 fx.topo.add_edge(et, a, b);
2886 let mut eng = RuleEngine::new();
2887 let mut g = fx.g();
2888 eng.create_rule(overlap_rule(), &mut g).unwrap();
2889 assert!(g.topo.neighbors(et, Direction::Out, b).contains(&a));
2891 assert_eq!(
2892 g.edge_props.get(et, b, a, "score"),
2893 Some(&Value::Float(1.0))
2894 );
2895 assert!(!eng.is_owned(et, a, b));
2896 assert!(eng.is_owned(et, b, a));
2897 eng.delete_rule("rel", &mut g).unwrap();
2898 assert!(g.topo.neighbors(et, Direction::Out, a).contains(&b)); assert!(!g.topo.neighbors(et, Direction::Out, b).contains(&a)); assert_eq!(g.edge_props.get(et, b, a, "score"), None);
2901 }
2902
2903 #[test]
2904 fn incremental_update_adds_and_removes_edges() {
2905 let mut fx = Fx::new();
2906 let a = fx.add("A", "a", vec![("tags", tags(&["x", "y"]))]);
2907 let b = fx.add("A", "b", vec![("tags", tags(&["y", "z"]))]);
2908 let et = fx.syms.intern("REL");
2909 let mut eng = RuleEngine::new();
2910 {
2911 let mut g = fx.g();
2912 eng.create_rule(overlap_rule(), &mut g).unwrap(); assert_eq!(g.topo.edge_count(), 0);
2914 }
2915 let old = fx.props.get(b, "tags").cloned();
2917 fx.props.set(b, "tags", tags(&["x", "y"]));
2918 {
2919 let mut g = fx.g();
2920 eng.on_node_changed(b, Some(("tags", old)), &mut g);
2921 assert!(g.topo.neighbors(et, Direction::Out, a).contains(&b));
2922 assert!(g.topo.neighbors(et, Direction::Out, b).contains(&a));
2923 }
2924 let old = fx.props.get(b, "tags").cloned();
2926 fx.props.set(b, "tags", tags(&["qqq"]));
2927 let mut g = fx.g();
2928 eng.on_node_changed(b, Some(("tags", old)), &mut g);
2929 assert_eq!(g.topo.edge_count(), 0);
2930 assert_eq!(g.edge_props.get(et, a, b, "score"), None);
2931 }
2932
2933 #[test]
2934 fn key_match_new_node_links_and_rebuild_is_noop() {
2935 let mut fx = Fx::new();
2936 fx.add("C", "c1", vec![]);
2937 let mut eng = RuleEngine::new();
2938 {
2939 let mut g = fx.g();
2940 eng.create_rule(
2941 RuleDef {
2942 name: "fk".into(),
2943 src_label: "T".into(),
2944 dst_label: "C".into(),
2945 predicate: Predicate::KeyMatch {
2946 field: "cid".into(),
2947 },
2948 edge_type: "AT".into(),
2949 weight_prop: None,
2950 max_edges: None,
2951 approximate: false,
2952 via_label: None,
2953 via_edge: None,
2954 via_dir: None,
2955 },
2956 &mut g,
2957 )
2958 .unwrap();
2959 }
2960 let t = fx.add("T", "t1", vec![("cid", Value::Str("c1".into()))]);
2961 let (at, c1, count_before) = {
2962 let mut g = fx.g();
2963 eng.on_node_changed(t, None, &mut g);
2964 let at = g.syms.get("AT").unwrap();
2965 let c1 = g.ids.get("c1").unwrap();
2966 assert!(g.topo.neighbors(at, Direction::Out, t).contains(&c1));
2967 (at, c1, g.topo.edge_count())
2968 };
2969 let mut g = fx.g();
2970 eng.rebuild("fk", &mut g).unwrap();
2971 assert_eq!(g.topo.edge_count(), count_before); assert!(g.topo.neighbors(at, Direction::Out, t).contains(&c1));
2973 }
2974
2975 #[test]
2976 fn score_refresh_on_persisting_owned_edge() {
2977 let mut fx = Fx::new();
2980 let a = fx.add("A", "a", vec![("tags", tags(&["x", "y", "z"]))]);
2981 let b = fx.add("A", "b", vec![("tags", tags(&["x", "y", "q"]))]);
2982 let et = fx.syms.intern("SIM");
2983 let mut eng = RuleEngine::new();
2984 {
2985 let mut g = fx.g();
2986 eng.create_rule(
2987 RuleDef {
2988 name: "sim".into(),
2989 src_label: "A".into(),
2990 dst_label: "A".into(),
2991 predicate: Predicate::Overlap {
2992 field: "tags".into(),
2993 min: 0.2,
2994 },
2995 edge_type: "SIM".into(),
2996 weight_prop: Some("score".into()),
2997 max_edges: None,
2998 approximate: false,
2999 via_label: None,
3000 via_edge: None,
3001 via_dir: None,
3002 },
3003 &mut g,
3004 )
3005 .unwrap();
3006 assert!(g.topo.neighbors(et, Direction::Out, a).contains(&b));
3008 assert!(g.topo.neighbors(et, Direction::Out, b).contains(&a));
3009 assert!(eng.is_owned(et, a, b) || eng.is_owned(et, b, a));
3010 let check = |v: Option<&Value>| {
3011 if let Some(Value::Float(f)) = v {
3012 assert!(
3013 (f - 0.5).abs() < 1e-9,
3014 "initial score should be 0.5, got {f}"
3015 );
3016 }
3017 };
3018 check(g.edge_props.get(et, a, b, "score"));
3019 check(g.edge_props.get(et, b, a, "score"));
3020 }
3021 let old = fx.props.get(b, "tags").cloned();
3023 fx.props.set(b, "tags", tags(&["x", "y", "z"]));
3024 {
3025 let mut g = fx.g();
3026 eng.on_node_changed(b, Some(("tags", old)), &mut g);
3027 assert!(g.topo.neighbors(et, Direction::Out, a).contains(&b));
3029 assert!(g.topo.neighbors(et, Direction::Out, b).contains(&a));
3030 assert_eq!(
3032 g.edge_props.get(et, a, b, "score"),
3033 Some(&Value::Float(1.0)),
3034 "score on a→b must refresh to 1.0"
3035 );
3036 assert_eq!(
3037 g.edge_props.get(et, b, a, "score"),
3038 Some(&Value::Float(1.0)),
3039 "score on b→a must refresh to 1.0"
3040 );
3041 }
3042 }
3043
3044 #[test]
3045 fn dst_side_keymatch_links_when_c_node_inserted_after_t() {
3046 let mut fx = Fx::new();
3048 let t = fx.add("T", "t1", vec![("cid", Value::Str("c9".into()))]);
3050 let mut eng = RuleEngine::new();
3051 {
3052 let mut g = fx.g();
3053 eng.create_rule(
3054 RuleDef {
3055 name: "fk".into(),
3056 src_label: "T".into(),
3057 dst_label: "C".into(),
3058 predicate: Predicate::KeyMatch {
3059 field: "cid".into(),
3060 },
3061 edge_type: "AT".into(),
3062 weight_prop: None,
3063 max_edges: None,
3064 approximate: false,
3065 via_label: None,
3066 via_edge: None,
3067 via_dir: None,
3068 },
3069 &mut g,
3070 )
3071 .unwrap();
3072 let at = g.syms.intern("AT");
3074 assert_eq!(g.topo.edge_count(), 0, "no C node yet → no edge");
3075 let _ = at;
3077 }
3078 let c9 = fx.add("C", "c9", vec![]);
3080 {
3081 let mut g = fx.g();
3082 eng.on_node_changed(c9, None, &mut g);
3083 let at = g.syms.get("AT").unwrap();
3084 assert!(
3086 g.topo.neighbors(at, Direction::Out, t).contains(&c9),
3087 "T→C edge must appear when C node is inserted"
3088 );
3089 assert!(eng.is_owned(at, t, c9));
3090 }
3091 }
3092
3093 #[test]
3094 fn on_node_removed_retracts_both_sides_and_deindexes() {
3095 let mut fx = Fx::new();
3096 let a = fx.add("A", "a", vec![("tags", tags(&["x", "y"]))]);
3097 let b = fx.add("A", "b", vec![("tags", tags(&["x", "y"]))]);
3098 let et = fx.syms.intern("REL");
3099 let mut eng = RuleEngine::new();
3100 {
3101 let mut g = fx.g();
3102 eng.create_rule(overlap_rule(), &mut g).unwrap();
3103 assert!(g.topo.neighbors(et, Direction::Out, a).contains(&b));
3104 assert!(g.topo.neighbors(et, Direction::Out, b).contains(&a));
3105 }
3106 {
3107 let mut g = fx.g();
3108 eng.on_node_removed(a, &mut g);
3109 assert!(!g.topo.neighbors(et, Direction::Out, a).contains(&b));
3110 assert!(!g.topo.neighbors(et, Direction::Out, b).contains(&a));
3111 assert_eq!(g.edge_props.get(et, a, b, "score"), None);
3112 assert_eq!(g.edge_props.get(et, b, a, "score"), None);
3113 assert!(!eng.is_owned(et, a, b));
3114 assert!(!eng.is_owned(et, b, a));
3115 }
3116 let c = fx.add("A", "c", vec![("tags", tags(&["x", "y"]))]);
3118 {
3119 let mut g = fx.g();
3120 eng.on_node_changed(c, None, &mut g);
3121 assert!(g.topo.neighbors(et, Direction::Out, b).contains(&c));
3122 assert!(g.topo.neighbors(et, Direction::Out, c).contains(&b));
3123 assert!(!g.topo.neighbors(et, Direction::Out, c).contains(&a));
3124 assert!(!g.topo.neighbors(et, Direction::Out, a).contains(&c));
3125 }
3126 {
3128 let mut g = fx.g();
3129 eng.on_node_removed(a, &mut g);
3130 assert!(g.topo.neighbors(et, Direction::Out, b).contains(&c));
3131 }
3132 }
3133
3134 #[test]
3135 fn duplicate_name_and_unknown_delete_error() {
3136 let mut fx = Fx::new();
3137 let mut eng = RuleEngine::new();
3138 let mut g = fx.g();
3139 eng.create_rule(overlap_rule(), &mut g).unwrap();
3140 assert!(eng.create_rule(overlap_rule(), &mut g).is_err());
3141 assert!(eng.delete_rule("nope", &mut g).is_err());
3142 }
3143
3144 #[test]
3150 fn coowned_edge_type_survives_first_delete_gone_after_second() {
3151 let mut fx = Fx::new();
3152 let a = fx.add("A", "a", vec![("tags", tags(&["x", "y"]))]);
3153 let b = fx.add("A", "b", vec![("tags", tags(&["x", "y"]))]);
3154 let mut eng = RuleEngine::new();
3155 {
3156 let mut g = fx.g();
3157 eng.create_rule(
3159 RuleDef {
3160 name: "r1".into(),
3161 src_label: "A".into(),
3162 dst_label: "A".into(),
3163 predicate: Predicate::Overlap {
3164 field: "tags".into(),
3165 min: 0.1,
3166 },
3167 edge_type: "REL2".into(),
3168 weight_prop: None,
3169 max_edges: None,
3170 approximate: false,
3171 via_label: None,
3172 via_edge: None,
3173 via_dir: None,
3174 },
3175 &mut g,
3176 )
3177 .unwrap();
3178 eng.create_rule(
3180 RuleDef {
3181 name: "r2".into(),
3182 src_label: "A".into(),
3183 dst_label: "A".into(),
3184 predicate: Predicate::Overlap {
3185 field: "tags".into(),
3186 min: 0.2,
3187 },
3188 edge_type: "REL2".into(),
3189 weight_prop: None,
3190 max_edges: None,
3191 approximate: false,
3192 via_label: None,
3193 via_edge: None,
3194 via_dir: None,
3195 },
3196 &mut g,
3197 )
3198 .unwrap();
3199
3200 let et = g.syms.intern("REL2");
3201 assert!(
3203 g.topo.neighbors(et, Direction::Out, a).contains(&b),
3204 "a→b must exist after both rules created"
3205 );
3206 assert!(
3207 g.topo.neighbors(et, Direction::Out, b).contains(&a),
3208 "b→a must exist after both rules created"
3209 );
3210
3211 eng.delete_rule("r1", &mut g).unwrap();
3213 assert!(
3214 g.topo.neighbors(et, Direction::Out, a).contains(&b),
3215 "a→b must survive R1 deletion (R2 rebuilds and claims it)"
3216 );
3217 assert!(
3218 g.topo.neighbors(et, Direction::Out, b).contains(&a),
3219 "b→a must survive R1 deletion (R2 rebuilds and claims it)"
3220 );
3221 assert!(
3223 eng.is_owned(et, a, b),
3224 "a→b must be owned by R2 after rebuild"
3225 );
3226 assert!(
3227 eng.is_owned(et, b, a),
3228 "b→a must be owned by R2 after rebuild"
3229 );
3230
3231 eng.delete_rule("r2", &mut g).unwrap();
3233 assert!(
3234 !g.topo.neighbors(et, Direction::Out, a).contains(&b),
3235 "a→b must be gone after both rules deleted"
3236 );
3237 assert!(
3238 !g.topo.neighbors(et, Direction::Out, b).contains(&a),
3239 "b→a must be gone after both rules deleted"
3240 );
3241 }
3242 }
3243
3244 fn topk_eq_rule(k: u64) -> RuleDef {
3246 RuleDef {
3247 name: "eq".into(),
3248 src_label: "N".into(),
3249 dst_label: "N".into(),
3250 predicate: Predicate::FieldEqual { field: "k".into() },
3251 edge_type: "EQ".into(),
3252 weight_prop: None,
3253 max_edges: Some(k),
3254 approximate: false,
3255 via_label: None,
3256 via_edge: None,
3257 via_dir: None,
3258 }
3259 }
3260
3261 fn prov_pairs(eng: &RuleEngine, name: &str) -> BTreeSet<(u32, u32)> {
3262 eng.provenance()
3263 .get(name)
3264 .map(|s| s.iter().map(|&(_, a, b)| (a, b)).collect())
3265 .unwrap_or_default()
3266 }
3267
3268 #[test]
3272 fn topk_k1_keeps_best_scored_dst() {
3273 let mut fx = Fx::new();
3274 let mut eng = RuleEngine::new();
3275 {
3276 let mut g = fx.g();
3277 eng.create_rule(topk_eq_rule(1), &mut g).unwrap();
3278 }
3279 let mut ids = Vec::new();
3281 for i in 0..4usize {
3282 let id = fx.add(
3283 "N",
3284 &format!("n{i}"),
3285 vec![("k", Value::Str("const".into()))],
3286 );
3287 ids.push(id);
3288 let mut g = fx.g();
3289 eng.on_node_changed(id, None, &mut g);
3290 }
3291 let et = fx.syms.get("EQ").unwrap();
3292 let expected_dsts = [ids[1], ids[0], ids[0], ids[0]];
3298 for (i, (&src, &expected_dst)) in ids.iter().zip(expected_dsts.iter()).enumerate() {
3299 let out: Vec<u32> = fx.topo.neighbors(et, Direction::Out, src).to_vec();
3300 assert_eq!(
3301 out,
3302 vec![expected_dst],
3303 "src n{i} should point only to the best dst"
3304 );
3305 }
3306 assert_eq!(eng.provenance()["eq"].len(), 4);
3307 assert!(!eng.is_tripped("eq"), "top-k rules never trip");
3308 }
3309
3310 #[test]
3313 fn topk_insert_evict() {
3314 let mut fx = Fx::new();
3318 let rule = RuleDef {
3319 name: "nw".into(),
3320 src_label: "S".into(),
3321 dst_label: "D".into(),
3322 predicate: Predicate::NumericWithin {
3323 field: "v".into(),
3324 tolerance: 10.0,
3325 },
3326 edge_type: "NEAR".into(),
3327 weight_prop: Some("score".into()),
3328 max_edges: Some(1),
3329 approximate: false,
3330 via_label: None,
3331 via_edge: None,
3332 via_dir: None,
3333 };
3334 let mut eng = RuleEngine::new();
3335 {
3336 let mut g = fx.g();
3337 eng.create_rule(rule, &mut g).unwrap();
3338 }
3339
3340 let s0 = fx.add("S", "s0", vec![("v", Value::Float(0.0))]);
3342 let d_far = fx.add("D", "d_far", vec![("v", Value::Float(9.0))]);
3344 {
3345 let mut g = fx.g();
3346 eng.on_node_changed(s0, None, &mut g);
3347 eng.on_node_changed(d_far, None, &mut g);
3348 }
3349 let et = fx.syms.get("NEAR").unwrap();
3350 assert!(fx.topo.neighbors(et, Direction::Out, s0).contains(&d_far));
3352 assert_eq!(eng.provenance()["nw"].len(), 1);
3353
3354 let d_close = fx.add("D", "d_close", vec![("v", Value::Float(1.0))]);
3356 {
3357 let mut g = fx.g();
3358 eng.on_node_changed(d_close, None, &mut g);
3359 }
3360 let out: Vec<u32> = fx.topo.neighbors(et, Direction::Out, s0).to_vec();
3362 assert_eq!(out, vec![d_close], "d_close should evict d_far");
3363 assert!(!fx.topo.neighbors(et, Direction::Out, s0).contains(&d_far));
3364 assert_eq!(eng.provenance()["nw"].len(), 1);
3365 assert!(eng.by_node_consistent());
3366 }
3367
3368 #[test]
3370 fn topk_retract_backfill() {
3371 let mut fx = Fx::new();
3372 let rule = RuleDef {
3373 name: "nw".into(),
3374 src_label: "S".into(),
3375 dst_label: "D".into(),
3376 predicate: Predicate::NumericWithin {
3377 field: "v".into(),
3378 tolerance: 10.0,
3379 },
3380 edge_type: "NEAR".into(),
3381 weight_prop: Some("score".into()),
3382 max_edges: Some(1),
3383 approximate: false,
3384 via_label: None,
3385 via_edge: None,
3386 via_dir: None,
3387 };
3388 let mut eng = RuleEngine::new();
3389
3390 let s0 = fx.add("S", "s0", vec![("v", Value::Float(0.0))]);
3391 let d_close = fx.add("D", "d_close", vec![("v", Value::Float(1.0))]); let d_far = fx.add("D", "d_far", vec![("v", Value::Float(8.0))]); {
3394 let mut g = fx.g();
3395 eng.create_rule(rule, &mut g).unwrap();
3396 }
3397 let et = fx.syms.get("NEAR").unwrap();
3398 assert!(fx.topo.neighbors(et, Direction::Out, s0).contains(&d_close));
3400 assert!(!fx.topo.neighbors(et, Direction::Out, s0).contains(&d_far));
3401 assert_eq!(eng.provenance()["nw"].len(), 1);
3402
3403 let old = fx.props.get(d_close, "v").cloned();
3405 fx.props.set(d_close, "v", Value::Float(50.0));
3406 {
3407 let mut g = fx.g();
3408 eng.on_node_changed(d_close, Some(("v", old)), &mut g);
3409 }
3410 assert!(!fx.topo.neighbors(et, Direction::Out, s0).contains(&d_close));
3412 assert!(
3413 fx.topo.neighbors(et, Direction::Out, s0).contains(&d_far),
3414 "d_far should backfill after d_close retracted"
3415 );
3416 assert_eq!(eng.provenance()["nw"].len(), 1);
3417 assert!(eng.by_node_consistent());
3418 }
3419
3420 #[test]
3422 fn topk_tie_broken_by_dst_key() {
3423 let mut fx = Fx::new();
3425 let mut eng = RuleEngine::new();
3426 {
3427 let mut g = fx.g();
3428 eng.create_rule(topk_eq_rule(2), &mut g).unwrap();
3429 }
3430 for name in ["a", "b", "c", "d", "e"] {
3433 let id = fx.add("N", name, vec![("k", Value::Str("x".into()))]);
3434 let mut g = fx.g();
3435 eng.on_node_changed(id, None, &mut g);
3436 }
3437 let et = fx.syms.get("EQ").unwrap();
3438 let get_id = |key: &str| fx.ids.get(key).unwrap();
3439 let a = get_id("a");
3441 let b = get_id("b");
3442 let c = get_id("c");
3443 let out_a: BTreeSet<u32> = fx
3444 .topo
3445 .neighbors(et, Direction::Out, a)
3446 .iter()
3447 .copied()
3448 .collect();
3449 assert!(out_a.contains(&b), "a→b (b is best key after a)");
3450 assert!(out_a.contains(&c), "a→c (c is 2nd best key)");
3451 assert_eq!(out_a.len(), 2);
3452 let e = get_id("e");
3454 let out_e: BTreeSet<u32> = fx
3455 .topo
3456 .neighbors(et, Direction::Out, e)
3457 .iter()
3458 .copied()
3459 .collect();
3460 assert!(out_e.contains(&a), "e→a");
3461 assert!(out_e.contains(&b), "e→b");
3462 assert_eq!(out_e.len(), 2);
3463 assert!(eng.by_node_consistent());
3464 }
3465
3466 #[test]
3468 fn topk_k_larger_than_candidate_count() {
3469 let mut fx = Fx::new();
3470 let mut eng = RuleEngine::new();
3471 {
3472 let mut g = fx.g();
3473 eng.create_rule(topk_eq_rule(100), &mut g).unwrap();
3475 }
3476 for i in 0..4usize {
3477 let id = fx.add("N", &format!("n{i}"), vec![("k", Value::Str("c".into()))]);
3478 let mut g = fx.g();
3479 eng.on_node_changed(id, None, &mut g);
3480 }
3481 assert_eq!(eng.provenance()["eq"].len(), 12);
3483 assert!(!eng.is_tripped("eq"));
3484 }
3485
3486 #[test]
3489 fn topk_rebuild_exact() {
3490 let mut fx = Fx::new();
3491 let mut eng = RuleEngine::new();
3492 {
3493 let mut g = fx.g();
3494 eng.create_rule(topk_eq_rule(1), &mut g).unwrap();
3495 }
3496 let _a = fx.add("N", "a", vec![("k", Value::Str("x".into()))]);
3498 let _b = fx.add("N", "b", vec![("k", Value::Str("x".into()))]);
3499 let _c = fx.add("N", "c", vec![("k", Value::Str("x".into()))]);
3500 {
3501 let mut g = fx.g();
3502 eng.on_node_changed(_a, None, &mut g);
3503 eng.on_node_changed(_b, None, &mut g);
3504 eng.on_node_changed(_c, None, &mut g);
3505 }
3506 assert_eq!(eng.provenance()["eq"].len(), 3);
3507
3508 {
3510 let mut g = fx.g();
3511 eng.rebuild("eq", &mut g).unwrap();
3512 }
3513 assert_eq!(eng.provenance()["eq"].len(), 3);
3514 assert!(!eng.is_tripped("eq"));
3515 assert!(eng.by_node_consistent());
3516 }
3517
3518 #[test]
3520 fn topk_by_node_consistent() {
3521 let mut fx = Fx::new();
3522 let mut eng = RuleEngine::new();
3523 {
3524 let mut g = fx.g();
3525 eng.create_rule(topk_eq_rule(2), &mut g).unwrap();
3526 }
3527 for i in 0..5usize {
3528 let id = fx.add(
3529 "N",
3530 &format!("n{i}"),
3531 vec![("k", Value::Str("const".into()))],
3532 );
3533 let mut g = fx.g();
3534 eng.on_node_changed(id, None, &mut g);
3535 }
3536 assert!(eng.by_node_consistent(), "consistent after insertions");
3537
3538 let id2 = fx.ids.get("n2").unwrap();
3540 let old = fx.props.get(id2, "k").cloned();
3541 fx.props.set(id2, "k", Value::Str("other".into()));
3542 {
3543 let mut g = fx.g();
3544 eng.on_node_changed(id2, Some(("k", old)), &mut g);
3545 }
3546 assert!(eng.by_node_consistent(), "consistent after eviction");
3547
3548 {
3549 let mut g = fx.g();
3550 eng.rebuild("eq", &mut g).unwrap();
3551 }
3552 assert!(eng.by_node_consistent(), "consistent after rebuild");
3553 }
3554
3555 fn numeric_rule() -> RuleDef {
3556 RuleDef {
3557 name: "nw".into(),
3558 src_label: "C".into(),
3559 dst_label: "C".into(),
3560 predicate: Predicate::NumericWithin {
3561 field: "year".into(),
3562 tolerance: 2.0,
3563 },
3564 edge_type: "NEAR".into(),
3565 weight_prop: Some("score".into()),
3566 max_edges: None,
3567 approximate: false,
3568 via_label: None,
3569 via_edge: None,
3570 via_dir: None,
3571 }
3572 }
3573
3574 fn geo_rule() -> RuleDef {
3575 RuleDef {
3576 name: "geo".into(),
3577 src_label: "City".into(),
3578 dst_label: "City".into(),
3579 predicate: Predicate::GeoRadius {
3580 field: "loc".into(),
3581 km: 400.0,
3582 },
3583 edge_type: "NEAR_GEO".into(),
3584 weight_prop: Some("score".into()),
3585 max_edges: None,
3586 approximate: false,
3587 via_label: None,
3588 via_edge: None,
3589 via_dir: None,
3590 }
3591 }
3592
3593 fn vec_rule() -> RuleDef {
3594 RuleDef {
3595 name: "vec".into(),
3596 src_label: "Doc".into(),
3597 dst_label: "Doc".into(),
3598 predicate: Predicate::VectorSimilar {
3599 field: "emb".into(),
3600 min: 0.9,
3601 },
3602 edge_type: "SIM".into(),
3603 weight_prop: Some("score".into()),
3604 max_edges: None,
3605 approximate: false,
3606 via_label: None,
3607 via_edge: None,
3608 via_dir: None,
3609 }
3610 }
3611
3612 fn pair_edges(topo: &Topology, et: u32, a: u32, b: u32) -> bool {
3613 topo.neighbors(et, Direction::Out, a).contains(&b)
3614 && topo.neighbors(et, Direction::Out, b).contains(&a)
3615 }
3616
3617 #[test]
3618 fn numeric_within_incremental_crosses_bucket_and_clears_old_index() {
3619 let mut fx = Fx::new();
3620 let a = fx.add("C", "a", vec![("year", Value::Float(10.0))]);
3621 let b = fx.add("C", "b", vec![("year", Value::Float(12.0))]);
3622 let et = fx.syms.intern("NEAR");
3623 let mut eng = RuleEngine::new();
3624 {
3625 let mut g = fx.g();
3626 eng.create_rule(numeric_rule(), &mut g).unwrap();
3627 assert!(pair_edges(g.topo, et, a, b));
3629 }
3630
3631 let old = fx.props.get(b, "year").cloned();
3634 fx.props.set(b, "year", Value::Float(16.1));
3635 {
3636 let mut g = fx.g();
3637 eng.on_node_changed(b, Some(("year", old)), &mut g);
3638 assert!(!pair_edges(g.topo, et, a, b));
3639 assert_eq!(g.topo.edge_count(), 0);
3640 }
3641 let def = numeric_rule();
3642 let spec = candidate_spec_for(&def);
3643 let old_map: std::collections::HashMap<_, _> =
3644 [("year".to_string(), Value::Float(12.0))].into();
3645 let old_get = |f: &str| old_map.get(f).cloned();
3646 let src_hits = eng.indexes["nw"].src_side.candidates(&spec, &old_get);
3647 let dst_hits = eng.indexes["nw"].dst_side.candidates(&spec, &old_get);
3648 assert!(!src_hits.contains(&b), "old src bucket must drop b");
3649 assert!(!dst_hits.contains(&b), "old dst bucket must drop b");
3650 assert!(src_hits.contains(&a));
3651
3652 let old = fx.props.get(b, "year").cloned();
3654 fx.props.set(b, "year", Value::Float(11.9));
3655 let mut g = fx.g();
3656 eng.on_node_changed(b, Some(("year", old)), &mut g);
3657 assert!(pair_edges(g.topo, et, a, b));
3658 }
3659
3660 fn loc_val(lat: f64, lon: f64) -> Value {
3661 Value::List(vec![Value::Float(lat), Value::Float(lon)])
3662 }
3663
3664 fn emb_val(vals: &[f64]) -> Value {
3665 Value::List(vals.iter().copied().map(Value::Float).collect())
3666 }
3667
3668 #[test]
3669 fn rebuild_is_noop_for_numeric_geo_and_vector() {
3670 let mut fx = Fx::new();
3671 let ca = fx.add("C", "ca", vec![("year", Value::Int(1998))]);
3672 let cb = fx.add("C", "cb", vec![("year", Value::Float(2000.0))]);
3673 let pa = fx.add("City", "paris", vec![("loc", loc_val(48.8566, 2.3522))]);
3674 let lo = fx.add("City", "london", vec![("loc", loc_val(51.5074, -0.1278))]);
3675 let da = fx.add("Doc", "d1", vec![("emb", emb_val(&[1.0, 0.0]))]);
3676 let db = fx.add("Doc", "d2", vec![("emb", emb_val(&[1.0, 0.0]))]);
3677
3678 let mut eng = RuleEngine::new();
3679 {
3680 let mut g = fx.g();
3681 eng.create_rule(numeric_rule(), &mut g).unwrap();
3682 eng.create_rule(geo_rule(), &mut g).unwrap();
3683 eng.create_rule(vec_rule(), &mut g).unwrap();
3684 }
3685
3686 let (near, ngeo, sim) = (
3687 fx.syms.get("NEAR").unwrap(),
3688 fx.syms.get("NEAR_GEO").unwrap(),
3689 fx.syms.get("SIM").unwrap(),
3690 );
3691 assert!(pair_edges(&fx.topo, near, ca, cb));
3692 assert!(pair_edges(&fx.topo, ngeo, pa, lo));
3693 assert!(pair_edges(&fx.topo, sim, da, db));
3694 let before = fx.topo.edge_count();
3695
3696 {
3697 let mut g = fx.g();
3698 eng.rebuild("nw", &mut g).unwrap();
3699 eng.rebuild("geo", &mut g).unwrap();
3700 eng.rebuild("vec", &mut g).unwrap();
3701 }
3702 assert_eq!(fx.topo.edge_count(), before);
3703 assert!(pair_edges(&fx.topo, near, ca, cb));
3704 assert!(pair_edges(&fx.topo, ngeo, pa, lo));
3705 assert!(pair_edges(&fx.topo, sim, da, db));
3706 }
3707
3708 fn fk_rule() -> RuleDef {
3709 RuleDef {
3710 name: "works_at".into(),
3711 src_label: "T".into(),
3712 dst_label: "C".into(),
3713 predicate: Predicate::KeyMatch {
3714 field: "cid".into(),
3715 },
3716 edge_type: "AT".into(),
3717 weight_prop: None,
3718 max_edges: None,
3719 approximate: false,
3720 via_label: None,
3721 via_edge: None,
3722 via_dir: None,
3723 }
3724 }
3725
3726 #[test]
3727 fn by_node_matches_rebuild_after_mutation_storm() {
3728 let mut fx = Fx::new();
3729 let hub = fx.add("C", "hub", vec![]);
3730 let other = fx.add("C", "other", vec![]);
3731 let mut people = Vec::new();
3732 for i in 0..40 {
3733 let cid = if i < 30 { "hub" } else { "other" };
3734 people.push(fx.add(
3735 "T",
3736 &format!("t{i}"),
3737 vec![("cid", Value::Str(cid.into())), ("tags", tags(&["x", "y"]))],
3738 ));
3739 }
3740 let mut overlap = overlap_rule();
3741 overlap.src_label = "T".into();
3742 overlap.dst_label = "T".into();
3743 let mut eng = RuleEngine::new();
3744 {
3745 let mut g = fx.g();
3746 eng.create_rule(fk_rule(), &mut g).unwrap();
3747 eng.create_rule(overlap, &mut g).unwrap();
3748 }
3749 assert!(eng.by_node_consistent());
3750 assert_eq!(eng.provenance_touching_len(hub), 30);
3751
3752 for (i, &id) in people.iter().enumerate().take(15) {
3754 let old = fx.props.get(id, "cid").cloned();
3755 fx.props.set(id, "cid", Value::Str("other".into()));
3756 let mut g = fx.g();
3757 eng.on_node_changed(id, Some(("cid", old)), &mut g);
3758 assert!(
3759 eng.by_node_consistent(),
3760 "inconsistent after cid update {i}"
3761 );
3762 }
3763 for &id in people.iter().take(8) {
3764 let old = fx.props.get(id, "tags").cloned();
3765 fx.props.set(id, "tags", tags(&["q"]));
3766 let mut g = fx.g();
3767 eng.on_node_changed(id, Some(("tags", old)), &mut g);
3768 }
3769 assert!(eng.by_node_consistent());
3770
3771 {
3773 let mut g = fx.g();
3774 eng.on_node_removed(people[0], &mut g);
3775 }
3776 fx.labels[people[0] as usize] = u32::MAX;
3777 assert!(eng.by_node_consistent());
3778 assert_eq!(eng.provenance_touching_len(people[0]), 0);
3779
3780 {
3781 let mut g = fx.g();
3782 eng.rebuild("works_at", &mut g).unwrap();
3783 eng.rebuild("rel", &mut g).unwrap();
3784 }
3785 assert!(eng.by_node_consistent());
3786
3787 {
3788 let mut g = fx.g();
3789 eng.delete_rule("rel", &mut g).unwrap();
3790 }
3791 assert!(eng.by_node_consistent());
3792 assert_eq!(eng.provenance_touching(people[1]).count(), 1);
3793
3794 let (defs, prov, tripped, fires) = eng.to_persist();
3796 let restored = RuleEngine::from_persist(defs, prov, tripped, fires);
3797 assert!(restored.by_node_consistent());
3798 assert_eq!(
3799 restored.provenance_touching_len(hub),
3800 eng.provenance_touching_len(hub)
3801 );
3802 assert_eq!(
3803 restored.provenance_touching_len(other),
3804 eng.provenance_touching_len(other)
3805 );
3806 }
3807
3808 #[test]
3809 fn provenance_touching_high_degree_hub() {
3810 let mut fx = Fx::new();
3811 let hub = fx.add("C", "hub", vec![]);
3812 let mut first = None;
3813 for i in 0..256 {
3814 let id = fx.add(
3815 "T",
3816 &format!("t{i}"),
3817 vec![("cid", Value::Str("hub".into()))],
3818 );
3819 if first.is_none() {
3820 first = Some(id);
3821 }
3822 }
3823 let first = first.unwrap();
3824 let mut eng = RuleEngine::new();
3825 {
3826 let mut g = fx.g();
3827 eng.create_rule(fk_rule(), &mut g).unwrap();
3828 }
3829 assert!(eng.by_node_consistent());
3830 assert_eq!(eng.provenance_touching_len(hub), 256);
3831 assert_eq!(eng.provenance_touching_len(first), 1);
3832 let hits: Vec<_> = eng.provenance_touching(first).collect();
3833 assert_eq!(hits.len(), 1);
3834 assert_eq!(hits[0].0, "works_at");
3835 assert_eq!(hits[0].2, first);
3836 assert_eq!(hits[0].3, hub);
3837 }
3838
3839 #[test]
3847 fn by_node_consistent_across_inserts_and_rebuild() {
3848 let mut fx = Fx::new();
3849 let mut eng = RuleEngine::new();
3850 let rule = RuleDef {
3851 name: "eq".into(),
3852 src_label: "N".into(),
3853 dst_label: "N".into(),
3854 predicate: Predicate::FieldEqual { field: "k".into() },
3855 edge_type: "EQ".into(),
3856 weight_prop: None,
3857 max_edges: None, approximate: false,
3859 via_label: None,
3860 via_edge: None,
3861 via_dir: None,
3862 };
3863 {
3864 let mut g = fx.g();
3865 eng.create_rule(rule, &mut g).unwrap();
3866 }
3867 let mut ids = Vec::new();
3868 for i in 0..6 {
3869 let id = fx.add(
3870 "N",
3871 &format!("n{i}"),
3872 vec![("k", Value::Str("const".into()))],
3873 );
3874 ids.push(id);
3875 let mut g = fx.g();
3876 eng.on_node_changed(id, None, &mut g);
3877 }
3878 assert_eq!(eng.provenance()["eq"].len(), 30);
3880 assert!(!eng.is_tripped("eq"));
3881 assert!(eng.by_node_consistent(), "consistent after insertions");
3882
3883 let old = fx.props.get(ids[3], "k").cloned();
3885 fx.props.set(ids[3], "k", Value::Str("other".into()));
3886 {
3887 let mut g = fx.g();
3888 eng.on_node_changed(ids[3], Some(("k", old)), &mut g);
3889 }
3890 assert!(eng.by_node_consistent(), "consistent after property change");
3891
3892 {
3893 let mut g = fx.g();
3894 eng.rebuild("eq", &mut g).unwrap();
3895 }
3896 assert!(!eng.is_tripped("eq"));
3897 assert!(eng.by_node_consistent(), "consistent after rebuild");
3898 }
3899
3900 fn mix64(mut x: u64) -> u64 {
3901 x = x.wrapping_add(0x9E3779B97F4A7C15);
3902 x = (x ^ (x >> 30)).wrapping_mul(0xBF58476D1CE4E5B9);
3903 x = (x ^ (x >> 27)).wrapping_mul(0x94D049BB133111EB);
3904 x ^ (x >> 31)
3905 }
3906
3907 fn rand_emb(seed: u64, i: u32, dim: usize) -> Value {
3908 let vals: Vec<f64> = (0..dim)
3909 .map(|d| {
3910 let bits = mix64(seed ^ ((i as u64 + 1).wrapping_mul(0x100000001)) ^ (d as u64));
3911 let mut f = (bits as f64) / (u64::MAX as f64) * 2.0 - 1.0;
3912 if f == 0.0 {
3913 f = 1.0;
3914 }
3915 f
3916 })
3917 .collect();
3918 emb_val(&vals)
3919 }
3920
3921 fn seed_docs(n: u32, seed: u64) -> (Fx, Vec<u32>) {
3922 let dims = [2usize, 3, 4, 8];
3923 let mut fx = Fx::new();
3924 let mut ids = Vec::new();
3925 for i in 0..n {
3926 let dim = dims[(i as usize) % dims.len()];
3927 ids.push(fx.add(
3928 "Doc",
3929 &format!("d{i}"),
3930 vec![("emb", rand_emb(seed, i, dim))],
3931 ));
3932 }
3933 (fx, ids)
3934 }
3935
3936 #[test]
3939 fn vector_dim_reject_matches_unfiltered_and_oracle() {
3940 const N: u32 = 500;
3941 const SEED: u64 = 0xC0FF_EE00_D15C;
3942 let def = vec_rule();
3943
3944 let (mut fx_on, ids) = seed_docs(N, SEED);
3945 let mut eng_on = RuleEngine::new();
3946 {
3947 let mut g = fx_on.g();
3948 eng_on.create_rule(def.clone(), &mut g).unwrap();
3949 }
3950 let on = prov_pairs(&eng_on, "vec");
3951 assert!(!on.is_empty(), "seeded set must produce some edges");
3952
3953 let (mut fx_off, _) = seed_docs(N, SEED);
3954 let mut eng_off = RuleEngine::new();
3955 {
3956 let mut g = fx_off.g();
3957 with_vector_dim_reject(false, || {
3958 eng_off.create_rule(def.clone(), &mut g).unwrap();
3959 });
3960 }
3961 assert_eq!(on, prov_pairs(&eng_off, "vec"), "filter vs no-filter");
3962
3963 let mut brute = BTreeSet::new();
3964 for &s in &ids {
3965 for &d in &ids {
3966 if s == d {
3967 continue;
3968 }
3969 let skey = fx_on.ids.key_of(s).unwrap();
3970 let dkey = fx_on.ids.key_of(d).unwrap();
3971 let sget = |f: &str| fx_on.props.get(s, f).cloned();
3972 let dget = |f: &str| fx_on.props.get(d, f).cloned();
3973 if evaluate(
3974 &def.predicate,
3975 &NodeView {
3976 key: skey,
3977 props: &sget,
3978 },
3979 &NodeView {
3980 key: dkey,
3981 props: &dget,
3982 },
3983 )
3984 .is_some()
3985 {
3986 brute.insert((s, d));
3987 }
3988 }
3989 }
3990 assert_eq!(on, brute, "filter vs brute-force evaluate");
3991 }
3992
3993 #[test]
3996 fn vector_dim_change_updates_cache_and_matches_fresh_build() {
3997 let mut fx = Fx::new();
3998 let a = fx.add("Doc", "a", vec![("emb", emb_val(&[1.0, 0.0]))]);
3999 let b = fx.add("Doc", "b", vec![("emb", emb_val(&[1.0, 0.0]))]);
4000 let c = fx.add("Doc", "c", vec![("emb", emb_val(&[1.0, 0.0, 0.0]))]);
4001 let mut eng = RuleEngine::new();
4002 {
4003 let mut g = fx.g();
4004 eng.create_rule(vec_rule(), &mut g).unwrap();
4005 }
4006 assert_eq!(eng.indexes["vec"].src_side.vec_dim(a), Some(2));
4007 assert_eq!(eng.indexes["vec"].src_side.vec_dim(c), Some(3));
4008 assert_eq!(prov_pairs(&eng, "vec"), BTreeSet::from([(a, b), (b, a)]));
4009
4010 let old = fx.props.get(b, "emb").cloned();
4011 fx.props.set(b, "emb", emb_val(&[1.0, 0.0, 0.0]));
4012 {
4013 let mut g = fx.g();
4014 eng.on_node_changed(b, Some(("emb", old)), &mut g);
4015 }
4016 assert_eq!(eng.indexes["vec"].src_side.vec_dim(b), Some(3));
4017 assert_eq!(eng.indexes["vec"].dst_side.vec_dim(b), Some(3));
4018 let after = prov_pairs(&eng, "vec");
4019 assert_eq!(after, BTreeSet::from([(b, c), (c, b)]));
4020
4021 let mut fresh_fx = Fx::new();
4023 let fa = fresh_fx.add("Doc", "a", vec![("emb", emb_val(&[1.0, 0.0]))]);
4024 let fb = fresh_fx.add("Doc", "b", vec![("emb", emb_val(&[1.0, 0.0, 0.0]))]);
4025 let fc = fresh_fx.add("Doc", "c", vec![("emb", emb_val(&[1.0, 0.0, 0.0]))]);
4026 let mut fresh = RuleEngine::new();
4027 {
4028 let mut g = fresh_fx.g();
4029 fresh.create_rule(vec_rule(), &mut g).unwrap();
4030 }
4031 assert_eq!(
4032 prov_pairs(&fresh, "vec"),
4033 BTreeSet::from([(fb, fc), (fc, fb)])
4034 );
4035 assert_eq!(fresh.indexes["vec"].src_side.vec_dim(fb), Some(3));
4036 assert_eq!(fresh.indexes["vec"].src_side.vec_dim(fa), Some(2));
4037 }
4038
4039 #[test]
4059 fn streaming_topk_order_identity_property_test() {
4060 fn reference_topk(rule: &RuleDef, k: u64, fx: &mut Fx) -> BTreeSet<(u32, u32)> {
4063 let mut idx = RuleIndex::default();
4064 for id in 0..fx.ids.len() as u32 {
4065 let label_sym = match fx.labels.get(id as usize).copied() {
4066 Some(s) if s != u32::MAX => s,
4067 _ => continue,
4068 };
4069 index_node_for_rule(
4070 id,
4071 label_sym,
4072 rule,
4073 &mut idx,
4074 &fx.syms,
4075 ColumnsView::owned(&fx.props),
4076 );
4077 }
4078 let src_sym = fx.syms.get(&rule.src_label);
4079 let mut out = BTreeSet::new();
4080 let ids_snap: Vec<u32> = (0..fx.ids.len() as u32).collect();
4081 for id in ids_snap {
4082 let label_sym = match fx.labels.get(id as usize).copied() {
4083 Some(s) if s != u32::MAX => s,
4084 _ => continue,
4085 };
4086 if src_sym != Some(label_sym) {
4087 continue;
4088 }
4089 let g = GraphMut {
4090 ids: &fx.ids,
4091 syms: &mut fx.syms,
4092 labels: &fx.labels,
4093 props: ColumnsView::owned(&fx.props),
4094 topo: &mut fx.topo,
4095 edge_props: &mut fx.eprops,
4096 };
4097 let per_src = compute_desired(rule, &idx, id, true, &g);
4098 let mut candidates: Vec<((u32, u32), f64)> = per_src.into_iter().collect();
4100 candidates.sort_by(|&((_, da), sa), &((_, db), sb)| {
4101 sb.total_cmp(&sa).then_with(|| {
4102 let ka = fx.ids.key_of(da).unwrap_or("");
4103 let kb = fx.ids.key_of(db).unwrap_or("");
4104 ka.cmp(kb)
4105 })
4106 });
4107 candidates.truncate(k as usize);
4108 out.extend(candidates.into_iter().map(|(k, _)| k));
4109 }
4110 out
4111 }
4112
4113 fn streaming_pairs(rule: RuleDef, fx: &mut Fx) -> BTreeSet<(u32, u32)> {
4115 let name = rule.name.clone();
4116 let mut eng = RuleEngine::new();
4117 eng.create_rule(rule, &mut fx.g()).unwrap();
4118 eng.provenance()
4119 .get(&name)
4120 .map(|s| s.iter().map(|&(_, a, b)| (a, b)).collect())
4121 .unwrap_or_default()
4122 }
4123
4124 for seed in [0u64, 1, 42, 0xDEAD_BEEF, 0x1234_5678, 99, 12_648_430, 7] {
4129 for k in [1u64, 2, 3, 5] {
4130 let rule = RuleDef {
4131 name: "eq".into(),
4132 src_label: "N".into(),
4133 dst_label: "N".into(),
4134 predicate: Predicate::FieldEqual { field: "k".into() },
4135 edge_type: "EQ".into(),
4136 weight_prop: None,
4137 max_edges: Some(k),
4138 approximate: false,
4139 via_label: None,
4140 via_edge: None,
4141 via_dir: None,
4142 };
4143
4144 let build = || {
4145 let mut fx = Fx::new();
4146 for i in 0..12u32 {
4147 let h = mix64(seed ^ (i as u64 + 1));
4148 let val = match h % 3 {
4149 0 => "a",
4150 1 => "b",
4151 _ => "c",
4152 };
4153 fx.add(
4154 "N",
4155 &format!("n{i:02}"),
4156 vec![("k", Value::Str(val.into()))],
4157 );
4158 }
4159 fx
4160 };
4161
4162 let expected = reference_topk(&rule, k, &mut build());
4163 let actual = streaming_pairs(rule, &mut build());
4164
4165 assert_eq!(
4166 expected, actual,
4167 "FieldEqual seed={seed} k={k}: streaming top-k must match brute-force top-k"
4168 );
4169 }
4170 }
4171
4172 for seed in [0u64, 1, 42, 7] {
4177 for k in [1u64, 2, 4] {
4178 let rule = RuleDef {
4179 name: "nw".into(),
4180 src_label: "S".into(),
4181 dst_label: "D".into(),
4182 predicate: Predicate::NumericWithin {
4183 field: "v".into(),
4184 tolerance: 10.0,
4185 },
4186 edge_type: "NEAR".into(),
4187 weight_prop: Some("score".into()),
4188 max_edges: Some(k),
4189 approximate: false,
4190 via_label: None,
4191 via_edge: None,
4192 via_dir: None,
4193 };
4194
4195 let build = || {
4196 let mut fx = Fx::new();
4197 for i in 0..6u32 {
4198 let h = mix64(seed ^ (i as u64 + 1));
4199 let v = (h % 20) as f64;
4200 fx.add("S", &format!("s{i}"), vec![("v", Value::Float(v))]);
4201 }
4202 for i in 0..8u32 {
4203 let h = mix64(seed ^ (i as u64 + 101));
4204 let v = (h % 20) as f64;
4205 fx.add("D", &format!("d{i}"), vec![("v", Value::Float(v))]);
4206 }
4207 fx
4208 };
4209
4210 let expected = reference_topk(&rule, k, &mut build());
4211 let actual = streaming_pairs(rule, &mut build());
4212
4213 assert_eq!(
4214 expected, actual,
4215 "NumericWithin seed={seed} k={k}: streaming top-k must match brute-force top-k"
4216 );
4217 }
4218 }
4219
4220 for seed in [0u64, 1, 42, 7] {
4227 for k in [1u64, 2] {
4228 let rule = RuleDef {
4229 name: "fk".into(),
4230 src_label: "T".into(),
4231 dst_label: "C".into(),
4232 predicate: Predicate::KeyMatch {
4233 field: "cid".into(),
4234 },
4235 edge_type: "AT".into(),
4236 weight_prop: None,
4237 max_edges: Some(k),
4238 approximate: false,
4239 via_label: None,
4240 via_edge: None,
4241 via_dir: None,
4242 };
4243
4244 let build = || {
4245 let mut fx = Fx::new();
4246 for i in 0..4u32 {
4248 fx.add("C", &format!("c{i}"), vec![]);
4249 }
4250 for i in 0..8u32 {
4252 let h = mix64(seed ^ (i as u64 + 1));
4253 let cid = format!("c{}", h % 4);
4254 fx.add("T", &format!("t{i}"), vec![("cid", Value::Str(cid))]);
4255 }
4256 fx
4257 };
4258
4259 let expected = reference_topk(&rule, k, &mut build());
4260 let actual = streaming_pairs(rule, &mut build());
4261
4262 assert_eq!(
4263 expected, actual,
4264 "KeyMatch seed={seed} k={k}: streaming top-k must match brute-force top-k"
4265 );
4266 }
4267 }
4268
4269 {
4275 let cluster_a: &[(&str, f64, f64)] = &[
4277 ("va0", 1.0_f64, 0.0_f64),
4278 ("va1", 0.98_f64, 0.199_f64), ("va2", 0.97_f64, 0.243_f64), ];
4281 let cluster_b: &[(&str, f64, f64)] = &[
4282 ("vb0", 0.0_f64, 1.0_f64),
4283 ("vb1", 0.1_f64, 0.995_f64),
4284 ("vb2", 0.05_f64, 0.999_f64),
4285 ];
4286 for k in [1u64, 2] {
4287 let rule = RuleDef {
4288 name: "vsim".into(),
4289 src_label: "V".into(),
4290 dst_label: "V".into(),
4291 predicate: Predicate::VectorSimilar {
4292 field: "emb".into(),
4293 min: 0.9,
4294 },
4295 edge_type: "VSIM".into(),
4296 weight_prop: Some("score".into()),
4297 max_edges: Some(k),
4298 approximate: false,
4299 via_label: None,
4300 via_edge: None,
4301 via_dir: None,
4302 };
4303
4304 let build = || {
4305 let mut fx = Fx::new();
4306 let mut add_v = |key: &str, x: f64, y: f64| {
4307 let norm = (x * x + y * y).sqrt();
4308 let v = Value::List(vec![Value::Float(x / norm), Value::Float(y / norm)]);
4309 fx.add("V", key, vec![("emb", v)]);
4310 };
4311 for &(k, x, y) in cluster_a.iter().chain(cluster_b.iter()) {
4312 add_v(k, x, y);
4313 }
4314 fx
4315 };
4316
4317 let expected = reference_topk(&rule, k, &mut build());
4318 let actual = streaming_pairs(rule, &mut build());
4319
4320 assert_eq!(
4321 expected, actual,
4322 "VectorSimilar/ScanAll k={k}: streaming top-k must match brute-force top-k"
4323 );
4324 }
4325 }
4326 }
4327
4328 #[test]
4354 #[ignore]
4355 fn streaming_peak_transient_bound() {
4356 use std::sync::{
4357 atomic::{AtomicBool, AtomicU64, Ordering},
4358 Arc,
4359 };
4360
4361 fn peak_rss_during<F: FnOnce()>(f: F) -> u64 {
4364 let done = Arc::new(AtomicBool::new(false));
4365 let peak = Arc::new(AtomicU64::new(0));
4366 let done2 = done.clone();
4367 let peak2 = peak.clone();
4368 let pid = std::process::id().to_string();
4369
4370 let handle = std::thread::spawn(move || {
4371 while !done2.load(Ordering::Relaxed) {
4372 let rss = std::process::Command::new("ps")
4373 .args(["-o", "rss=", "-p", &pid])
4374 .output()
4375 .ok()
4376 .and_then(|o| String::from_utf8(o.stdout).ok())
4377 .and_then(|s| s.trim().parse::<u64>().ok())
4378 .unwrap_or(0)
4379 * 1024;
4380 peak2.fetch_max(rss, Ordering::Relaxed);
4381 std::thread::sleep(std::time::Duration::from_millis(1));
4382 }
4383 });
4384
4385 f();
4386
4387 done.store(true, Ordering::Relaxed);
4388 let _ = handle.join();
4389 peak.load(Ordering::Relaxed)
4390 }
4391
4392 let mut fx = Fx::new();
4396 for i in 0..500u32 {
4397 fx.add(
4398 "Talent",
4399 &format!("t{i}"),
4400 vec![("k", Value::Str("same".into()))],
4401 );
4402 }
4403 for i in 0..500u32 {
4404 fx.add(
4405 "Company",
4406 &format!("c{i}"),
4407 vec![("k", Value::Str("same".into()))],
4408 );
4409 }
4410 let rule = RuleDef {
4411 name: "eq_tc".into(),
4412 src_label: "Talent".into(),
4413 dst_label: "Company".into(),
4414 predicate: Predicate::FieldEqual { field: "k".into() },
4415 edge_type: "EQ".into(),
4416 weight_prop: None,
4417 max_edges: Some(2), approximate: false,
4419 via_label: None,
4420 via_edge: None,
4421 via_dir: None,
4422 };
4423
4424 let pid = std::process::id().to_string();
4426 let baseline = std::process::Command::new("ps")
4427 .args(["-o", "rss=", "-p", &pid])
4428 .output()
4429 .ok()
4430 .and_then(|o| String::from_utf8(o.stdout).ok())
4431 .and_then(|s| s.trim().parse::<u64>().ok())
4432 .unwrap_or(0)
4433 * 1024;
4434
4435 let mut eng = RuleEngine::new();
4436 let peak = peak_rss_during(|| {
4437 eng.create_rule(rule, &mut fx.g()).unwrap();
4438 });
4439
4440 let peak_delta = peak.saturating_sub(baseline);
4441
4442 assert!(
4446 peak_delta < 3 * 1024 * 1024,
4447 "peak transient delta {} bytes ({} KiB) exceeded 3 MiB; \
4448 streaming path may be building the full pairs map",
4449 peak_delta,
4450 peak_delta / 1024
4451 );
4452 assert_eq!(eng.provenance()["eq_tc"].len(), 1_000); assert!(!eng.is_tripped("eq_tc")); eprintln!(
4455 "streaming_peak_transient_bound: baseline={baseline} peak={peak} \
4456 delta={peak_delta} bytes ({} KiB)",
4457 peak_delta / 1024
4458 );
4459 }
4460
4461 fn near_threshold_pair(dim: usize, min: f64) -> (Vec<f64>, Vec<f64>) {
4468 let cos_target = min + 1e-6; let sin_small = (1.0 - cos_target * cos_target).sqrt();
4472 let mut a = vec![0.0f64; dim];
4473 a[0] = 1.0;
4474 let mut b = vec![0.0f64; dim];
4475 b[0] = cos_target;
4476 if dim > 1 {
4477 b[1] = sin_small;
4478 }
4479 (a, b)
4480 }
4481
4482 fn emb_val2(xs: &[f64]) -> Value {
4483 Value::List(xs.iter().copied().map(Value::Float).collect())
4484 }
4485
4486 fn make_early_exit_fixture(seed: u64, min: f64) -> (Fx, Vec<u32>, usize, usize) {
4490 let dims = [2usize, 4, 8, 16];
4491 let n = 100u32;
4492 let mut fx = Fx::new();
4493 let mut ids = Vec::new();
4494 for i in 0..n {
4495 let dim = dims[(i as usize) % dims.len()];
4496 let emb = rand_emb(seed, i, dim);
4497 ids.push(fx.add("Doc", &format!("d{i}"), vec![("emb", emb)]));
4498 }
4499 let (va, vb) = near_threshold_pair(8, min);
4501 let nt_a = fx.add("Doc", "nt_a", vec![("emb", emb_val2(&va))]);
4502 let nt_b = fx.add("Doc", "nt_b", vec![("emb", emb_val2(&vb))]);
4503 ids.push(nt_a);
4504 ids.push(nt_b);
4505 (fx, ids, nt_a as usize, nt_b as usize)
4506 }
4507
4508 #[test]
4512 fn vector_early_exit_identity_proof() {
4513 const SEED: u64 = 0xEA_4E_5A;
4514 const MIN: f64 = 0.85;
4515
4516 let def = RuleDef {
4517 name: "vec".into(),
4518 src_label: "Doc".into(),
4519 dst_label: "Doc".into(),
4520 predicate: Predicate::VectorSimilar {
4521 field: "emb".into(),
4522 min: MIN,
4523 },
4524 edge_type: "SIM".into(),
4525 weight_prop: Some("score".into()),
4526 max_edges: None,
4527 approximate: false,
4528 via_label: None,
4529 via_edge: None,
4530 via_dir: None,
4531 };
4532
4533 let (mut fx_on, ids, nt_a, nt_b) = make_early_exit_fixture(SEED, MIN);
4535 let (mut fx_off, _, _, _) = make_early_exit_fixture(SEED, MIN);
4536 let (fx_oracle, _, _, _) = make_early_exit_fixture(SEED, MIN);
4537
4538 let nt_a = nt_a as u32;
4539 let nt_b = nt_b as u32;
4540
4541 let mut eng_on = RuleEngine::new();
4543 {
4544 let mut g = fx_on.g();
4545 eng_on.create_rule(def.clone(), &mut g).unwrap();
4546 }
4547 let edges_on = prov_pairs(&eng_on, "vec");
4548 assert!(!edges_on.is_empty(), "should produce some edges");
4549
4550 assert!(
4552 edges_on.contains(&(nt_a, nt_b)),
4553 "near-threshold pair nt_a→nt_b must match with early-exit ON"
4554 );
4555 assert!(
4556 edges_on.contains(&(nt_b, nt_a)),
4557 "near-threshold pair nt_b→nt_a must match with early-exit ON"
4558 );
4559
4560 let mut eng_off = RuleEngine::new();
4562 {
4563 let mut g = fx_off.g();
4564 with_vector_early_exit(false, || {
4565 eng_off.create_rule(def.clone(), &mut g).unwrap();
4566 });
4567 }
4568 let edges_off = prov_pairs(&eng_off, "vec");
4569 assert_eq!(
4570 edges_on, edges_off,
4571 "early-exit ON vs OFF must produce identical edges"
4572 );
4573
4574 let mut oracle = BTreeSet::new();
4576 for &s in &ids {
4577 for &d in &ids {
4578 if s == d {
4579 continue;
4580 }
4581 let skey = fx_oracle.ids.key_of(s).unwrap();
4582 let dkey = fx_oracle.ids.key_of(d).unwrap();
4583 let sg = |f: &str| fx_oracle.props.get(s, f).cloned();
4584 let dg = |f: &str| fx_oracle.props.get(d, f).cloned();
4585 if evaluate(
4586 &def.predicate,
4587 &NodeView {
4588 key: skey,
4589 props: &sg,
4590 },
4591 &NodeView {
4592 key: dkey,
4593 props: &dg,
4594 },
4595 )
4596 .is_some()
4597 {
4598 oracle.insert((s, d));
4599 }
4600 }
4601 }
4602 assert_eq!(
4603 edges_on, oracle,
4604 "early-exit ON vs brute-force oracle must be identical"
4605 );
4606 }
4607
4608 #[test]
4611 fn vector_early_exit_checkpoint_coherence() {
4612 let mut fx = Fx::new();
4613 let a = fx.add("Doc", "a", vec![("emb", emb_val(&[1.0, 0.0, 0.0, 0.0]))]);
4615 let b = fx.add("Doc", "b", vec![("emb", emb_val(&[1.0, 0.0, 0.0, 0.0]))]);
4616 let c = fx.add(
4618 "Doc",
4619 "c",
4620 vec![("emb", emb_val(&[1.0, 0.0, 0.0, 0.0, 0.0, 0.0]))],
4621 );
4622 let def = RuleDef {
4623 name: "vec".into(),
4624 src_label: "Doc".into(),
4625 dst_label: "Doc".into(),
4626 predicate: Predicate::VectorSimilar {
4627 field: "emb".into(),
4628 min: 0.9,
4629 },
4630 edge_type: "SIM".into(),
4631 weight_prop: None,
4632 max_edges: None,
4633 approximate: false,
4634 via_label: None,
4635 via_edge: None,
4636 via_dir: None,
4637 };
4638
4639 let mut eng = RuleEngine::new();
4640 {
4641 let mut g = fx.g();
4642 eng.create_rule(def.clone(), &mut g).unwrap();
4643 }
4644
4645 assert!(
4647 eng.indexes["vec"].src_side.vec_ckpts(a).is_some(),
4648 "a must have src checkpoints"
4649 );
4650 assert!(
4651 eng.indexes["vec"].dst_side.vec_ckpts(b).is_some(),
4652 "b must have dst checkpoints"
4653 );
4654 assert!(
4655 eng.indexes["vec"].src_side.vec_ckpts(c).is_some(),
4656 "c must have src checkpoints (dim=6)"
4657 );
4658
4659 let ckpts_a = *eng.indexes["vec"].src_side.vec_ckpts(a).unwrap();
4661 let norm_a = eng.indexes["vec"].src_side.vec_meta(a).unwrap().1;
4662 assert!(
4663 (ckpts_a[0] - norm_a).abs() < 1e-12,
4664 "ckpts[0] must equal the full L2 norm"
4665 );
4666
4667 assert_eq!(prov_pairs(&eng, "vec"), BTreeSet::from([(a, b), (b, a)]));
4669
4670 let old_b = fx.props.get(b, "emb").cloned();
4672 fx.props
4673 .set(b, "emb", emb_val(&[1.0, 0.0, 0.0, 0.0, 0.0, 0.0]));
4674 {
4675 let mut g = fx.g();
4676 eng.on_node_changed(b, Some(("emb", old_b)), &mut g);
4677 }
4678 assert_eq!(eng.indexes["vec"].src_side.vec_dim(b), Some(6));
4680 assert_eq!(eng.indexes["vec"].dst_side.vec_dim(b), Some(6));
4681 assert!(eng.indexes["vec"].src_side.vec_ckpts(b).is_some());
4683 assert_eq!(prov_pairs(&eng, "vec"), BTreeSet::from([(b, c), (c, b)]));
4685
4686 let wrong_live = vec![2.0f64, 0.0, 0.0, 0.0, 0.0, 0.0]; let gate_result = eng.indexes["vec"].src_side.fresh_ckpts_for(b, &wrong_live);
4690 assert!(
4691 gate_result.is_none(),
4692 "freshness gate must reject a mismatched-norm live vector"
4693 );
4694
4695 let correct_live = vec![1.0f64, 0.0, 0.0, 0.0, 0.0, 0.0];
4697 let gate_result = eng.indexes["vec"]
4698 .src_side
4699 .fresh_ckpts_for(b, &correct_live);
4700 assert!(
4701 gate_result.is_some(),
4702 "freshness gate must accept the matching live vector"
4703 );
4704 }
4705
4706 #[test]
4715 fn vector_early_exit_razor_dim1536() {
4716 const MIN: f64 = 0.85;
4717 const DIM: usize = 1536;
4718 let target = MIN + 5e-13;
4720 let inv_sqrt = 1.0 / (DIM as f64).sqrt();
4721
4722 let a: Vec<f64> = vec![inv_sqrt; DIM];
4724
4725 let perp_scale = (1.0 - target * target).sqrt() / (2.0f64).sqrt();
4731 let mut b: Vec<f64> = vec![target * inv_sqrt; DIM];
4732 b[0] += perp_scale;
4733 b[1] -= perp_scale;
4734
4735 let def = RuleDef {
4736 name: "razor".into(),
4737 src_label: "Doc".into(),
4738 dst_label: "Doc".into(),
4739 predicate: Predicate::VectorSimilar {
4740 field: "emb".into(),
4741 min: MIN,
4742 },
4743 edge_type: "SIM".into(),
4744 weight_prop: None,
4745 max_edges: None,
4746 approximate: false,
4747 via_label: None,
4748 via_edge: None,
4749 via_dir: None,
4750 };
4751
4752 let build_fx = || {
4754 let mut fx = Fx::new();
4755 let na = fx.add("Doc", "razor_a", vec![("emb", emb_val2(&a))]);
4756 let nb = fx.add("Doc", "razor_b", vec![("emb", emb_val2(&b))]);
4757 (fx, na, nb)
4758 };
4759
4760 let (mut fx_on, na, nb) = build_fx();
4761 let (mut fx_off, _, _) = build_fx();
4762 let (fx_oracle, _, _) = build_fx();
4763
4764 let mut eng_on = RuleEngine::new();
4766 {
4767 let mut g = fx_on.g();
4768 eng_on.create_rule(def.clone(), &mut g).unwrap();
4769 }
4770 let edges_on = prov_pairs(&eng_on, "razor");
4771 assert!(
4772 edges_on.contains(&(na, nb)),
4773 "razor pair razor_a→razor_b must be present with early-exit ON (cos={target:.15}, min={MIN})"
4774 );
4775 assert!(
4776 edges_on.contains(&(nb, na)),
4777 "razor pair razor_b→razor_a must be present with early-exit ON"
4778 );
4779
4780 let mut eng_off = RuleEngine::new();
4782 {
4783 let mut g = fx_off.g();
4784 with_vector_early_exit(false, || {
4785 eng_off.create_rule(def.clone(), &mut g).unwrap();
4786 });
4787 }
4788 let edges_off = prov_pairs(&eng_off, "razor");
4789 assert_eq!(
4790 edges_on, edges_off,
4791 "razor dim=1536: early-exit ON vs OFF must produce identical edges"
4792 );
4793
4794 let ids = [na, nb];
4796 let mut oracle = BTreeSet::new();
4797 for &s in &ids {
4798 for &d in &ids {
4799 if s == d {
4800 continue;
4801 }
4802 let skey = fx_oracle.ids.key_of(s).unwrap();
4803 let dkey = fx_oracle.ids.key_of(d).unwrap();
4804 let sg = |f: &str| fx_oracle.props.get(s, f).cloned();
4805 let dg = |f: &str| fx_oracle.props.get(d, f).cloned();
4806 if evaluate(
4807 &def.predicate,
4808 &NodeView {
4809 key: skey,
4810 props: &sg,
4811 },
4812 &NodeView {
4813 key: dkey,
4814 props: &dg,
4815 },
4816 )
4817 .is_some()
4818 {
4819 oracle.insert((s, d));
4820 }
4821 }
4822 }
4823 assert_eq!(
4824 edges_on, oracle,
4825 "razor dim=1536: early-exit ON vs brute-force oracle must be identical"
4826 );
4827 }
4828}