1use crate::def::{
2 evaluate, is_keymatch_rooted, predicate_contains_keymatch, NodeView, Predicate, RuleDef,
3 MAX_KEYMATCH_LIST,
4};
5use crate::hnsw::HnswIndex;
6use crate::index::{
7 candidate_spec, candidate_spec_approx_with_k, ivf_drift_rebuild_threshold, CandidateSpec,
8 RuleIndex,
9};
10use core_storage::v8::encode::{decode_ivf_bytes, decode_provenance_bytes};
11use core_storage::v8::seam::{ColumnsView, TopologyView};
12use core_storage::{EdgeProps, IdMap, Interner, Topology, Value};
13
14fn decode_ivf_bytes_to_export(bytes: &[u8]) -> BTreeMap<String, RuleIvfExport> {
17 decode_ivf_bytes(bytes)
18 .into_iter()
19 .map(|(name, ps)| {
20 (
21 name,
22 (
23 (ps.src.centroids, ps.src.clusters, ps.src.drift),
24 (ps.dst.centroids, ps.dst.clusters, ps.dst.drift),
25 ),
26 )
27 })
28 .collect()
29}
30use std::collections::{BTreeMap, BTreeSet};
31use std::sync::{Mutex, OnceLock};
32
33#[derive(Debug, Clone)]
40pub struct EngineEdgeDelta {
41 pub rule: String,
42 pub src_key: String,
44 pub dst_key: String,
46 pub edge_type: String,
48 pub etype_sym: u32,
50 pub src_id: u32,
52 pub dst_id: u32,
54 pub fired: bool,
56}
57
58#[cfg(test)]
59pub use crate::index::{with_ivf_drift_rebuild, with_vector_dim_reject, with_vector_early_exit};
60
61#[cfg(test)]
64pub(crate) static PEAK_DESIRED_PAIRS: std::sync::atomic::AtomicUsize =
65 std::sync::atomic::AtomicUsize::new(0);
66
67#[cfg(test)]
68pub(crate) fn record_desired_len(n: usize) {
69 use std::sync::atomic::Ordering;
70 let mut cur = PEAK_DESIRED_PAIRS.load(Ordering::Relaxed);
71 while n > cur {
72 match PEAK_DESIRED_PAIRS.compare_exchange_weak(cur, n, Ordering::Relaxed, Ordering::Relaxed)
73 {
74 Ok(_) => break,
75 Err(actual) => cur = actual,
76 }
77 }
78}
79
80pub struct GraphMut<'a> {
82 pub ids: &'a IdMap,
83 pub syms: &'a mut Interner,
84 pub labels: &'a [u32],
85 pub props: ColumnsView<'a>,
86 pub topo: &'a mut Topology,
90 pub base_topo: Option<&'a core_storage::v8::layout::ArchivedCsr>,
93 pub edge_props: &'a mut EdgeProps,
94}
95
96impl GraphMut<'_> {
97 pub fn neighbors(
106 &self,
107 etype: u32,
108 dir: core_storage::Direction,
109 v: u32,
110 ) -> std::borrow::Cow<'_, [u32]> {
111 match self.base_topo {
112 None => TopologyView::owned(self.topo).neighbors(etype, dir, v),
113 Some(base) => TopologyView::with_base(self.topo, base).neighbors(etype, dir, v),
114 }
115 }
116}
117
118pub const DEFAULT_MAX_EDGES: u64 = 1_000_000;
120
121pub const MAX_CHAIN_DEPTH: usize = 4;
130
131type Triple = (u32, u32, u32);
133type Touch = (u32, u32, u32, u32);
135
136struct ChainScope {
139 cursor: usize,
141 prev_emit: bool,
143 active: bool,
146}
147
148pub type SideIvfExport = (Vec<Vec<f64>>, BTreeMap<u32, usize>, u64);
151pub type RuleIvfExport = (SideIvfExport, SideIvfExport);
153
154type HnswBlobMap = BTreeMap<String, (Vec<u8>, Vec<u8>)>;
156type LazyHnswMap = BTreeMap<String, (Option<HnswIndex>, Option<HnswIndex>)>;
158
159#[derive(Debug, Default)]
167struct LazyProvenance {
168 provenance: BTreeMap<String, BTreeSet<Triple>>,
169 by_node: BTreeMap<u32, BTreeSet<Touch>>,
170 intern_rule: Vec<String>,
171}
172
173#[derive(Debug, Default)]
174pub struct RuleEngine {
175 rules: BTreeMap<String, RuleDef>,
176 indexes: BTreeMap<String, RuleIndex>,
177 provenance: BTreeMap<String, BTreeSet<Triple>>,
178 owned: BTreeSet<Triple>,
179 by_node: BTreeMap<u32, BTreeSet<Touch>>,
182 rule_intern: BTreeMap<String, u32>,
187 intern_rule: Vec<String>,
188 tripped: BTreeMap<String, bool>,
189 fires: BTreeMap<String, u64>,
190 pending_deltas: Vec<EngineEdgeDelta>,
197 emit_deltas: bool,
210 rebuild_needed: BTreeSet<String>,
214 indexes_populated: bool,
220 retained_hnsw_blobs: Mutex<HnswBlobMap>,
229 retained_ivf_bytes: Mutex<Option<Vec<u8>>>,
237 retained_provenance_bytes: Mutex<Option<Vec<u8>>>,
246 lazy_provenance: OnceLock<LazyProvenance>,
254 lazy_hnsw: OnceLock<LazyHnswMap>,
263 chain_depth: usize,
267 chain_fired: BTreeSet<(u32, u32)>,
273 doomed: Option<u32>,
282 chain_truncations: u64,
285 hnsw_builds: u64,
293}
294
295fn candidate_spec_for(def: &RuleDef) -> CandidateSpec<'_> {
302 if def.approximate {
303 let k = def.max_edges.map(|me| me.max(128)).unwrap_or(128) as usize;
307 candidate_spec_approx_with_k(&def.predicate, k)
308 } else {
309 candidate_spec(&def.predicate)
310 }
311}
312
313fn src_lookup_spec_for(def: &RuleDef) -> CandidateSpec<'_> {
320 if is_keymatch_rooted(&def.predicate) {
321 let field =
322 keymatch_field(&def.predicate).expect("keymatch-rooted predicate has a KeyMatch field");
323 CandidateSpec::ScalarOrElements { field }
324 } else {
325 candidate_spec_for(def)
326 }
327}
328
329fn predicate_covers_field(p: &Predicate, field: &str) -> bool {
331 match p {
332 Predicate::VectorSimilar { field: f, .. } => f == field,
333 Predicate::All(parts) | Predicate::Any(parts) => {
334 parts.iter().any(|q| predicate_covers_field(q, field))
335 }
336 _ => false,
337 }
338}
339
340fn keymatch_field(p: &Predicate) -> Option<&str> {
342 match p {
343 Predicate::KeyMatch { field } => Some(field),
344 Predicate::All(parts) => parts.first().and_then(keymatch_field),
345 Predicate::Any(_) => None,
346 _ => None,
347 }
348}
349
350fn all_node_ids(g: &GraphMut<'_>) -> BTreeSet<u32> {
361 (0..g.ids.len() as u32).collect()
362}
363
364fn compute_desired(
367 def: &RuleDef,
368 index: &RuleIndex,
369 n: u32,
370 on_src_side: bool,
371 g: &GraphMut<'_>,
372) -> BTreeMap<(u32, u32), f64> {
373 let (my_label, other_label) = if on_src_side {
374 (&def.src_label, &def.dst_label)
375 } else {
376 (&def.dst_label, &def.src_label)
377 };
378
379 let Some(my_sym) = g.syms.get(my_label) else {
380 return BTreeMap::new();
381 };
382 if g.labels.get(n as usize).copied() != Some(my_sym) {
383 return BTreeMap::new();
384 }
385 let other_sym = g.syms.get(other_label);
386
387 let n_key = match g.ids.key_of(n) {
388 Some(k) => k,
389 None => return BTreeMap::new(),
390 };
391 let n_get = |f: &str| g.props.get(n, f).map(|vr| vr.into_value());
392
393 let spec = candidate_spec_for(def);
394 let candidates: BTreeSet<u32> = if on_src_side {
395 if is_keymatch_rooted(&def.predicate) {
396 let field = keymatch_field(&def.predicate).expect("ByKey always comes from KeyMatch");
400 match n_get(field) {
401 Some(Value::Str(ref target_key)) => match g.ids.get(target_key) {
402 Some(dst_id) => std::iter::once(dst_id).collect(),
403 None => BTreeSet::new(),
404 },
405 Some(Value::List(items)) => items
409 .iter()
410 .take(MAX_KEYMATCH_LIST)
411 .filter_map(|v| match v {
412 Value::Str(target_key) => g.ids.get(target_key),
413 _ => None,
414 })
415 .collect(),
416 _ => BTreeSet::new(),
417 }
418 } else if predicate_contains_keymatch(&def.predicate) {
419 all_node_ids(g)
420 } else {
421 index.dst_side.candidates(&spec, &n_get)
422 }
423 } else {
424 let src_spec = src_lookup_spec_for(def);
426 if is_keymatch_rooted(&def.predicate) {
427 let key_getter = |_: &str| Some(Value::Str(n_key.to_string()));
430 index.src_side.candidates(&src_spec, &key_getter)
431 } else if predicate_contains_keymatch(&def.predicate) {
432 all_node_ids(g)
433 } else {
434 index.src_side.candidates(&src_spec, &n_get)
435 }
436 };
437
438 let n_early_exit_hint: Option<(Vec<f64>, f64, [f64; 8])> = if !def.approximate {
453 if let Predicate::VectorSimilar { field, .. } = &def.predicate {
454 if crate::index::vector_early_exit_enabled() {
455 let n_side = if on_src_side {
456 &index.src_side
457 } else {
458 &index.dst_side
459 };
460 if let Some(vn_v) = n_get(field) {
461 if let Some(vn) = crate::index::as_numeric_list(&vn_v) {
462 if let Some((norm_n, ckpts_n)) = n_side.fresh_ckpts_for(n, &vn) {
463 Some((vn, norm_n, *ckpts_n))
464 } else {
465 None
466 }
467 } else {
468 None
469 }
470 } else {
471 None
472 }
473 } else {
474 None
475 }
476 } else {
477 None
478 }
479 } else {
480 None
481 };
482
483 let mut out = BTreeMap::new();
484 for m in candidates {
485 if m == n {
486 continue; }
488 if g.labels.get(m as usize).copied() != other_sym {
489 continue; }
491 let m_key = match g.ids.key_of(m) {
492 Some(k) => k,
493 None => continue,
494 };
495 let m_get = |f: &str| g.props.get(m, f).map(|vr| vr.into_value());
496 let (s_view, d_view, s_id, d_id) = if on_src_side {
497 (
498 NodeView {
499 key: n_key,
500 props: &n_get,
501 },
502 NodeView {
503 key: m_key,
504 props: &m_get,
505 },
506 n,
507 m,
508 )
509 } else {
510 (
511 NodeView {
512 key: m_key,
513 props: &m_get,
514 },
515 NodeView {
516 key: n_key,
517 props: &n_get,
518 },
519 m,
520 n,
521 )
522 };
523
524 if let (Some((ref vn, norm_n, ckpts_n)), Predicate::VectorSimilar { field, min }) =
526 (&n_early_exit_hint, &def.predicate)
527 {
528 let m_side = if on_src_side {
529 &index.dst_side
530 } else {
531 &index.src_side
532 };
533 if let Some(vm_v) = m_get(field) {
534 if let Some(vm) = crate::index::as_numeric_list(&vm_v) {
535 if let Some((norm_m, ckpts_m)) = m_side.fresh_ckpts_for(m, &vm) {
536 let (va, ckpts_a, na, vb, ckpts_b, nb) = if on_src_side {
537 (
538 vn.as_slice(),
539 ckpts_n,
540 *norm_n,
541 vm.as_slice(),
542 ckpts_m,
543 norm_m,
544 )
545 } else {
546 (
547 vm.as_slice(),
548 ckpts_m,
549 norm_m,
550 vn.as_slice(),
551 ckpts_n,
552 *norm_n,
553 )
554 };
555 match crate::def::cosine_early_exit(va, vb, ckpts_a, ckpts_b, na, nb, *min)
556 {
557 None => continue, Some(score) => {
559 out.insert((s_id, d_id), score);
560 continue; }
562 }
563 }
564 }
565 }
566 }
567
568 if let Some(score) = evaluate(&def.predicate, &s_view, &d_view) {
569 out.insert((s_id, d_id), score);
570 }
571 }
572 #[cfg(test)]
573 record_desired_len(out.len());
574 out
575}
576
577fn compute_desired_via(
617 def: &RuleDef,
618 index: Option<&RuleIndex>,
619 anchor: ViaAnchor,
620 doomed: Option<u32>,
621 g: &GraphMut<'_>,
622) -> BTreeMap<(u32, u32), f64> {
623 let via_label = def.via_label.as_deref().unwrap();
624 let via_edge_str = def.via_edge.as_deref().unwrap();
625 let via_dir = def.via_dir.unwrap_or(core_storage::Direction::Out);
626
627 let src_sym = match g.syms.get(&def.src_label) {
628 Some(s) => s,
629 None => return BTreeMap::new(),
630 };
631 let via_sym = match g.syms.get(via_label) {
632 Some(s) => s,
633 None => return BTreeMap::new(),
634 };
635 let dst_sym = match g.syms.get(&def.dst_label) {
636 Some(s) => s,
637 None => return BTreeMap::new(),
638 };
639 let via_etype = match g.syms.get(via_edge_str) {
640 Some(e) => e,
641 None => return BTreeMap::new(),
642 };
643
644 let srcs: Vec<u32> = match anchor {
646 ViaAnchor::Src(src_id) => {
647 if Some(src_id) == doomed {
648 return BTreeMap::new();
649 }
650 if g.labels.get(src_id as usize).copied() == Some(src_sym) {
651 vec![src_id]
652 } else {
653 return BTreeMap::new();
654 }
655 }
656 ViaAnchor::Dst(_) => {
657 (0..g.ids.len() as u32)
659 .filter(|&id| {
660 Some(id) != doomed
661 && matches!(
662 g.labels.get(id as usize).copied(),
663 Some(s) if s != u32::MAX && s == src_sym
664 )
665 })
666 .collect()
667 }
668 };
669
670 let anchored_dst: Option<u32> = match anchor {
672 ViaAnchor::Dst(dst_id) => {
673 if Some(dst_id) == doomed {
674 return BTreeMap::new();
675 }
676 if g.labels.get(dst_id as usize).copied() == Some(dst_sym) {
677 Some(dst_id)
678 } else {
679 return BTreeMap::new();
680 }
681 }
682 _ => None,
683 };
684
685 let ceiling = matches!(def.predicate, Predicate::Overlap { .. });
689
690 let mut out = BTreeMap::new();
691
692 for src in srcs {
693 let _src_key = match g.ids.key_of(src) {
694 Some(k) => k,
695 None => continue,
696 };
697 let via_neighbors: Vec<u32> = g
699 .neighbors(via_etype, via_dir, src)
700 .iter()
701 .copied()
702 .filter(|&v| Some(v) != doomed && g.labels.get(v as usize).copied() == Some(via_sym))
703 .collect();
704
705 if via_neighbors.is_empty() {
706 continue;
707 }
708 let via_set: BTreeSet<u32> = if ceiling {
710 via_neighbors.iter().copied().collect()
711 } else {
712 BTreeSet::new()
713 };
714
715 let indexed = index.filter(|_| !predicate_contains_keymatch(&def.predicate));
723 let dsts: Vec<u32> = if let Some(dst_id) = anchored_dst {
724 vec![dst_id]
725 } else if let Some(idx) = indexed {
726 let spec = candidate_spec_for(def);
727 let mut set = BTreeSet::new();
728 for &via_id in &via_neighbors {
729 let via_get = |f: &str| g.props.get(via_id, f).map(|vr| vr.into_value());
730 set.extend(idx.dst_side.candidates(&spec, &via_get));
731 }
732 set.into_iter()
733 .filter(|&id| {
734 id != src
735 && Some(id) != doomed
736 && matches!(
737 g.labels.get(id as usize).copied(),
738 Some(s) if s != u32::MAX && s == dst_sym
739 )
740 })
741 .collect()
742 } else {
743 (0..g.ids.len() as u32)
744 .filter(|&id| {
745 id != src
746 && Some(id) != doomed
747 && matches!(
748 g.labels.get(id as usize).copied(),
749 Some(s) if s != u32::MAX && s == dst_sym
750 )
751 })
752 .collect()
753 };
754
755 for dst in dsts {
756 if dst == src {
757 continue; }
759 let dst_key = match g.ids.key_of(dst) {
760 Some(k) => k,
761 None => continue,
762 };
763 let dst_get = |f: &str| g.props.get(dst, f).map(|vr| vr.into_value());
764 let dst_view = NodeView {
765 key: dst_key,
766 props: &dst_get,
767 };
768
769 let mut best: Option<f64> = None;
782 let first = (ceiling && via_set.contains(&dst)).then_some(dst);
783 for via_id in first.into_iter().chain(via_neighbors.iter().copied()) {
784 let via_key = match g.ids.key_of(via_id) {
785 Some(k) => k,
786 None => continue,
787 };
788 let via_get = |f: &str| g.props.get(via_id, f).map(|vr| vr.into_value());
789 let via_view = NodeView {
790 key: via_key,
791 props: &via_get,
792 };
793 if let Some(score) = evaluate(&def.predicate, &via_view, &dst_view) {
794 best = Some(match best {
795 None => score,
796 Some(prev) => prev.max(score),
797 });
798 if ceiling && best == Some(1.0) {
799 break;
800 }
801 }
802 }
803
804 if let Some(score) = best {
805 out.insert((src, dst), score);
806 }
807 }
808 }
809
810 out
811}
812
813enum ViaAnchor {
815 Src(u32),
817 Dst(u32),
820}
821
822fn edge_budget(def: &RuleDef) -> u64 {
823 def.max_edges.unwrap_or(DEFAULT_MAX_EDGES)
826}
827
828pub(crate) fn filter_src_top_k(
850 per_src: BTreeMap<(u32, u32), f64>,
851 k: u64,
852 ids: &core_storage::IdMap,
853) -> BTreeMap<(u32, u32), f64> {
854 if per_src.len() as u64 <= k {
855 return per_src;
856 }
857 let mut candidates: Vec<((u32, u32), f64)> = per_src.into_iter().collect();
858 candidates.sort_by(|&((_, da), sa), &((_, db), sb)| {
860 sb.total_cmp(&sa).then_with(|| {
861 let ka = ids.key_of(da).unwrap_or("");
862 let kb = ids.key_of(db).unwrap_or("");
863 ka.cmp(kb)
864 })
865 });
866 candidates.truncate(k as usize);
867 candidates.into_iter().collect()
868}
869
870fn apply_per_src_top_k(
877 def: &RuleDef,
878 src: u32,
879 desired_from_src: BTreeMap<(u32, u32), f64>,
880 prov: &mut ProvSets<'_>,
881 g: &mut GraphMut<'_>,
882) {
883 let et = g.syms.intern(&def.edge_type);
884
885 let current: Vec<Triple> = {
889 let rid = prov.rule_intern.get(&def.name).copied();
890 prov.by_node
891 .get(&src)
892 .into_iter()
893 .flatten()
894 .filter(|(r, t, s, _d)| Some(*r) == rid && *t == et && *s == src)
895 .map(|(_, t, s, d)| (*t, *s, *d))
896 .collect()
897 };
898
899 for (t, s, d) in current {
901 if !desired_from_src.contains_key(&(s, d)) {
902 g.topo.remove_edge(t, s, d);
903 g.edge_props.remove_edge(t, s, d);
904 prov.remove(&def.name, (t, s, d), g.ids, g.syms);
905 }
906 }
907
908 for ((s, d), score) in &desired_from_src {
910 let triple = (et, *s, *d);
911 let already = prov.contains(&triple);
912 if !already {
913 let newly = g.topo.add_edge(et, *s, *d);
914 if newly {
915 prov.insert(&def.name, triple, g.ids, g.syms);
916 }
917 }
918 let is_owned = already || prov.contains(&triple);
919 if is_owned {
920 if let Some(p) = &def.weight_prop {
921 g.edge_props.set(et, *s, *d, p, Value::Float(*score));
922 }
923 }
924 }
925}
926
927fn intern_rule(intern: &mut BTreeMap<String, u32>, names: &mut Vec<String>, rule: &str) -> u32 {
929 if let Some(&id) = intern.get(rule) {
930 return id;
931 }
932 let id = names.len() as u32;
933 intern.insert(rule.to_string(), id);
934 names.push(rule.to_string());
935 id
936}
937
938type ByNodeRebuild = (
939 BTreeMap<u32, BTreeSet<Touch>>,
940 BTreeMap<String, u32>,
941 Vec<String>,
942);
943
944fn rebuild_by_node(provenance: &BTreeMap<String, BTreeSet<Triple>>) -> ByNodeRebuild {
945 let mut by_node = BTreeMap::new();
946 let mut intern = BTreeMap::new();
947 let mut names = Vec::new();
948 for (rule, set) in provenance {
949 let rid = intern_rule(&mut intern, &mut names, rule);
950 for &triple in set {
951 touch_insert(&mut by_node, rid, triple);
952 }
953 }
954 (by_node, intern, names)
955}
956
957fn touch_insert(by_node: &mut BTreeMap<u32, BTreeSet<Touch>>, rid: u32, triple: Triple) {
958 let (t, s, d) = triple;
959 let entry = (rid, t, s, d);
960 by_node.entry(s).or_default().insert(entry);
961 if s != d {
962 by_node.entry(d).or_default().insert(entry);
963 }
964}
965
966fn touch_remove(by_node: &mut BTreeMap<u32, BTreeSet<Touch>>, rid: u32, triple: Triple) {
967 let (t, s, d) = triple;
968 let entry = (rid, t, s, d);
969 if let Some(set) = by_node.get_mut(&s) {
970 set.remove(&entry);
971 if set.is_empty() {
972 by_node.remove(&s);
973 }
974 }
975 if s != d {
976 if let Some(set) = by_node.get_mut(&d) {
977 set.remove(&entry);
978 if set.is_empty() {
979 by_node.remove(&d);
980 }
981 }
982 }
983}
984
985#[cfg(test)]
986fn resolve_by_node(
987 by_node: &BTreeMap<u32, BTreeSet<Touch>>,
988 names: &[String],
989) -> BTreeMap<u32, BTreeSet<(String, Triple)>> {
990 by_node
991 .iter()
992 .map(|(&n, set)| {
993 let resolved = set
994 .iter()
995 .map(|&(rid, t, s, d)| (names[rid as usize].clone(), (t, s, d)))
996 .collect();
997 (n, resolved)
998 })
999 .collect()
1000}
1001
1002struct ProvSets<'a> {
1005 set: &'a mut BTreeSet<Triple>,
1006 owned: &'a mut BTreeSet<Triple>,
1007 by_node: &'a mut BTreeMap<u32, BTreeSet<Touch>>,
1008 rule_intern: &'a mut BTreeMap<String, u32>,
1009 intern_rule: &'a mut Vec<String>,
1010 deltas: &'a mut Vec<EngineEdgeDelta>,
1014 emit: bool,
1017}
1018
1019impl ProvSets<'_> {
1020 fn insert(&mut self, rule: &str, triple: Triple, ids: &IdMap, syms: &Interner) -> bool {
1024 if !self.set.insert(triple) {
1025 return false;
1026 }
1027 self.owned.insert(triple);
1028 let rid = intern_rule(self.rule_intern, self.intern_rule, rule);
1029 touch_insert(self.by_node, rid, triple);
1030 let (etype, src, dst) = triple;
1031 if self.emit {
1032 if let (Some(sk), Some(dk), Some(et)) =
1033 (ids.key_of(src), ids.key_of(dst), syms.resolve(etype))
1034 {
1035 self.deltas.push(EngineEdgeDelta {
1036 rule: rule.to_string(),
1037 src_key: sk.to_string(),
1038 dst_key: dk.to_string(),
1039 edge_type: et.to_string(),
1040 etype_sym: etype,
1041 src_id: src,
1042 dst_id: dst,
1043 fired: true,
1044 });
1045 }
1046 }
1047 true
1048 }
1049
1050 fn remove(&mut self, rule: &str, triple: Triple, ids: &IdMap, syms: &Interner) -> bool {
1051 if !self.set.remove(&triple) {
1052 return false;
1053 }
1054 self.owned.remove(&triple);
1055 let rid = intern_rule(self.rule_intern, self.intern_rule, rule);
1056 touch_remove(self.by_node, rid, triple);
1057 let (etype, src, dst) = triple;
1058 if self.emit {
1059 if let (Some(sk), Some(dk), Some(et)) =
1060 (ids.key_of(src), ids.key_of(dst), syms.resolve(etype))
1061 {
1062 self.deltas.push(EngineEdgeDelta {
1063 rule: rule.to_string(),
1064 src_key: sk.to_string(),
1065 dst_key: dk.to_string(),
1066 edge_type: et.to_string(),
1067 etype_sym: etype,
1068 src_id: src,
1069 dst_id: dst,
1070 fired: false,
1071 });
1072 }
1073 }
1074 true
1075 }
1076
1077 fn contains(&self, triple: &Triple) -> bool {
1078 self.set.contains(triple)
1079 }
1080
1081 fn len(&self) -> usize {
1082 self.set.len()
1083 }
1084}
1085
1086fn apply_desired(
1097 def: &RuleDef,
1098 desired: BTreeMap<(u32, u32), f64>,
1099 retract_touching: Option<u32>,
1100 prov: &mut ProvSets<'_>,
1101 tripped: &mut bool,
1102 g: &mut GraphMut<'_>,
1103) {
1104 let budget = edge_budget(def);
1105 let et = g.syms.intern(&def.edge_type);
1106
1107 let current: Vec<Triple> = match retract_touching {
1108 None => prov
1109 .set
1110 .iter()
1111 .filter(|(t, _, _)| *t == et)
1112 .copied()
1113 .collect(),
1114 Some(n) => {
1115 let rid = prov.rule_intern.get(&def.name).copied();
1116 prov.by_node
1117 .get(&n)
1118 .into_iter()
1119 .flatten()
1120 .filter(|(r, t, _, _)| Some(*r) == rid && *t == et)
1121 .map(|(_, t, s, d)| (*t, *s, *d))
1122 .collect()
1123 }
1124 };
1125
1126 for (t, s, d) in current {
1127 if !desired.contains_key(&(s, d)) {
1128 g.topo.remove_edge(t, s, d);
1129 g.edge_props.remove_edge(t, s, d);
1130 prov.remove(&def.name, (t, s, d), g.ids, g.syms);
1131 }
1132 }
1133
1134 for ((s, d), score) in desired {
1135 let triple = (et, s, d);
1136 let already = prov.contains(&triple);
1137 if !already {
1138 if *tripped || prov.len() as u64 >= budget {
1139 *tripped = true;
1140 continue;
1141 }
1142 let newly = g.topo.add_edge(et, s, d);
1143 if newly {
1144 prov.insert(&def.name, triple, g.ids, g.syms);
1145 }
1146 }
1147 let is_owned_here = already || prov.contains(&triple);
1151 if is_owned_here {
1152 if let Some(p) = &def.weight_prop {
1153 g.edge_props.set(et, s, d, p, Value::Float(score));
1154 }
1155 }
1156 }
1157}
1158
1159#[cfg(test)]
1167#[allow(dead_code)]
1168fn compute_full_desired(
1169 def: &RuleDef,
1170 index: &RuleIndex,
1171 g: &GraphMut<'_>,
1172) -> BTreeMap<(u32, u32), f64> {
1173 let mut desired = BTreeMap::new();
1174 let src_sym = g.syms.get(&def.src_label);
1175 for id in 0..g.ids.len() as u32 {
1176 let label_sym = match g.labels.get(id as usize).copied() {
1177 Some(s) if s != u32::MAX => s,
1178 _ => continue,
1179 };
1180 if src_sym == Some(label_sym) {
1181 desired.extend(compute_desired(def, index, id, true, g));
1182 #[cfg(test)]
1183 record_desired_len(desired.len());
1184 }
1185 }
1186 desired
1187}
1188
1189fn pair_still_desired(def: &RuleDef, s: u32, d: u32, g: &GraphMut<'_>) -> bool {
1196 let src_sym = match g.syms.get(&def.src_label) {
1197 Some(sym) => sym,
1198 None => return false,
1199 };
1200 let dst_sym = match g.syms.get(&def.dst_label) {
1201 Some(sym) => sym,
1202 None => return false,
1203 };
1204 if g.labels.get(s as usize).copied() != Some(src_sym) {
1205 return false;
1206 }
1207 if g.labels.get(d as usize).copied() != Some(dst_sym) {
1208 return false;
1209 }
1210 let s_key = match g.ids.key_of(s) {
1211 Some(k) => k,
1212 None => return false,
1213 };
1214 let d_key = match g.ids.key_of(d) {
1215 Some(k) => k,
1216 None => return false,
1217 };
1218 let s_get = |f: &str| g.props.get(s, f).map(|vr| vr.into_value());
1219 let d_get = |f: &str| g.props.get(d, f).map(|vr| vr.into_value());
1220 evaluate(
1221 &def.predicate,
1222 &NodeView {
1223 key: s_key,
1224 props: &s_get,
1225 },
1226 &NodeView {
1227 key: d_key,
1228 props: &d_get,
1229 },
1230 )
1231 .is_some()
1232}
1233
1234fn count_desired_up_to(def: &RuleDef, index: &RuleIndex, limit: u64, g: &GraphMut<'_>) -> u64 {
1239 let mut count = 0u64;
1240 let src_sym = g.syms.get(&def.src_label);
1241 for id in 0..g.ids.len() as u32 {
1242 let label_sym = match g.labels.get(id as usize).copied() {
1243 Some(s) if s != u32::MAX => s,
1244 _ => continue,
1245 };
1246 if src_sym != Some(label_sym) {
1247 continue;
1248 }
1249 count += compute_desired(def, index, id, true, g).len() as u64;
1250 if count > limit {
1251 return count;
1252 }
1253 }
1254 count
1255}
1256
1257fn apply_streaming_create(
1279 def: &RuleDef,
1280 index: &RuleIndex,
1281 prov: &mut ProvSets<'_>,
1282 tripped: &mut bool,
1283 g: &mut GraphMut<'_>,
1284) {
1285 let budget = edge_budget(def);
1286 let et = g.syms.intern(&def.edge_type);
1287 let src_sym = g.syms.get(&def.src_label);
1288
1289 'outer: for id in 0..g.ids.len() as u32 {
1290 let label_sym = match g.labels.get(id as usize).copied() {
1291 Some(s) if s != u32::MAX => s,
1292 _ => continue,
1293 };
1294 if src_sym != Some(label_sym) {
1295 continue;
1296 }
1297 let per_src = compute_desired(def, index, id, true, g);
1298 for ((s, d), score) in per_src {
1299 let triple = (et, s, d);
1300 let already = prov.contains(&triple);
1305 if !already {
1306 if *tripped || prov.len() as u64 >= budget {
1307 *tripped = true;
1308 break 'outer;
1309 }
1310 let newly = g.topo.add_edge(et, s, d);
1311 if newly {
1312 prov.insert(&def.name, triple, g.ids, g.syms);
1313 }
1314 }
1315 let is_owned_here = already || prov.contains(&triple);
1316 if is_owned_here {
1317 if let Some(p) = &def.weight_prop {
1318 g.edge_props.set(et, s, d, p, Value::Float(score));
1319 }
1320 }
1321 }
1322 }
1323}
1324
1325fn apply_streaming_create_top_k(
1332 def: &RuleDef,
1333 k: u64,
1334 index: &RuleIndex,
1335 prov: &mut ProvSets<'_>,
1336 g: &mut GraphMut<'_>,
1337) {
1338 let src_sym = g.syms.get(&def.src_label);
1339 for id in 0..g.ids.len() as u32 {
1340 let label_sym = match g.labels.get(id as usize).copied() {
1341 Some(s) if s != u32::MAX => s,
1342 _ => continue,
1343 };
1344 if src_sym != Some(label_sym) {
1345 continue;
1346 }
1347 let per_src = compute_desired(def, index, id, true, g);
1348 let top_k = filter_src_top_k(per_src, k, g.ids);
1349 apply_per_src_top_k(def, id, top_k, prov, g);
1350 }
1351}
1352
1353fn apply_streaming_rebuild_top_k(
1360 def: &RuleDef,
1361 k: u64,
1362 index: &RuleIndex,
1363 doomed: Option<u32>,
1364 prov: &mut ProvSets<'_>,
1365 g: &mut GraphMut<'_>,
1366) {
1367 let et = g.syms.intern(&def.edge_type);
1368
1369 let existing_srcs: BTreeSet<u32> = prov
1372 .set
1373 .iter()
1374 .filter(|(t, _, _)| *t == et)
1375 .map(|(_, s, _)| *s)
1376 .collect();
1377
1378 let src_sym = g.syms.get(&def.src_label);
1379 let mut all_srcs: BTreeSet<u32> = existing_srcs;
1380 for id in 0..g.ids.len() as u32 {
1381 let label_sym = match g.labels.get(id as usize).copied() {
1382 Some(s) if s != u32::MAX => s,
1383 _ => continue,
1384 };
1385 if src_sym == Some(label_sym) {
1386 all_srcs.insert(id);
1387 }
1388 }
1389
1390 for src in all_srcs {
1391 let desired_src = if def.via_edge.is_some() {
1397 compute_desired_via(def, None, ViaAnchor::Src(src), doomed, g)
1398 } else {
1399 compute_desired(def, index, src, true, g)
1400 };
1401 let top_k = filter_src_top_k(desired_src, k, g.ids);
1402 apply_per_src_top_k(def, src, top_k, prov, g);
1403 }
1404}
1405
1406fn apply_via_rebuild(
1420 def: &RuleDef,
1421 doomed: Option<u32>,
1422 prov: &mut ProvSets<'_>,
1423 tripped: &mut bool,
1424 g: &mut GraphMut<'_>,
1425) {
1426 let budget = edge_budget(def);
1427 let et = g.syms.intern(&def.edge_type);
1428
1429 let mut sources: BTreeSet<u32> = prov
1432 .set
1433 .iter()
1434 .filter(|(t, _, _)| *t == et)
1435 .map(|(_, s, _)| *s)
1436 .collect();
1437 let src_sym = g.syms.get(&def.src_label);
1438 for id in 0..g.ids.len() as u32 {
1439 if let Some(s) = g.labels.get(id as usize).copied() {
1440 if s != u32::MAX && Some(s) == src_sym {
1441 sources.insert(id);
1442 }
1443 }
1444 }
1445 let sources: Vec<u32> = sources.into_iter().collect();
1446
1447 let mut total: u64 = 0;
1450 for &src in &sources {
1451 total += compute_desired_via(def, None, ViaAnchor::Src(src), doomed, g).len() as u64;
1452 if total > budget {
1453 *tripped = true;
1454 return; }
1456 }
1457 *tripped = false;
1458
1459 for src in sources {
1462 let desired = compute_desired_via(def, None, ViaAnchor::Src(src), doomed, g);
1463 let current: Vec<Triple> = prov
1464 .set
1465 .iter()
1466 .filter(|&&(t, s, _)| t == et && s == src)
1467 .copied()
1468 .collect();
1469 for triple in current {
1470 let (t, s, d) = triple;
1471 if !desired.contains_key(&(s, d)) {
1472 g.topo.remove_edge(t, s, d);
1473 g.edge_props.remove_edge(t, s, d);
1474 prov.remove(&def.name, triple, g.ids, g.syms);
1475 }
1476 }
1477 for ((s, d), score) in desired {
1478 let triple = (et, s, d);
1479 let already = prov.contains(&triple);
1480 if !already && g.topo.add_edge(et, s, d) {
1481 prov.insert(&def.name, triple, g.ids, g.syms);
1482 }
1483 if already || prov.contains(&triple) {
1486 if let Some(p) = &def.weight_prop {
1487 g.edge_props.set(et, s, d, p, Value::Float(score));
1488 }
1489 }
1490 }
1491 }
1492}
1493
1494fn apply_streaming_rebuild(
1507 def: &RuleDef,
1508 index: &RuleIndex,
1509 prov: &mut ProvSets<'_>,
1510 tripped: &mut bool,
1511 g: &mut GraphMut<'_>,
1512) {
1513 let budget = edge_budget(def);
1514 let et = g.syms.intern(&def.edge_type);
1515
1516 let total = count_desired_up_to(def, index, budget, g);
1518 if total > budget {
1519 *tripped = true;
1520 return; }
1522
1523 *tripped = false;
1525
1526 let current: Vec<Triple> = prov
1529 .set
1530 .iter()
1531 .filter(|(t, _, _)| *t == et)
1532 .copied()
1533 .collect();
1534 for (t, s, d) in current {
1535 if !pair_still_desired(def, s, d, g) {
1536 g.topo.remove_edge(t, s, d);
1537 g.edge_props.remove_edge(t, s, d);
1538 prov.remove(&def.name, (t, s, d), g.ids, g.syms);
1539 }
1540 }
1541
1542 let src_sym = g.syms.get(&def.src_label);
1545 for id in 0..g.ids.len() as u32 {
1546 let label_sym = match g.labels.get(id as usize).copied() {
1547 Some(s) if s != u32::MAX => s,
1548 _ => continue,
1549 };
1550 if src_sym != Some(label_sym) {
1551 continue;
1552 }
1553 let per_src = compute_desired(def, index, id, true, g);
1554 for ((s, d), score) in per_src {
1555 let triple = (et, s, d);
1556 let already = prov.contains(&triple);
1557 if !already {
1558 let newly = g.topo.add_edge(et, s, d);
1559 if newly {
1560 prov.insert(&def.name, triple, g.ids, g.syms);
1561 }
1562 }
1563 let is_owned_here = already || prov.contains(&triple);
1564 if is_owned_here {
1565 if let Some(p) = &def.weight_prop {
1566 g.edge_props.set(et, s, d, p, Value::Float(score));
1567 }
1568 }
1569 }
1570 }
1571}
1572
1573fn bump_fires_for_participants(def: &RuleDef, g: &GraphMut<'_>, fires: &mut u64) {
1576 let src_sym = g.syms.get(&def.src_label);
1577 let dst_sym = g.syms.get(&def.dst_label);
1578 for id in 0..g.ids.len() as u32 {
1579 let label_sym = match g.labels.get(id as usize).copied() {
1580 Some(s) if s != u32::MAX => s,
1581 _ => continue,
1582 };
1583 if src_sym == Some(label_sym) || dst_sym == Some(label_sym) {
1584 *fires += 1;
1585 }
1586 }
1587}
1588
1589fn index_node_for_rule(
1591 id: u32,
1592 label_sym: u32,
1593 def: &RuleDef,
1594 index: &mut RuleIndex,
1595 syms: &Interner,
1596 props: ColumnsView<'_>,
1597) {
1598 const NONE: (BTreeSet<u32>, BTreeSet<u32>) = (BTreeSet::new(), BTreeSet::new());
1599 index_node_for_rule_skipping(id, label_sym, def, index, syms, props, &NONE);
1600}
1601
1602fn index_node_for_rule_skipping(
1606 id: u32,
1607 label_sym: u32,
1608 def: &RuleDef,
1609 index: &mut RuleIndex,
1610 syms: &Interner,
1611 props: ColumnsView<'_>,
1612 skip: &(BTreeSet<u32>, BTreeSet<u32>),
1613) {
1614 let get = |f: &str| props.get(id, f).map(|vr| vr.into_value());
1615 if syms.get(&def.src_label) == Some(label_sym) {
1616 let spec = src_lookup_spec_for(def);
1617 index.src_side.insert_skipping(&spec, id, &skip.0, &get);
1618 }
1619 if syms.get(&def.dst_label) == Some(label_sym) {
1620 let spec = candidate_spec_for(def);
1621 index.dst_side.insert_skipping(&spec, id, &skip.1, &get);
1622 }
1623}
1624
1625impl RuleEngine {
1630 pub fn new() -> Self {
1631 Self::default()
1632 }
1633
1634 fn chaining_possible(&self) -> bool {
1637 self.rules.values().any(|r| r.via_edge.is_some())
1638 }
1639
1640 fn begin_chain(&mut self) -> ChainScope {
1660 let active = self.chain_depth == 0 && self.chaining_possible();
1661 let prev_emit = self.emit_deltas;
1662 if active {
1663 self.emit_deltas = true;
1664 }
1665 ChainScope {
1666 cursor: self.pending_deltas.len(),
1667 prev_emit,
1668 active,
1669 }
1670 }
1671
1672 fn end_chain(&mut self, scope: ChainScope, g: &mut GraphMut<'_>) {
1674 if scope.active {
1675 self.chain_from(scope.cursor, g);
1676 }
1677 self.emit_deltas = scope.prev_emit;
1678 }
1679
1680 pub fn reset_chain_state(&mut self) {
1689 self.chain_depth = 0;
1690 self.chain_fired.clear();
1691 self.doomed = None;
1692 }
1693
1694 fn chain_from(&mut self, mut cursor: usize, g: &mut GraphMut<'_>) {
1701 debug_assert_eq!(self.chain_depth, 0);
1702 if self.pending_deltas.len() == cursor {
1703 return; }
1705 let via_edges: BTreeSet<String> = self
1709 .rules
1710 .values()
1711 .filter_map(|r| r.via_edge.clone())
1712 .collect();
1713 let rule_count = self.rules.len();
1714 for level in 1..=MAX_CHAIN_DEPTH {
1715 let end = self.pending_deltas.len();
1716 if end == cursor {
1717 return; }
1719 let batch: Vec<(String, u32, u32)> = self.pending_deltas[cursor..end]
1720 .iter()
1721 .filter(|d| via_edges.contains(&d.edge_type))
1722 .map(|d| (d.edge_type.clone(), d.src_id, d.dst_id))
1723 .collect();
1724 cursor = end;
1725 if batch.is_empty() {
1726 return; }
1728 self.chain_fired.clear();
1735 self.chain_depth = level;
1736 for (etype, src, dst) in batch {
1737 self.on_edge_changed_inner(&etype, src, dst, g);
1738 }
1739 self.chain_depth = 0;
1740 debug_assert_eq!(
1744 self.rules.len(),
1745 rule_count,
1746 "the rule set must not change during a chain"
1747 );
1748 }
1749 let truncated = self.pending_deltas[cursor..]
1753 .iter()
1754 .any(|d| via_edges.contains(&d.edge_type));
1755 if truncated {
1756 self.chain_truncations = self.chain_truncations.saturating_add(1);
1757 }
1758 }
1759
1760 pub fn rules(&self) -> impl Iterator<Item = &RuleDef> {
1761 self.rules.values()
1762 }
1763
1764 pub fn chain_truncations(&self) -> u64 {
1771 self.chain_truncations
1772 }
1773
1774 pub fn is_owned(&self, etype: u32, src: u32, dst: u32) -> bool {
1775 self.owned.contains(&(etype, src, dst))
1776 }
1777
1778 fn provenance_is_retained(&self) -> bool {
1783 self.retained_provenance_bytes
1784 .lock()
1785 .expect("lock poisoned")
1786 .is_some()
1787 }
1788
1789 pub fn provenance(&self) -> &BTreeMap<String, BTreeSet<(u32, u32, u32)>> {
1794 if self.provenance_is_retained() {
1795 self.ensure_provenance_loaded();
1796 &self.lazy_provenance.get().unwrap().provenance
1797 } else {
1798 &self.provenance
1799 }
1800 }
1801
1802 pub fn provenance_touching(
1807 &self,
1808 node: u32,
1809 ) -> impl Iterator<Item = (&str, u32, u32, u32)> + '_ {
1810 let use_lazy = self.provenance_is_retained();
1811 let (by_node, intern_rule): (&BTreeMap<u32, BTreeSet<Touch>>, &Vec<String>) = if use_lazy {
1812 self.ensure_provenance_loaded();
1813 let lp = self.lazy_provenance.get().unwrap();
1814 (&lp.by_node, &lp.intern_rule)
1815 } else {
1816 (&self.by_node, &self.intern_rule)
1817 };
1818 by_node
1819 .get(&node)
1820 .into_iter()
1821 .flatten()
1822 .map(move |&(rid, t, s, d)| (intern_rule[rid as usize].as_str(), t, s, d))
1823 }
1824
1825 pub fn provenance_touching_len(&self, node: u32) -> usize {
1827 if self.provenance_is_retained() {
1828 self.ensure_provenance_loaded();
1829 self.lazy_provenance
1830 .get()
1831 .unwrap()
1832 .by_node
1833 .get(&node)
1834 .map_or(0, BTreeSet::len)
1835 } else {
1836 self.by_node.get(&node).map_or(0, BTreeSet::len)
1837 }
1838 }
1839
1840 pub fn is_tripped(&self, name: &str) -> bool {
1843 self.tripped.get(name).copied().unwrap_or(false)
1844 }
1845
1846 pub fn fire_count(&self, name: &str) -> u64 {
1850 self.fires.get(name).copied().unwrap_or(0)
1851 }
1852
1853 pub fn drain_deltas(&mut self) -> Vec<EngineEdgeDelta> {
1867 std::mem::take(&mut self.pending_deltas)
1868 }
1869
1870 pub fn pending_delta_count(&self) -> usize {
1873 self.pending_deltas.len()
1874 }
1875
1876 pub fn pending_deltas_since(&self, cursor: usize) -> &[EngineEdgeDelta] {
1885 &self.pending_deltas[cursor..]
1886 }
1887
1888 #[allow(clippy::type_complexity)]
1892 pub fn to_persist(
1893 &self,
1894 ) -> (
1895 Vec<RuleDef>,
1896 BTreeMap<String, BTreeSet<(u32, u32, u32)>>,
1897 BTreeMap<String, bool>,
1898 BTreeMap<String, u64>,
1899 ) {
1900 (
1901 self.rules.values().cloned().collect(),
1902 self.provenance.clone(),
1903 self.tripped.clone(),
1904 self.fires.clone(),
1905 )
1906 }
1907
1908 pub fn from_persist(
1910 rules: Vec<RuleDef>,
1911 prov: BTreeMap<String, BTreeSet<(u32, u32, u32)>>,
1912 tripped: BTreeMap<String, bool>,
1913 fires: BTreeMap<String, u64>,
1914 ) -> Self {
1915 let mut owned = BTreeSet::new();
1916 for set in prov.values() {
1917 owned.extend(set.iter().copied());
1918 }
1919 let indexes = rules
1920 .iter()
1921 .map(|r| (r.name.clone(), RuleIndex::default()))
1922 .collect();
1923 let rules: BTreeMap<String, RuleDef> =
1924 rules.into_iter().map(|r| (r.name.clone(), r)).collect();
1925 let mut tripped = tripped;
1927 let mut fires = fires;
1928 for name in rules.keys() {
1929 tripped.entry(name.clone()).or_insert(false);
1930 fires.entry(name.clone()).or_insert(0);
1931 }
1932 let (by_node, rule_intern, intern_rule) = rebuild_by_node(&prov);
1933 Self {
1934 rules,
1935 indexes,
1936 provenance: prov,
1937 owned,
1938 by_node,
1939 rule_intern,
1940 intern_rule,
1941 tripped,
1942 fires,
1943 pending_deltas: Vec::new(),
1944 emit_deltas: false,
1945 rebuild_needed: BTreeSet::new(),
1946 indexes_populated: false,
1950 retained_hnsw_blobs: Mutex::new(BTreeMap::new()),
1951 retained_ivf_bytes: Mutex::new(None),
1952 retained_provenance_bytes: Mutex::new(None),
1953 lazy_provenance: OnceLock::new(),
1954 lazy_hnsw: OnceLock::new(),
1955 chain_depth: 0,
1956 chain_fired: BTreeSet::new(),
1957 doomed: None,
1958 chain_truncations: 0,
1959 hnsw_builds: 0,
1960 }
1961 }
1962
1963 pub fn set_emit_deltas(&mut self, emit: bool) {
1969 self.emit_deltas = emit;
1970 }
1971
1972 pub fn emit_deltas(&self) -> bool {
1974 self.emit_deltas
1975 }
1976
1977 pub fn take_rebuild_needed(&mut self) -> Vec<String> {
1980 std::mem::take(&mut self.rebuild_needed)
1981 .into_iter()
1982 .collect()
1983 }
1984
1985 pub fn queue_rebuild_needed(&mut self, name: String) {
1989 self.rebuild_needed.insert(name);
1990 }
1991
1992 fn maybe_queue_ivf_rebuild(&mut self, rule_name: &str, def: &RuleDef) {
1993 if !def.approximate {
1994 return;
1995 }
1996 let Some(idx) = self.indexes.get(rule_name) else {
1997 return;
1998 };
1999 if idx.dst_side.ivf_drift > ivf_drift_rebuild_threshold() {
2000 self.rebuild_needed.insert(rule_name.to_string());
2001 }
2002 }
2003
2004 #[doc(hidden)]
2014 pub fn hnsw_build_count(&self) -> u64 {
2015 self.hnsw_builds
2016 }
2017
2018 #[doc(hidden)]
2025 pub fn lazy_hnsw_len(&self) -> usize {
2026 self.lazy_hnsw.get().map_or(0, |m| m.len())
2027 }
2028
2029 fn mark_indexes_populated(&mut self) {
2044 self.indexes_populated = true;
2045 self.lazy_hnsw = OnceLock::new();
2046 }
2047
2048 pub fn export_ivf_state(&self) -> BTreeMap<String, RuleIvfExport> {
2052 let mut out = BTreeMap::new();
2053 for (name, def) in &self.rules {
2054 if def.approximate {
2055 if let Some(idx) = self.indexes.get(name) {
2056 out.insert(
2057 name.clone(),
2058 (
2059 idx.src_side.export_ivf_state(),
2060 idx.dst_side.export_ivf_state(),
2061 ),
2062 );
2063 }
2064 }
2065 }
2066 out
2067 }
2068
2069 pub fn reindex_all(
2071 &mut self,
2072 ids: &IdMap,
2073 syms: &Interner,
2074 labels: &[u32],
2075 props: ColumnsView<'_>,
2076 ) {
2077 for idx in self.indexes.values_mut() {
2078 *idx = RuleIndex::default();
2079 }
2080 let rule_names: Vec<String> = self.rules.keys().cloned().collect();
2083
2084 for name in &rule_names {
2086 if self.rules[name].approximate {
2087 let idx = self.indexes.get_mut(name).unwrap();
2088 idx.src_side.init_hnsw(name);
2089 idx.dst_side.init_hnsw(name);
2090 self.hnsw_builds += 2;
2091 }
2092 }
2093
2094 for id in 0..ids.len() as u32 {
2095 let label_sym = match labels.get(id as usize).copied() {
2096 Some(s) if s != u32::MAX => s,
2097 _ => continue,
2098 };
2099 for name in &rule_names {
2100 let def = self.rules[name].clone();
2101 let idx = self.indexes.get_mut(name).unwrap();
2102 index_node_for_rule(id, label_sym, &def, idx, syms, props);
2103 }
2104 }
2105 for name in &rule_names {
2108 if self.rules[name].approximate {
2109 let idx = self.indexes.get_mut(name).unwrap();
2110 idx.src_side.fit_ivf_clusters(name);
2111 idx.dst_side.fit_ivf_clusters(name);
2112 }
2113 }
2114 self.mark_indexes_populated();
2115 }
2116
2117 pub fn reindex_all_load_ivf(
2134 &mut self,
2135 ids: &IdMap,
2136 syms: &Interner,
2137 labels: &[u32],
2138 props: ColumnsView<'_>,
2139 ivf_state: BTreeMap<String, RuleIvfExport>,
2140 ) {
2141 self.reindex_all_load_state(ids, syms, labels, props, ivf_state, BTreeMap::new());
2142 }
2143
2144 pub fn reindex_all_load_state(
2169 &mut self,
2170 ids: &IdMap,
2171 syms: &Interner,
2172 labels: &[u32],
2173 props: ColumnsView<'_>,
2174 ivf_state: BTreeMap<String, RuleIvfExport>,
2175 hnsw_state: BTreeMap<String, (Vec<u8>, Vec<u8>)>,
2176 ) {
2177 for idx in self.indexes.values_mut() {
2178 *idx = RuleIndex::default();
2179 }
2180 let rule_names: Vec<String> = self.rules.keys().cloned().collect();
2181
2182 let mut leftover_blobs = hnsw_state;
2186 let mut adopted: BTreeMap<String, (BTreeSet<u32>, BTreeSet<u32>)> = BTreeMap::new();
2187 for name in &rule_names {
2188 if !self.rules[name].approximate {
2189 continue;
2190 }
2191 let (src_blob, dst_blob) = leftover_blobs.remove(name).unwrap_or_default();
2192 let idx = self.indexes.get_mut(name).unwrap();
2193 let (src_ids, src_adopted) = idx.src_side.init_or_adopt_hnsw(name, &src_blob);
2194 let (dst_ids, dst_adopted) = idx.dst_side.init_or_adopt_hnsw(name, &dst_blob);
2195 if !src_adopted {
2196 self.hnsw_builds += 1;
2197 }
2198 if !dst_adopted {
2199 self.hnsw_builds += 1;
2200 }
2201 adopted.insert(name.clone(), (src_ids, dst_ids));
2202 }
2203
2204 let empty: (BTreeSet<u32>, BTreeSet<u32>) = (BTreeSet::new(), BTreeSet::new());
2205 for id in 0..ids.len() as u32 {
2206 let label_sym = match labels.get(id as usize).copied() {
2207 Some(s) if s != u32::MAX => s,
2208 _ => continue,
2209 };
2210 for name in &rule_names {
2211 let def = self.rules[name].clone();
2212 let skip = adopted.get(name).unwrap_or(&empty);
2213 let idx = self.indexes.get_mut(name).unwrap();
2214 index_node_for_rule_skipping(id, label_sym, &def, idx, syms, props, skip);
2215 }
2216 }
2217
2218 if !leftover_blobs.is_empty() {
2221 self.load_hnsw_state(leftover_blobs);
2222 }
2223
2224 for name in &rule_names {
2226 if !self.rules[name].approximate {
2227 continue;
2228 }
2229 let idx = self.indexes.get_mut(name).unwrap();
2230 if let Some(((sc, sa, sd), (dc, da, dd))) = ivf_state.get(name) {
2231 idx.src_side.load_ivf_state(sc.clone(), sa.clone(), *sd);
2232 idx.dst_side.load_ivf_state(dc.clone(), da.clone(), *dd);
2233 } else {
2234 idx.src_side.fit_ivf_clusters(name);
2236 idx.dst_side.fit_ivf_clusters(name);
2237 }
2238 }
2239 self.mark_indexes_populated();
2240 }
2241
2242 pub fn store_snapshot_state(
2250 &self,
2251 hnsw_blobs: BTreeMap<String, (Vec<u8>, Vec<u8>)>,
2252 ivf_bytes: Vec<u8>,
2253 ) {
2254 *self
2255 .retained_hnsw_blobs
2256 .lock()
2257 .expect("retained_hnsw_blobs lock poisoned") = hnsw_blobs;
2258 *self
2259 .retained_ivf_bytes
2260 .lock()
2261 .expect("retained_ivf_bytes lock poisoned") = if ivf_bytes.is_empty() {
2262 None
2263 } else {
2264 Some(ivf_bytes)
2265 };
2266 }
2268
2269 pub fn store_provenance_bytes(&self, bytes: Vec<u8>) {
2276 *self
2277 .retained_provenance_bytes
2278 .lock()
2279 .expect("lock poisoned") = if bytes.is_empty() { None } else { Some(bytes) };
2280 }
2281
2282 pub fn ensure_provenance_loaded(&self) {
2289 self.lazy_provenance.get_or_init(|| {
2290 let guard = self
2293 .retained_provenance_bytes
2294 .lock()
2295 .expect("retained_provenance_bytes lock poisoned");
2296 let bytes = match &*guard {
2297 Some(b) if !b.is_empty() => b,
2298 _ => return LazyProvenance::default(),
2299 };
2300 let prov = decode_provenance_bytes(bytes);
2301 let (by_node, _rule_intern, intern_rule) = rebuild_by_node(&prov);
2302 LazyProvenance {
2303 provenance: prov,
2304 by_node,
2305 intern_rule,
2306 }
2307 });
2308 }
2309
2310 pub fn ensure_provenance_loaded_mut(&mut self) {
2316 let bytes = match self
2317 .retained_provenance_bytes
2318 .lock()
2319 .expect("lock poisoned")
2320 .take()
2321 {
2322 Some(b) => b,
2323 None => return,
2324 };
2325 let prov = decode_provenance_bytes(&bytes);
2326 for set in prov.values() {
2327 self.owned.extend(set.iter().copied());
2328 }
2329 let (by_node, rule_intern, intern_rule) = rebuild_by_node(&prov);
2330 self.provenance = prov;
2331 self.by_node = by_node;
2332 self.rule_intern = rule_intern;
2333 self.intern_rule = intern_rule;
2334 }
2335
2336 pub fn consume_retained_state_eager(
2342 &mut self,
2343 ids: &IdMap,
2344 syms: &Interner,
2345 labels: &[u32],
2346 props: ColumnsView<'_>,
2347 ) {
2348 if self.indexes_populated {
2349 return;
2350 }
2351 self.ensure_provenance_loaded_mut();
2354 let hnsw = std::mem::take(
2355 &mut *self
2356 .retained_hnsw_blobs
2357 .lock()
2358 .expect("retained_hnsw_blobs lock poisoned"),
2359 );
2360 let ivf_bytes = self
2361 .retained_ivf_bytes
2362 .lock()
2363 .expect("retained_ivf_bytes lock poisoned")
2364 .take()
2365 .unwrap_or_default();
2366 let ivf = decode_ivf_bytes_to_export(&ivf_bytes);
2367 self.reindex_all_load_state(ids, syms, labels, props, ivf, hnsw);
2371 }
2372
2373 pub fn ensure_hnsw_loaded(&self) {
2384 self.lazy_hnsw.get_or_init(|| {
2385 let snapshot: Vec<(String, Vec<u8>, Vec<u8>)> = {
2389 let guard = self
2390 .retained_hnsw_blobs
2391 .lock()
2392 .expect("retained_hnsw_blobs lock poisoned");
2393 if guard.is_empty() {
2394 return BTreeMap::new();
2395 }
2396 guard
2397 .iter()
2398 .map(|(name, (sb, db))| (name.clone(), sb.clone(), db.clone()))
2399 .collect()
2400 }; snapshot
2402 .into_iter()
2403 .map(|(name, sb, db)| {
2404 let src = if !sb.is_empty() {
2405 bincode::deserialize::<HnswIndex>(&sb).ok()
2406 } else {
2407 None
2408 };
2409 let dst = if !db.is_empty() {
2410 bincode::deserialize::<HnswIndex>(&db).ok()
2411 } else {
2412 None
2413 };
2414 (name, (src, dst))
2415 })
2416 .collect()
2417 });
2418 }
2419
2420 pub fn indexes_populated(&self) -> bool {
2423 self.indexes_populated
2424 }
2425
2426 pub fn export_hnsw_state(&self) -> BTreeMap<String, (Vec<u8>, Vec<u8>)> {
2431 let mut out = BTreeMap::new();
2432 for (name, def) in &self.rules {
2433 if def.approximate {
2434 if let Some(idx) = self.indexes.get(name) {
2435 out.insert(
2436 name.clone(),
2437 (
2438 idx.src_side.export_hnsw_blob(),
2439 idx.dst_side.export_hnsw_blob(),
2440 ),
2441 );
2442 }
2443 }
2444 }
2445 out
2446 }
2447
2448 pub fn export_hnsw_state_passthrough(&self) -> BTreeMap<String, (Vec<u8>, Vec<u8>)> {
2452 if !self.indexes_populated {
2453 let guard = self
2454 .retained_hnsw_blobs
2455 .lock()
2456 .expect("retained_hnsw_blobs lock poisoned");
2457 if !guard.is_empty() {
2458 return guard.clone();
2459 }
2460 }
2461 self.export_hnsw_state()
2462 }
2463
2464 pub fn retained_ivf_bytes_clone(&self) -> Option<Vec<u8>> {
2470 self.retained_ivf_bytes
2471 .lock()
2472 .expect("retained_ivf_bytes lock poisoned")
2473 .clone()
2474 }
2475
2476 pub fn load_hnsw_state(&mut self, blobs: BTreeMap<String, (Vec<u8>, Vec<u8>)>) {
2486 for (name, (src_blob, dst_blob)) in blobs {
2487 if let Some(idx) = self.indexes.get_mut(&name) {
2488 if !src_blob.is_empty() {
2489 idx.src_side.load_hnsw_blob(&src_blob);
2490 }
2491 if !dst_blob.is_empty() {
2492 idx.dst_side.load_hnsw_blob(&dst_blob);
2493 }
2494 }
2495 }
2496 }
2497
2498 pub fn hnsw_search_dst(
2503 &self,
2504 field: &str,
2505 dst_label: &str,
2506 q: &[f64],
2507 k: usize,
2508 ) -> Option<Vec<(u32, f64)>> {
2509 for (name, def) in &self.rules {
2510 if !def.approximate || def.dst_label != dst_label {
2511 continue;
2512 }
2513 if !predicate_covers_field(&def.predicate, field) {
2515 continue;
2516 }
2517 if let Some(idx) = self.indexes.get(name) {
2518 if let Some(h) = idx.dst_side.hnsw_ref() {
2519 if !h.is_empty() {
2520 return Some(h.search(q, k));
2521 }
2522 }
2523 }
2524 if let Some(lazy) = self.lazy_hnsw.get() {
2527 if let Some((_, Some(h))) = lazy.get(name) {
2528 if !h.is_empty() {
2529 return Some(h.search(q, k));
2530 }
2531 }
2532 }
2533 }
2534 None
2535 }
2536
2537 pub fn hnsw_has_rule(&self, field: &str) -> bool {
2544 self.rules
2545 .values()
2546 .any(|def| def.approximate && predicate_covers_field(&def.predicate, field))
2547 }
2548
2549 pub fn hnsw_search_any_dst(&self, field: &str, q: &[f64], k: usize) -> Option<Vec<(u32, f64)>> {
2559 let mut merged: std::collections::BTreeMap<u32, f64> = std::collections::BTreeMap::new();
2560 let mut found_index = false;
2561
2562 for (name, def) in &self.rules {
2563 if !def.approximate {
2564 continue;
2565 }
2566 if !predicate_covers_field(&def.predicate, field) {
2567 continue;
2568 }
2569 let live = self
2576 .indexes
2577 .get(name)
2578 .and_then(|idx| idx.dst_side.hnsw_ref())
2579 .filter(|h| !h.is_empty());
2580 let lazy = self
2581 .lazy_hnsw
2582 .get()
2583 .and_then(|lazy| lazy.get(name))
2584 .and_then(|(_, dst)| dst.as_ref())
2585 .filter(|h| !h.is_empty());
2586 let hits: Option<Vec<(u32, f64)>> = live.or(lazy).map(|h| {
2587 found_index = true;
2588 h.search(q, k)
2589 });
2590
2591 if let Some(hits) = hits {
2592 for (id, score) in hits {
2593 merged
2594 .entry(id)
2595 .and_modify(|s| {
2596 if score > *s {
2597 *s = score;
2598 }
2599 })
2600 .or_insert(score);
2601 }
2602 }
2603 }
2604
2605 if !found_index {
2606 return None;
2607 }
2608 let mut result: Vec<(u32, f64)> = merged.into_iter().collect();
2609 result.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
2610 result.truncate(k);
2611 Some(result)
2612 }
2613
2614 fn ensure_indexes_populated(&mut self, g: &GraphMut<'_>) {
2628 if self.indexes_populated || self.rules.is_empty() {
2629 return;
2630 }
2631 let hnsw = std::mem::take(
2632 &mut *self
2633 .retained_hnsw_blobs
2634 .lock()
2635 .expect("retained_hnsw_blobs lock poisoned"),
2636 );
2637 let ivf_bytes = self
2638 .retained_ivf_bytes
2639 .lock()
2640 .expect("retained_ivf_bytes lock poisoned")
2641 .take()
2642 .unwrap_or_default();
2643 let ivf = decode_ivf_bytes_to_export(&ivf_bytes);
2644 self.reindex_all_load_state(g.ids, g.syms, g.labels, g.props, ivf, hnsw);
2645 }
2646
2647 pub fn create_rule(&mut self, def: RuleDef, g: &mut GraphMut<'_>) -> Result<(), String> {
2650 def.validate()?;
2651 if self.rules.contains_key(&def.name) {
2652 return Err(format!("rule {:?} already exists", def.name));
2653 }
2654 self.ensure_indexes_populated(g);
2660 let scope = self.begin_chain();
2664 let name = def.name.clone();
2665 self.rules.insert(name.clone(), def);
2666 self.indexes.insert(name.clone(), RuleIndex::default());
2667 self.provenance.entry(name.clone()).or_default();
2668 self.tripped.insert(name.clone(), false);
2669 self.fires.insert(name.clone(), 0);
2670
2671 let n_total = g.ids.len() as u32;
2673 let def = self.rules[&name].clone();
2674
2675 if def.approximate {
2678 let idx = self.indexes.get_mut(&name).unwrap();
2679 idx.src_side.init_hnsw(&name);
2680 idx.dst_side.init_hnsw(&name);
2681 self.hnsw_builds += 2;
2682 }
2683
2684 for id in 0..n_total {
2685 let label_sym = match g.labels.get(id as usize).copied() {
2686 Some(s) if s != u32::MAX => s,
2687 _ => continue,
2688 };
2689 let idx = self.indexes.get_mut(&name).unwrap();
2690 index_node_for_rule(id, label_sym, &def, idx, g.syms, g.props);
2691 }
2692
2693 if def.approximate {
2696 let idx = self.indexes.get_mut(&name).unwrap();
2697 idx.src_side.fit_ivf_clusters(&name);
2698 idx.dst_side.fit_ivf_clusters(&name);
2699 }
2700
2701 let mut prov = ProvSets {
2706 set: self.provenance.get_mut(&name).unwrap(),
2707 owned: &mut self.owned,
2708 by_node: &mut self.by_node,
2709 rule_intern: &mut self.rule_intern,
2710 intern_rule: &mut self.intern_rule,
2711 deltas: &mut self.pending_deltas,
2712 emit: self.emit_deltas,
2713 };
2714 if def.via_label.is_some() {
2715 let budget = edge_budget(&def);
2717 let et = g.syms.intern(&def.edge_type);
2718 let src_sym = g.syms.get(&def.src_label);
2719 let tripped = self.tripped.get_mut(&name).unwrap();
2720 'via_outer: for id in 0..g.ids.len() as u32 {
2721 let label_sym = match g.labels.get(id as usize).copied() {
2722 Some(s) if s != u32::MAX => s,
2723 _ => continue,
2724 };
2725 if src_sym != Some(label_sym) {
2726 continue;
2727 }
2728 let per_src = compute_desired_via(&def, None, ViaAnchor::Src(id), self.doomed, g);
2729 if let Some(k) = def.max_edges {
2730 let top_k = filter_src_top_k(per_src, k, g.ids);
2731 apply_per_src_top_k(&def, id, top_k, &mut prov, g);
2732 } else {
2733 for ((s, d), score) in per_src {
2734 let triple = (et, s, d);
2735 let already = prov.contains(&triple);
2736 if !already {
2737 if *tripped || prov.len() as u64 >= budget {
2738 *tripped = true;
2739 break 'via_outer;
2740 }
2741 let newly = g.topo.add_edge(et, s, d);
2742 if newly {
2743 prov.insert(&name, triple, g.ids, g.syms);
2744 }
2745 }
2746 let is_owned_here = already || prov.contains(&triple);
2747 if is_owned_here {
2748 if let Some(p) = &def.weight_prop {
2749 g.edge_props.set(et, s, d, p, Value::Float(score));
2750 }
2751 }
2752 }
2753 }
2754 }
2755 } else if let Some(k) = def.max_edges {
2756 apply_streaming_create_top_k(&def, k, &self.indexes[&name], &mut prov, g);
2757 } else {
2758 let tripped = self.tripped.get_mut(&name).unwrap();
2759 apply_streaming_create(&def, &self.indexes[&name], &mut prov, tripped, g);
2760 }
2761 let fires = self.fires.get_mut(&name).unwrap();
2764 bump_fires_for_participants(&def, g, fires);
2765
2766 self.mark_indexes_populated();
2770
2771 self.end_chain(scope, g);
2772 Ok(())
2773 }
2774
2775 pub fn delete_rule(&mut self, name: &str, g: &mut GraphMut<'_>) -> Result<(), String> {
2777 if !self.rules.contains_key(name) {
2778 return Err(format!("rule {:?} not found", name));
2779 }
2780 let scope = self.begin_chain();
2784 let def = self.rules.remove(name).unwrap();
2785 self.indexes.remove(name);
2786 self.tripped.remove(name);
2787 self.fires.remove(name);
2788 let mut leftover = self.provenance.remove(name).unwrap_or_default();
2789 let _et = g.syms.intern(&def.edge_type);
2791 let triples: Vec<Triple> = leftover.iter().copied().collect();
2792 let mut sets = ProvSets {
2793 set: &mut leftover,
2794 owned: &mut self.owned,
2795 by_node: &mut self.by_node,
2796 rule_intern: &mut self.rule_intern,
2797 intern_rule: &mut self.intern_rule,
2798 deltas: &mut self.pending_deltas,
2799 emit: self.emit_deltas,
2800 };
2801 for triple in triples {
2802 let (t, s, d) = triple;
2803 g.topo.remove_edge(t, s, d);
2804 g.edge_props.remove_edge(t, s, d);
2805 sets.remove(name, triple, g.ids, g.syms);
2806 }
2807 let same_etype_survivors: Vec<String> = self
2813 .rules
2814 .values()
2815 .filter(|r| r.edge_type == def.edge_type)
2816 .map(|r| r.name.clone())
2817 .collect();
2818 for survivor in same_etype_survivors {
2819 let _ = self.rebuild_inner(&survivor, g);
2821 }
2822 self.end_chain(scope, g);
2823 Ok(())
2824 }
2825
2826 pub fn on_node_changed(
2840 &mut self,
2841 n: u32,
2842 changed: Option<(&str, Option<Value>)>,
2843 g: &mut GraphMut<'_>,
2844 ) {
2845 let scope = self.begin_chain();
2846 self.on_node_changed_inner(n, changed, g);
2847 self.end_chain(scope, g);
2848 }
2849
2850 fn on_node_changed_inner(
2851 &mut self,
2852 n: u32,
2853 changed: Option<(&str, Option<Value>)>,
2854 g: &mut GraphMut<'_>,
2855 ) {
2856 self.ensure_provenance_loaded_mut();
2858 self.ensure_indexes_populated(g);
2864
2865 let n_label = g.labels.get(n as usize).copied();
2866 let rule_names: Vec<String> = self.rules.keys().cloned().collect();
2867
2868 for rule_name in rule_names {
2869 let def = self.rules[&rule_name].clone();
2870
2871 if def.via_label.is_some() {
2872 self.on_node_changed_via(&rule_name, &def, n, n_label, changed.clone(), g);
2874 } else {
2875 let src_sym = g.syms.get(&def.src_label);
2877 let dst_sym = g.syms.get(&def.dst_label);
2878 let as_src = src_sym.is_some() && n_label == src_sym;
2879 let as_dst = dst_sym.is_some() && n_label == dst_sym;
2880
2881 let fires = match changed {
2882 None => as_src || as_dst,
2883 Some((field, _)) => def.watched_fields().contains(field) && (as_src || as_dst),
2884 };
2885 if !fires {
2886 continue;
2887 }
2888 *self.fires.entry(rule_name.clone()).or_default() += 1;
2889
2890 if let Some((field, ref old_val)) = changed {
2892 let old_val_cloned = old_val.clone();
2893 let old_getter = |f: &str| {
2894 if f == field {
2895 old_val_cloned.clone()
2896 } else {
2897 g.props.get(n, f).map(|vr| vr.into_value())
2898 }
2899 };
2900 let idx = self.indexes.get_mut(&rule_name).unwrap();
2901 if as_src {
2902 let spec = src_lookup_spec_for(&def);
2903 idx.src_side.remove(&spec, n, &old_getter);
2904 }
2905 if as_dst {
2906 let spec = candidate_spec_for(&def);
2907 idx.dst_side.remove(&spec, n, &old_getter);
2908 }
2909 }
2910
2911 {
2912 let cur_getter = |f: &str| g.props.get(n, f).map(|vr| vr.into_value());
2913 let idx = self.indexes.get_mut(&rule_name).unwrap();
2914 if as_src {
2915 let spec = src_lookup_spec_for(&def);
2916 idx.src_side.insert(&spec, n, &cur_getter);
2917 }
2918 if as_dst {
2919 let spec = candidate_spec_for(&def);
2920 idx.dst_side.insert(&spec, n, &cur_getter);
2921 }
2922 }
2923
2924 self.maybe_queue_ivf_rebuild(&rule_name, &def);
2925
2926 if let Some(k) = def.max_edges {
2928 let et = g.syms.intern(&def.edge_type);
2929 let affected_srcs_for_n_dst: BTreeSet<u32> = if as_dst {
2930 let rid = self.rule_intern.get(&def.name).copied();
2931 self.by_node
2932 .get(&n)
2933 .into_iter()
2934 .flatten()
2935 .filter(|(r, t, _s, d)| Some(*r) == rid && *t == et && *d == n)
2936 .map(|(_, _, s, _)| *s)
2937 .collect()
2938 } else {
2939 BTreeSet::new()
2940 };
2941
2942 let mut prov = ProvSets {
2943 set: self.provenance.entry(rule_name.clone()).or_default(),
2944 owned: &mut self.owned,
2945 by_node: &mut self.by_node,
2946 rule_intern: &mut self.rule_intern,
2947 intern_rule: &mut self.intern_rule,
2948 deltas: &mut self.pending_deltas,
2949 emit: self.emit_deltas,
2950 };
2951
2952 if as_src {
2953 let desired_n_src =
2954 compute_desired(&def, &self.indexes[&rule_name], n, true, g);
2955 let top_k = filter_src_top_k(desired_n_src, k, g.ids);
2956 apply_per_src_top_k(&def, n, top_k, &mut prov, g);
2957 }
2958
2959 if as_dst {
2960 let new_desired =
2961 compute_desired(&def, &self.indexes[&rule_name], n, false, g);
2962 let new_srcs: BTreeSet<u32> = new_desired.keys().map(|(s, _)| *s).collect();
2963 let affected_srcs: BTreeSet<u32> =
2964 affected_srcs_for_n_dst.union(&new_srcs).copied().collect();
2965 for src in affected_srcs {
2966 if src == n {
2967 continue;
2968 }
2969 let desired_src =
2970 compute_desired(&def, &self.indexes[&rule_name], src, true, g);
2971 let top_k = filter_src_top_k(desired_src, k, g.ids);
2972 apply_per_src_top_k(&def, src, top_k, &mut prov, g);
2973 }
2974 }
2975 } else {
2976 let mut desired = BTreeMap::new();
2977 if as_src {
2978 desired.extend(compute_desired(
2979 &def,
2980 &self.indexes[&rule_name],
2981 n,
2982 true,
2983 g,
2984 ));
2985 }
2986 if as_dst {
2987 desired.extend(compute_desired(
2988 &def,
2989 &self.indexes[&rule_name],
2990 n,
2991 false,
2992 g,
2993 ));
2994 }
2995 let tripped = self.tripped.entry(rule_name.clone()).or_default();
2996 apply_desired(
2997 &def,
2998 desired,
2999 Some(n),
3000 &mut ProvSets {
3001 set: self.provenance.entry(rule_name).or_default(),
3002 owned: &mut self.owned,
3003 by_node: &mut self.by_node,
3004 rule_intern: &mut self.rule_intern,
3005 intern_rule: &mut self.intern_rule,
3006 deltas: &mut self.pending_deltas,
3007 emit: self.emit_deltas,
3008 },
3009 tripped,
3010 g,
3011 );
3012 }
3013 }
3014 }
3015 }
3016
3017 fn on_node_changed_via(
3039 &mut self,
3040 rule_name: &str,
3041 def: &RuleDef,
3042 n: u32,
3043 n_label: Option<u32>,
3044 changed: Option<(&str, Option<Value>)>,
3045 g: &mut GraphMut<'_>,
3046 ) {
3047 let doomed = self.doomed;
3048 let src_sym = g.syms.get(&def.src_label);
3049 let dst_sym = g.syms.get(&def.dst_label);
3050 let via_sym = def.via_label.as_deref().and_then(|l| g.syms.get(l));
3051
3052 let as_src = src_sym.is_some() && n_label == src_sym;
3053 let as_dst = dst_sym.is_some() && n_label == dst_sym;
3054 let as_via = via_sym.is_some() && n_label == via_sym;
3055
3056 let fires = match changed {
3060 None => as_src || as_via || as_dst,
3061 Some((field, _)) => {
3062 let wf = def.watched_fields();
3063 (wf.contains(field)) && (as_src || as_via || as_dst)
3064 }
3065 };
3066 if !fires {
3067 return;
3068 }
3069 *self.fires.entry(rule_name.to_string()).or_default() += 1;
3070
3071 if as_dst {
3080 let spec = candidate_spec_for(def);
3081 let idx = self.indexes.entry(rule_name.to_string()).or_default();
3082 if let Some((field, ref old_val)) = changed {
3083 let old_val_cloned = old_val.clone();
3084 let old_getter = |f: &str| {
3085 if f == field {
3086 old_val_cloned.clone()
3087 } else {
3088 g.props.get(n, f).map(|vr| vr.into_value())
3089 }
3090 };
3091 idx.dst_side.remove(&spec, n, &old_getter);
3092 }
3093 let cur_getter = |f: &str| g.props.get(n, f).map(|vr| vr.into_value());
3094 idx.dst_side.insert(&spec, n, &cur_getter);
3095 }
3096
3097 let mut affected_srcs: BTreeSet<u32> = BTreeSet::new();
3099 if as_src {
3100 affected_srcs.insert(n);
3101 }
3102 if as_via {
3103 let via_edge_str = def.via_edge.as_deref().unwrap();
3105 let via_dir = def.via_dir.unwrap_or(core_storage::Direction::Out);
3106 let rev_dir = match via_dir {
3107 core_storage::Direction::Out => core_storage::Direction::In,
3108 core_storage::Direction::In => core_storage::Direction::Out,
3109 };
3110 if let (Some(via_etype), Some(s_sym)) = (g.syms.get(via_edge_str), src_sym) {
3111 for &src in g.neighbors(via_etype, rev_dir, n).as_ref() {
3112 if g.labels.get(src as usize).copied() == Some(s_sym) {
3113 affected_srcs.insert(src);
3114 }
3115 }
3116 }
3117 }
3118 if as_dst {
3119 let desired_touching_n = compute_desired_via(
3121 def,
3122 self.indexes.get(rule_name),
3123 ViaAnchor::Dst(n),
3124 doomed,
3125 g,
3126 );
3127 for (src, _dst) in desired_touching_n.keys() {
3128 affected_srcs.insert(*src);
3129 }
3130 let et = g.syms.intern(&def.edge_type);
3132 let rid = self.rule_intern.get(rule_name).copied();
3133 let old_srcs: Vec<u32> = self
3134 .by_node
3135 .get(&n)
3136 .into_iter()
3137 .flatten()
3138 .filter(|(r, t, _s, d)| Some(*r) == rid && *t == et && *d == n)
3139 .map(|(_, _, s, _)| *s)
3140 .collect();
3141 affected_srcs.extend(old_srcs);
3142 }
3143
3144 let affected_srcs: Vec<u32> = affected_srcs.into_iter().collect();
3146 let rule_index = self.indexes.get(rule_name);
3149
3150 if let Some(k) = def.max_edges {
3151 let mut prov = ProvSets {
3152 set: self.provenance.entry(rule_name.to_string()).or_default(),
3153 owned: &mut self.owned,
3154 by_node: &mut self.by_node,
3155 rule_intern: &mut self.rule_intern,
3156 intern_rule: &mut self.intern_rule,
3157 deltas: &mut self.pending_deltas,
3158 emit: self.emit_deltas,
3159 };
3160 for src in affected_srcs {
3161 let desired_src =
3162 compute_desired_via(def, rule_index, ViaAnchor::Src(src), doomed, g);
3163 let top_k = filter_src_top_k(desired_src, k, g.ids);
3164 apply_per_src_top_k(def, src, top_k, &mut prov, g);
3165 }
3166 } else {
3167 let tripped = self.tripped.entry(rule_name.to_string()).or_default();
3168 let budget = edge_budget(def);
3169 for src in affected_srcs {
3172 let desired_src =
3173 compute_desired_via(def, rule_index, ViaAnchor::Src(src), doomed, g);
3174 if !*tripped {
3175 let mut prov = ProvSets {
3176 set: self.provenance.entry(rule_name.to_string()).or_default(),
3177 owned: &mut self.owned,
3178 by_node: &mut self.by_node,
3179 rule_intern: &mut self.rule_intern,
3180 intern_rule: &mut self.intern_rule,
3181 deltas: &mut self.pending_deltas,
3182 emit: self.emit_deltas,
3183 };
3184 apply_desired(def, desired_src, Some(src), &mut prov, tripped, g);
3185 }
3186 let _ = budget;
3190 }
3191 }
3192 }
3193
3194 pub fn on_edge_changed(
3208 &mut self,
3209 etype_str: &str,
3210 src_id: u32,
3211 dst_id: u32,
3212 g: &mut GraphMut<'_>,
3213 ) {
3214 let scope = self.begin_chain();
3215 self.on_edge_changed_inner(etype_str, src_id, dst_id, g);
3216 self.end_chain(scope, g);
3217 }
3218
3219 fn on_edge_changed_inner(
3220 &mut self,
3221 etype_str: &str,
3222 src_id: u32,
3223 dst_id: u32,
3224 g: &mut GraphMut<'_>,
3225 ) {
3226 self.ensure_provenance_loaded_mut();
3228 self.ensure_indexes_populated(g);
3231
3232 let rule_names: Vec<String> = self.rules.keys().cloned().collect();
3233 for (rule_idx, rule_name) in rule_names.into_iter().enumerate() {
3234 let def = self.rules[&rule_name].clone();
3235 let Some(ref via_edge) = def.via_edge else {
3236 continue; };
3238 if via_edge != etype_str {
3239 continue; }
3241
3242 let src_sym = match g.syms.get(&def.src_label) {
3244 Some(s) => s,
3245 None => continue,
3246 };
3247 let via_sym = match def.via_label.as_deref().and_then(|l| g.syms.get(l)) {
3248 Some(s) => s,
3249 None => continue,
3250 };
3251 let via_dir = def.via_dir.unwrap_or(core_storage::Direction::Out);
3255 let (rule_src, rule_via) = match via_dir {
3256 core_storage::Direction::Out => (src_id, dst_id),
3257 core_storage::Direction::In => (dst_id, src_id),
3258 };
3259
3260 if g.labels.get(rule_src as usize).copied() != Some(src_sym) {
3261 continue;
3262 }
3263 if g.labels.get(rule_via as usize).copied() != Some(via_sym) {
3264 continue;
3265 }
3266
3267 if self.chain_depth > 0 && !self.chain_fired.insert((rule_idx as u32, rule_src)) {
3278 continue;
3279 }
3280
3281 *self.fires.entry(rule_name.clone()).or_default() += 1;
3283 let desired_src =
3284 compute_desired_via(&def, None, ViaAnchor::Src(rule_src), self.doomed, g);
3285
3286 if let Some(k) = def.max_edges {
3287 let mut prov = ProvSets {
3288 set: self.provenance.entry(rule_name).or_default(),
3289 owned: &mut self.owned,
3290 by_node: &mut self.by_node,
3291 rule_intern: &mut self.rule_intern,
3292 intern_rule: &mut self.intern_rule,
3293 deltas: &mut self.pending_deltas,
3294 emit: self.emit_deltas,
3295 };
3296 let top_k = filter_src_top_k(desired_src, k, g.ids);
3297 apply_per_src_top_k(&def, rule_src, top_k, &mut prov, g);
3298 } else {
3299 let tripped = self.tripped.entry(rule_name.clone()).or_default();
3300 let mut prov = ProvSets {
3301 set: self.provenance.entry(rule_name).or_default(),
3302 owned: &mut self.owned,
3303 by_node: &mut self.by_node,
3304 rule_intern: &mut self.rule_intern,
3305 intern_rule: &mut self.intern_rule,
3306 deltas: &mut self.pending_deltas,
3307 emit: self.emit_deltas,
3308 };
3309 apply_desired(&def, desired_src, Some(rule_src), &mut prov, tripped, g);
3310 }
3311 }
3312 }
3313
3314 pub fn on_node_removed(&mut self, n: u32, g: &mut GraphMut<'_>) {
3325 let prev_doomed = self.doomed;
3332 self.doomed = Some(n);
3333 let scope = self.begin_chain();
3334 self.on_node_removed_inner(n, g);
3335 self.end_chain(scope, g);
3336 self.doomed = prev_doomed;
3337 }
3338
3339 fn on_node_removed_inner(&mut self, n: u32, g: &mut GraphMut<'_>) {
3340 self.ensure_provenance_loaded_mut();
3342 self.ensure_indexes_populated(g);
3347
3348 let n_label = g.labels.get(n as usize).copied();
3349 let rule_names: Vec<String> = self.rules.keys().cloned().collect();
3350
3351 for rule_name in rule_names {
3352 let def = self.rules[&rule_name].clone();
3353 let src_sym = g.syms.get(&def.src_label);
3354 let dst_sym = g.syms.get(&def.dst_label);
3355 let as_src = src_sym.is_some() && n_label == src_sym;
3356 let as_dst = dst_sym.is_some() && n_label == dst_sym;
3357
3358 {
3359 let cur_getter = |f: &str| g.props.get(n, f).map(|vr| vr.into_value());
3360 let idx = self.indexes.get_mut(&rule_name).unwrap();
3361 if as_src {
3362 let spec = src_lookup_spec_for(&def);
3363 idx.src_side.remove(&spec, n, &cur_getter);
3364 }
3365 if as_dst {
3366 let spec = candidate_spec_for(&def);
3367 idx.dst_side.remove(&spec, n, &cur_getter);
3368 }
3369 }
3370
3371 self.maybe_queue_ivf_rebuild(&rule_name, &def);
3372 }
3373
3374 let touching: Vec<(String, Triple)> = self
3375 .by_node
3376 .get(&n)
3377 .into_iter()
3378 .flatten()
3379 .map(|&(rid, t, s, d)| (self.intern_rule[rid as usize].clone(), (t, s, d)))
3380 .collect();
3381
3382 let topk_backfill: Vec<(String, u32)> = touching
3386 .iter()
3387 .filter_map(|(rule_name, triple)| {
3388 let &(_, s, d) = triple;
3389 let def = self.rules.get(rule_name)?;
3390 def.max_edges?; if d == n && s != n {
3392 Some((rule_name.clone(), s))
3393 } else {
3394 None
3395 }
3396 })
3397 .collect();
3398
3399 for (rule_name, triple) in touching {
3400 let (t, s, d) = triple;
3401 g.topo.remove_edge(t, s, d);
3402 g.edge_props.remove_edge(t, s, d);
3403 if let Some(set) = self.provenance.get_mut(&rule_name) {
3404 ProvSets {
3405 set,
3406 owned: &mut self.owned,
3407 by_node: &mut self.by_node,
3408 rule_intern: &mut self.rule_intern,
3409 intern_rule: &mut self.intern_rule,
3410 deltas: &mut self.pending_deltas,
3411 emit: self.emit_deltas,
3412 }
3413 .remove(&rule_name, triple, g.ids, g.syms);
3414 }
3415 }
3416
3417 for (rule_name, src) in topk_backfill {
3422 let def = self.rules[&rule_name].clone();
3423 let k = def.max_edges.unwrap(); let desired_src = if def.via_edge.is_some() {
3428 compute_desired_via(&def, None, ViaAnchor::Src(src), self.doomed, g)
3429 } else {
3430 compute_desired(&def, &self.indexes[&rule_name], src, true, g)
3431 };
3432 let top_k = filter_src_top_k(desired_src, k, g.ids);
3433 let mut prov = ProvSets {
3434 set: self.provenance.entry(rule_name.clone()).or_default(),
3435 owned: &mut self.owned,
3436 by_node: &mut self.by_node,
3437 rule_intern: &mut self.rule_intern,
3438 intern_rule: &mut self.intern_rule,
3439 deltas: &mut self.pending_deltas,
3440 emit: self.emit_deltas,
3441 };
3442 apply_per_src_top_k(&def, src, top_k, &mut prov, g);
3443 }
3444 }
3445
3446 pub fn rebuild(&mut self, name: &str, g: &mut GraphMut<'_>) -> Result<(), String> {
3459 let scope = self.begin_chain();
3460 let out = self.rebuild_inner(name, g);
3461 self.end_chain(scope, g);
3462 out
3463 }
3464
3465 fn rebuild_inner(&mut self, name: &str, g: &mut GraphMut<'_>) -> Result<(), String> {
3469 if !self.rules.contains_key(name) {
3470 return Err(format!("rule {:?} not found", name));
3471 }
3472 self.rebuild_needed.remove(name);
3473 let def = self.rules[name].clone();
3474
3475 *self.indexes.get_mut(name).unwrap() = RuleIndex::default();
3477
3478 if def.approximate {
3480 let idx = self.indexes.get_mut(name).unwrap();
3481 idx.src_side.init_hnsw(name);
3482 idx.dst_side.init_hnsw(name);
3483 self.hnsw_builds += 2;
3484 }
3485
3486 let n_total = g.ids.len() as u32;
3487 for id in 0..n_total {
3488 let label_sym = match g.labels.get(id as usize).copied() {
3489 Some(s) if s != u32::MAX => s,
3490 _ => continue,
3491 };
3492 let idx = self.indexes.get_mut(name).unwrap();
3493 index_node_for_rule(id, label_sym, &def, idx, g.syms, g.props);
3494 }
3495
3496 if def.approximate {
3499 let idx = self.indexes.get_mut(name).unwrap();
3500 idx.src_side.fit_ivf_clusters(name);
3501 idx.dst_side.fit_ivf_clusters(name);
3502 }
3503
3504 let doomed = self.doomed;
3511 let mut prov = ProvSets {
3512 set: self.provenance.get_mut(name).unwrap(),
3513 owned: &mut self.owned,
3514 by_node: &mut self.by_node,
3515 rule_intern: &mut self.rule_intern,
3516 intern_rule: &mut self.intern_rule,
3517 deltas: &mut self.pending_deltas,
3518 emit: self.emit_deltas,
3519 };
3520 if let Some(k) = def.max_edges {
3521 apply_streaming_rebuild_top_k(&def, k, &self.indexes[name], doomed, &mut prov, g);
3522 } else {
3523 let tripped = self.tripped.get_mut(name).unwrap();
3524 if def.via_edge.is_some() {
3525 apply_via_rebuild(&def, doomed, &mut prov, tripped, g);
3526 } else {
3527 apply_streaming_rebuild(&def, &self.indexes[name], &mut prov, tripped, g);
3528 }
3529 }
3530 let fires = self.fires.entry(name.to_string()).or_default();
3531 bump_fires_for_participants(&def, g, fires);
3532
3533 Ok(())
3534 }
3535
3536 #[cfg(test)]
3537 fn by_node_consistent(&self) -> bool {
3538 let (rebuilt, intern, names) = rebuild_by_node(&self.provenance);
3539 resolve_by_node(&self.by_node, &self.intern_rule) == resolve_by_node(&rebuilt, &names)
3540 && intern.len() == names.len()
3541 }
3542}
3543
3544#[cfg(test)]
3549mod tests {
3550 use super::*;
3551 use crate::def::{evaluate, NodeView, Predicate, RuleDef};
3552 use core_storage::{ColumnStore, Direction, EdgeProps, IdMap, Interner, Topology, Value};
3553
3554 struct Fx {
3555 ids: IdMap,
3556 syms: Interner,
3557 labels: Vec<u32>,
3558 props: ColumnStore,
3559 topo: Topology,
3560 eprops: EdgeProps,
3561 }
3562 impl Fx {
3563 fn new() -> Self {
3564 Fx {
3565 ids: IdMap::new(),
3566 syms: Interner::new(),
3567 labels: vec![],
3568 props: ColumnStore::new(),
3569 topo: Topology::new(),
3570 eprops: EdgeProps::new(),
3571 }
3572 }
3573 fn add(&mut self, label: &str, key: &str, props: Vec<(&str, Value)>) -> u32 {
3574 let id = self.ids.get_or_insert(key);
3575 let sym = self.syms.intern(label);
3576 self.labels.resize(id as usize + 1, u32::MAX);
3577 self.labels[id as usize] = sym;
3578 for (f, v) in props {
3579 self.props.set(id, f, v);
3580 }
3581 id
3582 }
3583 fn g(&mut self) -> GraphMut<'_> {
3584 GraphMut {
3585 ids: &self.ids,
3586 syms: &mut self.syms,
3587 labels: &self.labels,
3588 props: ColumnsView::owned(&self.props),
3589 topo: &mut self.topo,
3590 base_topo: None,
3591 edge_props: &mut self.eprops,
3592 }
3593 }
3594 }
3595
3596 fn tags(items: &[&str]) -> Value {
3597 Value::List(items.iter().map(|s| Value::Str((*s).into())).collect())
3598 }
3599
3600 fn overlap_rule() -> RuleDef {
3601 RuleDef {
3602 name: "rel".into(),
3603 src_label: "A".into(),
3604 dst_label: "A".into(),
3605 predicate: Predicate::Overlap {
3606 field: "tags".into(),
3607 min: 0.4,
3608 },
3609 edge_type: "REL".into(),
3610 weight_prop: Some("score".into()),
3611 max_edges: None,
3612 approximate: false,
3613 via_label: None,
3614 via_edge: None,
3615 via_dir: None,
3616 }
3617 }
3618
3619 fn emb(xs: &[f64]) -> Value {
3620 Value::List(xs.iter().copied().map(Value::Float).collect())
3621 }
3622
3623 fn approx_vec_rule() -> RuleDef {
3624 RuleDef {
3625 name: "sim".into(),
3626 src_label: "V".into(),
3627 dst_label: "V".into(),
3628 predicate: Predicate::VectorSimilar {
3629 field: "emb".into(),
3630 min: 0.5,
3631 },
3632 edge_type: "SIM".into(),
3633 weight_prop: None,
3634 max_edges: None,
3635 approximate: true,
3636 via_label: None,
3637 via_edge: None,
3638 via_dir: None,
3639 }
3640 }
3641
3642 fn approx_fixture() -> (Fx, RuleEngine) {
3649 let mut fx = Fx::new();
3650 for i in 0..8 {
3651 let t = i as f64 * std::f64::consts::FRAC_PI_4;
3652 fx.add(
3653 "V",
3654 &format!("v{i}"),
3655 vec![("emb", emb(&[t.cos(), t.sin()]))],
3656 );
3657 }
3658 let mut eng = RuleEngine::new();
3659 {
3660 let mut g = fx.g();
3661 eng.create_rule(approx_vec_rule(), &mut g).unwrap();
3662 }
3663 (fx, eng)
3664 }
3665
3666 fn reopened(
3667 fx: &Fx,
3668 ivf: BTreeMap<String, RuleIvfExport>,
3669 hnsw: BTreeMap<String, (Vec<u8>, Vec<u8>)>,
3670 ) -> RuleEngine {
3671 let mut eng = RuleEngine::from_persist(
3672 vec![approx_vec_rule()],
3673 BTreeMap::new(),
3674 BTreeMap::new(),
3675 BTreeMap::new(),
3676 );
3677 eng.reindex_all_load_state(
3678 &fx.ids,
3679 &fx.syms,
3680 &fx.labels,
3681 ColumnsView::owned(&fx.props),
3682 ivf,
3683 hnsw,
3684 );
3685 eng
3686 }
3687
3688 #[test]
3691 fn reindex_with_persisted_hnsw_skips_the_build() {
3692 let (fx, eng) = approx_fixture();
3693 assert!(
3694 eng.hnsw_build_count() > 0,
3695 "create_rule builds the graph for the first time"
3696 );
3697 let before = eng.hnsw_search_dst("emb", "V", &[1.0, 0.0], 4);
3698 assert!(before.is_some(), "fixture must have a populated HNSW");
3699
3700 let eng2 = reopened(&fx, eng.export_ivf_state(), eng.export_hnsw_state());
3701 assert_eq!(
3702 eng2.hnsw_build_count(),
3703 0,
3704 "no HNSW graph may be built when the snapshot persisted one"
3705 );
3706 assert_eq!(
3707 eng2.hnsw_search_dst("emb", "V", &[1.0, 0.0], 4),
3708 before,
3709 "the restored graph must answer exactly as the built one did"
3710 );
3711 }
3712
3713 #[test]
3716 fn reindex_without_persisted_hnsw_rebuilds() {
3717 let (fx, eng) = approx_fixture();
3718 let before = eng.hnsw_search_dst("emb", "V", &[1.0, 0.0], 4);
3719
3720 let eng2 = reopened(&fx, eng.export_ivf_state(), BTreeMap::new());
3721 assert_eq!(
3722 eng2.hnsw_build_count(),
3723 2,
3724 "both sides of the rule must be rebuilt when no blob is persisted"
3725 );
3726 assert_eq!(eng2.hnsw_search_dst("emb", "V", &[1.0, 0.0], 4), before);
3727 }
3728
3729 #[test]
3732 fn reindex_with_corrupt_hnsw_blob_rebuilds() {
3733 let (fx, eng) = approx_fixture();
3734 let before = eng.hnsw_search_dst("emb", "V", &[1.0, 0.0], 4);
3735
3736 let mut hnsw = eng.export_hnsw_state();
3737 for (src, dst) in hnsw.values_mut() {
3738 src.truncate(src.len() / 2);
3739 dst.truncate(dst.len() / 2);
3740 }
3741 let eng2 = reopened(&fx, eng.export_ivf_state(), hnsw);
3742 assert_eq!(
3743 eng2.hnsw_build_count(),
3744 2,
3745 "a corrupt blob must cost a rebuild, not an empty index"
3746 );
3747 assert_eq!(
3748 eng2.hnsw_search_dst("emb", "V", &[1.0, 0.0], 4),
3749 before,
3750 "the rebuilt graph must answer as the original did"
3751 );
3752 }
3753
3754 #[test]
3758 fn reindex_inserts_nodes_the_blob_predates() {
3759 let (mut fx, eng) = approx_fixture();
3760 let hnsw = eng.export_hnsw_state();
3761 let ivf = eng.export_ivf_state();
3762
3763 fx.add("V", "late", vec![("emb", emb(&[0.999, 0.045]))]);
3765
3766 let eng2 = reopened(&fx, ivf, hnsw);
3767 assert_eq!(
3768 eng2.hnsw_build_count(),
3769 0,
3770 "adopting the blob must still skip both builds"
3771 );
3772 let late_id = fx.ids.len() as u32 - 1;
3773 let hits = eng2
3774 .hnsw_search_dst("emb", "V", &[1.0, 0.0], 8)
3775 .expect("the dst side must have a graph");
3776 assert!(
3777 hits.iter().any(|&(id, _)| id == late_id),
3778 "a node the blob predates must be inserted by the scan; got {hits:?}"
3779 );
3780 }
3781
3782 #[test]
3784 fn reindex_rebuilds_only_the_side_without_a_blob() {
3785 let (fx, eng) = approx_fixture();
3786 let mut hnsw = eng.export_hnsw_state();
3787 for (src, _) in hnsw.values_mut() {
3788 src.clear();
3789 }
3790 let eng2 = reopened(&fx, eng.export_ivf_state(), hnsw);
3791 assert_eq!(eng2.hnsw_build_count(), 1);
3792 assert_eq!(
3793 eng2.hnsw_search_dst("emb", "V", &[1.0, 0.0], 4),
3794 eng.hnsw_search_dst("emb", "V", &[1.0, 0.0], 4)
3795 );
3796 }
3797
3798 #[test]
3799 fn approximate_rule_rebuilds_after_drift_threshold() {
3800 with_ivf_drift_rebuild(1, || {
3801 let mut fx = Fx::new();
3802 let mut ids = Vec::new();
3803 for i in 0..6 {
3804 let x = i as f64 * 0.2;
3805 ids.push(fx.add("V", &format!("v{i}"), vec![("emb", emb(&[x, 1.0 - x]))]));
3806 }
3807 let mut eng = RuleEngine::new();
3808 {
3809 let mut g = fx.g();
3810 eng.create_rule(approx_vec_rule(), &mut g).unwrap();
3811 }
3812 assert!(eng.take_rebuild_needed().is_empty());
3813 {
3814 let mut g = fx.g();
3815 eng.on_node_removed(ids[0], &mut g);
3816 }
3817 assert!(
3818 eng.take_rebuild_needed().is_empty(),
3819 "drift=1 is not > threshold 1"
3820 );
3821 {
3822 let mut g = fx.g();
3823 eng.on_node_removed(ids[1], &mut g);
3824 }
3825 assert_eq!(eng.take_rebuild_needed(), vec!["sim".to_string()]);
3826 {
3827 let mut g = fx.g();
3828 eng.rebuild("sim", &mut g).unwrap();
3829 }
3830 assert!(
3831 eng.take_rebuild_needed().is_empty(),
3832 "rebuild must reset drift and not re-queue itself"
3833 );
3834 let drift = eng
3835 .export_ivf_state()
3836 .get("sim")
3837 .map(|(_, dst)| dst.2)
3838 .unwrap();
3839 assert_eq!(drift, 0, "rebuild resets dst-side IVF drift");
3840 });
3841 }
3842
3843 #[test]
3844 fn backfill_creates_edges_with_scores_and_delete_removes_exactly_them() {
3845 let mut fx = Fx::new();
3846 let a = fx.add("A", "a", vec![("tags", tags(&["x", "y"]))]);
3847 let b = fx.add("A", "b", vec![("tags", tags(&["x", "y"]))]);
3848 let _c = fx.add("A", "c", vec![("tags", tags(&["q"]))]);
3849 let et = fx.syms.intern("REL");
3851 fx.topo.add_edge(et, a, b);
3852 let mut eng = RuleEngine::new();
3853 let mut g = fx.g();
3854 eng.create_rule(overlap_rule(), &mut g).unwrap();
3855 assert!(g.topo.neighbors(et, Direction::Out, b).contains(&a));
3857 assert_eq!(
3858 g.edge_props.get(et, b, a, "score"),
3859 Some(&Value::Float(1.0))
3860 );
3861 assert!(!eng.is_owned(et, a, b));
3862 assert!(eng.is_owned(et, b, a));
3863 eng.delete_rule("rel", &mut g).unwrap();
3864 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);
3867 }
3868
3869 #[test]
3870 fn incremental_update_adds_and_removes_edges() {
3871 let mut fx = Fx::new();
3872 let a = fx.add("A", "a", vec![("tags", tags(&["x", "y"]))]);
3873 let b = fx.add("A", "b", vec![("tags", tags(&["y", "z"]))]);
3874 let et = fx.syms.intern("REL");
3875 let mut eng = RuleEngine::new();
3876 {
3877 let mut g = fx.g();
3878 eng.create_rule(overlap_rule(), &mut g).unwrap(); assert_eq!(g.topo.edge_count(), 0);
3880 }
3881 let old = fx.props.get(b, "tags").cloned();
3883 fx.props.set(b, "tags", tags(&["x", "y"]));
3884 {
3885 let mut g = fx.g();
3886 eng.on_node_changed(b, Some(("tags", old)), &mut g);
3887 assert!(g.topo.neighbors(et, Direction::Out, a).contains(&b));
3888 assert!(g.topo.neighbors(et, Direction::Out, b).contains(&a));
3889 }
3890 let old = fx.props.get(b, "tags").cloned();
3892 fx.props.set(b, "tags", tags(&["qqq"]));
3893 let mut g = fx.g();
3894 eng.on_node_changed(b, Some(("tags", old)), &mut g);
3895 assert_eq!(g.topo.edge_count(), 0);
3896 assert_eq!(g.edge_props.get(et, a, b, "score"), None);
3897 }
3898
3899 #[test]
3900 fn key_match_new_node_links_and_rebuild_is_noop() {
3901 let mut fx = Fx::new();
3902 fx.add("C", "c1", vec![]);
3903 let mut eng = RuleEngine::new();
3904 {
3905 let mut g = fx.g();
3906 eng.create_rule(
3907 RuleDef {
3908 name: "fk".into(),
3909 src_label: "T".into(),
3910 dst_label: "C".into(),
3911 predicate: Predicate::KeyMatch {
3912 field: "cid".into(),
3913 },
3914 edge_type: "AT".into(),
3915 weight_prop: None,
3916 max_edges: None,
3917 approximate: false,
3918 via_label: None,
3919 via_edge: None,
3920 via_dir: None,
3921 },
3922 &mut g,
3923 )
3924 .unwrap();
3925 }
3926 let t = fx.add("T", "t1", vec![("cid", Value::Str("c1".into()))]);
3927 let (at, c1, count_before) = {
3928 let mut g = fx.g();
3929 eng.on_node_changed(t, None, &mut g);
3930 let at = g.syms.get("AT").unwrap();
3931 let c1 = g.ids.get("c1").unwrap();
3932 assert!(g.topo.neighbors(at, Direction::Out, t).contains(&c1));
3933 (at, c1, g.topo.edge_count())
3934 };
3935 let mut g = fx.g();
3936 eng.rebuild("fk", &mut g).unwrap();
3937 assert_eq!(g.topo.edge_count(), count_before); assert!(g.topo.neighbors(at, Direction::Out, t).contains(&c1));
3939 }
3940
3941 #[test]
3942 fn score_refresh_on_persisting_owned_edge() {
3943 let mut fx = Fx::new();
3946 let a = fx.add("A", "a", vec![("tags", tags(&["x", "y", "z"]))]);
3947 let b = fx.add("A", "b", vec![("tags", tags(&["x", "y", "q"]))]);
3948 let et = fx.syms.intern("SIM");
3949 let mut eng = RuleEngine::new();
3950 {
3951 let mut g = fx.g();
3952 eng.create_rule(
3953 RuleDef {
3954 name: "sim".into(),
3955 src_label: "A".into(),
3956 dst_label: "A".into(),
3957 predicate: Predicate::Overlap {
3958 field: "tags".into(),
3959 min: 0.2,
3960 },
3961 edge_type: "SIM".into(),
3962 weight_prop: Some("score".into()),
3963 max_edges: None,
3964 approximate: false,
3965 via_label: None,
3966 via_edge: None,
3967 via_dir: None,
3968 },
3969 &mut g,
3970 )
3971 .unwrap();
3972 assert!(g.topo.neighbors(et, Direction::Out, a).contains(&b));
3974 assert!(g.topo.neighbors(et, Direction::Out, b).contains(&a));
3975 assert!(eng.is_owned(et, a, b) || eng.is_owned(et, b, a));
3976 let check = |v: Option<&Value>| {
3977 if let Some(Value::Float(f)) = v {
3978 assert!(
3979 (f - 0.5).abs() < 1e-9,
3980 "initial score should be 0.5, got {f}"
3981 );
3982 }
3983 };
3984 check(g.edge_props.get(et, a, b, "score"));
3985 check(g.edge_props.get(et, b, a, "score"));
3986 }
3987 let old = fx.props.get(b, "tags").cloned();
3989 fx.props.set(b, "tags", tags(&["x", "y", "z"]));
3990 {
3991 let mut g = fx.g();
3992 eng.on_node_changed(b, Some(("tags", old)), &mut g);
3993 assert!(g.topo.neighbors(et, Direction::Out, a).contains(&b));
3995 assert!(g.topo.neighbors(et, Direction::Out, b).contains(&a));
3996 assert_eq!(
3998 g.edge_props.get(et, a, b, "score"),
3999 Some(&Value::Float(1.0)),
4000 "score on a→b must refresh to 1.0"
4001 );
4002 assert_eq!(
4003 g.edge_props.get(et, b, a, "score"),
4004 Some(&Value::Float(1.0)),
4005 "score on b→a must refresh to 1.0"
4006 );
4007 }
4008 }
4009
4010 #[test]
4011 fn dst_side_keymatch_links_when_c_node_inserted_after_t() {
4012 let mut fx = Fx::new();
4014 let t = fx.add("T", "t1", vec![("cid", Value::Str("c9".into()))]);
4016 let mut eng = RuleEngine::new();
4017 {
4018 let mut g = fx.g();
4019 eng.create_rule(
4020 RuleDef {
4021 name: "fk".into(),
4022 src_label: "T".into(),
4023 dst_label: "C".into(),
4024 predicate: Predicate::KeyMatch {
4025 field: "cid".into(),
4026 },
4027 edge_type: "AT".into(),
4028 weight_prop: None,
4029 max_edges: None,
4030 approximate: false,
4031 via_label: None,
4032 via_edge: None,
4033 via_dir: None,
4034 },
4035 &mut g,
4036 )
4037 .unwrap();
4038 let at = g.syms.intern("AT");
4040 assert_eq!(g.topo.edge_count(), 0, "no C node yet → no edge");
4041 let _ = at;
4043 }
4044 let c9 = fx.add("C", "c9", vec![]);
4046 {
4047 let mut g = fx.g();
4048 eng.on_node_changed(c9, None, &mut g);
4049 let at = g.syms.get("AT").unwrap();
4050 assert!(
4052 g.topo.neighbors(at, Direction::Out, t).contains(&c9),
4053 "T→C edge must appear when C node is inserted"
4054 );
4055 assert!(eng.is_owned(at, t, c9));
4056 }
4057 }
4058
4059 #[test]
4060 fn on_node_removed_retracts_both_sides_and_deindexes() {
4061 let mut fx = Fx::new();
4062 let a = fx.add("A", "a", vec![("tags", tags(&["x", "y"]))]);
4063 let b = fx.add("A", "b", vec![("tags", tags(&["x", "y"]))]);
4064 let et = fx.syms.intern("REL");
4065 let mut eng = RuleEngine::new();
4066 {
4067 let mut g = fx.g();
4068 eng.create_rule(overlap_rule(), &mut g).unwrap();
4069 assert!(g.topo.neighbors(et, Direction::Out, a).contains(&b));
4070 assert!(g.topo.neighbors(et, Direction::Out, b).contains(&a));
4071 }
4072 {
4073 let mut g = fx.g();
4074 eng.on_node_removed(a, &mut g);
4075 assert!(!g.topo.neighbors(et, Direction::Out, a).contains(&b));
4076 assert!(!g.topo.neighbors(et, Direction::Out, b).contains(&a));
4077 assert_eq!(g.edge_props.get(et, a, b, "score"), None);
4078 assert_eq!(g.edge_props.get(et, b, a, "score"), None);
4079 assert!(!eng.is_owned(et, a, b));
4080 assert!(!eng.is_owned(et, b, a));
4081 }
4082 let c = fx.add("A", "c", vec![("tags", tags(&["x", "y"]))]);
4084 {
4085 let mut g = fx.g();
4086 eng.on_node_changed(c, None, &mut g);
4087 assert!(g.topo.neighbors(et, Direction::Out, b).contains(&c));
4088 assert!(g.topo.neighbors(et, Direction::Out, c).contains(&b));
4089 assert!(!g.topo.neighbors(et, Direction::Out, c).contains(&a));
4090 assert!(!g.topo.neighbors(et, Direction::Out, a).contains(&c));
4091 }
4092 {
4094 let mut g = fx.g();
4095 eng.on_node_removed(a, &mut g);
4096 assert!(g.topo.neighbors(et, Direction::Out, b).contains(&c));
4097 }
4098 }
4099
4100 #[test]
4101 fn duplicate_name_and_unknown_delete_error() {
4102 let mut fx = Fx::new();
4103 let mut eng = RuleEngine::new();
4104 let mut g = fx.g();
4105 eng.create_rule(overlap_rule(), &mut g).unwrap();
4106 assert!(eng.create_rule(overlap_rule(), &mut g).is_err());
4107 assert!(eng.delete_rule("nope", &mut g).is_err());
4108 }
4109
4110 #[test]
4116 fn coowned_edge_type_survives_first_delete_gone_after_second() {
4117 let mut fx = Fx::new();
4118 let a = fx.add("A", "a", vec![("tags", tags(&["x", "y"]))]);
4119 let b = fx.add("A", "b", vec![("tags", tags(&["x", "y"]))]);
4120 let mut eng = RuleEngine::new();
4121 {
4122 let mut g = fx.g();
4123 eng.create_rule(
4125 RuleDef {
4126 name: "r1".into(),
4127 src_label: "A".into(),
4128 dst_label: "A".into(),
4129 predicate: Predicate::Overlap {
4130 field: "tags".into(),
4131 min: 0.1,
4132 },
4133 edge_type: "REL2".into(),
4134 weight_prop: None,
4135 max_edges: None,
4136 approximate: false,
4137 via_label: None,
4138 via_edge: None,
4139 via_dir: None,
4140 },
4141 &mut g,
4142 )
4143 .unwrap();
4144 eng.create_rule(
4146 RuleDef {
4147 name: "r2".into(),
4148 src_label: "A".into(),
4149 dst_label: "A".into(),
4150 predicate: Predicate::Overlap {
4151 field: "tags".into(),
4152 min: 0.2,
4153 },
4154 edge_type: "REL2".into(),
4155 weight_prop: None,
4156 max_edges: None,
4157 approximate: false,
4158 via_label: None,
4159 via_edge: None,
4160 via_dir: None,
4161 },
4162 &mut g,
4163 )
4164 .unwrap();
4165
4166 let et = g.syms.intern("REL2");
4167 assert!(
4169 g.topo.neighbors(et, Direction::Out, a).contains(&b),
4170 "a→b must exist after both rules created"
4171 );
4172 assert!(
4173 g.topo.neighbors(et, Direction::Out, b).contains(&a),
4174 "b→a must exist after both rules created"
4175 );
4176
4177 eng.delete_rule("r1", &mut g).unwrap();
4179 assert!(
4180 g.topo.neighbors(et, Direction::Out, a).contains(&b),
4181 "a→b must survive R1 deletion (R2 rebuilds and claims it)"
4182 );
4183 assert!(
4184 g.topo.neighbors(et, Direction::Out, b).contains(&a),
4185 "b→a must survive R1 deletion (R2 rebuilds and claims it)"
4186 );
4187 assert!(
4189 eng.is_owned(et, a, b),
4190 "a→b must be owned by R2 after rebuild"
4191 );
4192 assert!(
4193 eng.is_owned(et, b, a),
4194 "b→a must be owned by R2 after rebuild"
4195 );
4196
4197 eng.delete_rule("r2", &mut g).unwrap();
4199 assert!(
4200 !g.topo.neighbors(et, Direction::Out, a).contains(&b),
4201 "a→b must be gone after both rules deleted"
4202 );
4203 assert!(
4204 !g.topo.neighbors(et, Direction::Out, b).contains(&a),
4205 "b→a must be gone after both rules deleted"
4206 );
4207 }
4208 }
4209
4210 fn topk_eq_rule(k: u64) -> RuleDef {
4212 RuleDef {
4213 name: "eq".into(),
4214 src_label: "N".into(),
4215 dst_label: "N".into(),
4216 predicate: Predicate::FieldEqual { field: "k".into() },
4217 edge_type: "EQ".into(),
4218 weight_prop: None,
4219 max_edges: Some(k),
4220 approximate: false,
4221 via_label: None,
4222 via_edge: None,
4223 via_dir: None,
4224 }
4225 }
4226
4227 fn prov_pairs(eng: &RuleEngine, name: &str) -> BTreeSet<(u32, u32)> {
4228 eng.provenance()
4229 .get(name)
4230 .map(|s| s.iter().map(|&(_, a, b)| (a, b)).collect())
4231 .unwrap_or_default()
4232 }
4233
4234 #[test]
4238 fn topk_k1_keeps_best_scored_dst() {
4239 let mut fx = Fx::new();
4240 let mut eng = RuleEngine::new();
4241 {
4242 let mut g = fx.g();
4243 eng.create_rule(topk_eq_rule(1), &mut g).unwrap();
4244 }
4245 let mut ids = Vec::new();
4247 for i in 0..4usize {
4248 let id = fx.add(
4249 "N",
4250 &format!("n{i}"),
4251 vec![("k", Value::Str("const".into()))],
4252 );
4253 ids.push(id);
4254 let mut g = fx.g();
4255 eng.on_node_changed(id, None, &mut g);
4256 }
4257 let et = fx.syms.get("EQ").unwrap();
4258 let expected_dsts = [ids[1], ids[0], ids[0], ids[0]];
4264 for (i, (&src, &expected_dst)) in ids.iter().zip(expected_dsts.iter()).enumerate() {
4265 let out: Vec<u32> = fx.topo.neighbors(et, Direction::Out, src).to_vec();
4266 assert_eq!(
4267 out,
4268 vec![expected_dst],
4269 "src n{i} should point only to the best dst"
4270 );
4271 }
4272 assert_eq!(eng.provenance()["eq"].len(), 4);
4273 assert!(!eng.is_tripped("eq"), "top-k rules never trip");
4274 }
4275
4276 #[test]
4279 fn topk_insert_evict() {
4280 let mut fx = Fx::new();
4284 let rule = RuleDef {
4285 name: "nw".into(),
4286 src_label: "S".into(),
4287 dst_label: "D".into(),
4288 predicate: Predicate::NumericWithin {
4289 field: "v".into(),
4290 tolerance: 10.0,
4291 },
4292 edge_type: "NEAR".into(),
4293 weight_prop: Some("score".into()),
4294 max_edges: Some(1),
4295 approximate: false,
4296 via_label: None,
4297 via_edge: None,
4298 via_dir: None,
4299 };
4300 let mut eng = RuleEngine::new();
4301 {
4302 let mut g = fx.g();
4303 eng.create_rule(rule, &mut g).unwrap();
4304 }
4305
4306 let s0 = fx.add("S", "s0", vec![("v", Value::Float(0.0))]);
4308 let d_far = fx.add("D", "d_far", vec![("v", Value::Float(9.0))]);
4310 {
4311 let mut g = fx.g();
4312 eng.on_node_changed(s0, None, &mut g);
4313 eng.on_node_changed(d_far, None, &mut g);
4314 }
4315 let et = fx.syms.get("NEAR").unwrap();
4316 assert!(fx.topo.neighbors(et, Direction::Out, s0).contains(&d_far));
4318 assert_eq!(eng.provenance()["nw"].len(), 1);
4319
4320 let d_close = fx.add("D", "d_close", vec![("v", Value::Float(1.0))]);
4322 {
4323 let mut g = fx.g();
4324 eng.on_node_changed(d_close, None, &mut g);
4325 }
4326 let out: Vec<u32> = fx.topo.neighbors(et, Direction::Out, s0).to_vec();
4328 assert_eq!(out, vec![d_close], "d_close should evict d_far");
4329 assert!(!fx.topo.neighbors(et, Direction::Out, s0).contains(&d_far));
4330 assert_eq!(eng.provenance()["nw"].len(), 1);
4331 assert!(eng.by_node_consistent());
4332 }
4333
4334 #[test]
4336 fn topk_retract_backfill() {
4337 let mut fx = Fx::new();
4338 let rule = RuleDef {
4339 name: "nw".into(),
4340 src_label: "S".into(),
4341 dst_label: "D".into(),
4342 predicate: Predicate::NumericWithin {
4343 field: "v".into(),
4344 tolerance: 10.0,
4345 },
4346 edge_type: "NEAR".into(),
4347 weight_prop: Some("score".into()),
4348 max_edges: Some(1),
4349 approximate: false,
4350 via_label: None,
4351 via_edge: None,
4352 via_dir: None,
4353 };
4354 let mut eng = RuleEngine::new();
4355
4356 let s0 = fx.add("S", "s0", vec![("v", Value::Float(0.0))]);
4357 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))]); {
4360 let mut g = fx.g();
4361 eng.create_rule(rule, &mut g).unwrap();
4362 }
4363 let et = fx.syms.get("NEAR").unwrap();
4364 assert!(fx.topo.neighbors(et, Direction::Out, s0).contains(&d_close));
4366 assert!(!fx.topo.neighbors(et, Direction::Out, s0).contains(&d_far));
4367 assert_eq!(eng.provenance()["nw"].len(), 1);
4368
4369 let old = fx.props.get(d_close, "v").cloned();
4371 fx.props.set(d_close, "v", Value::Float(50.0));
4372 {
4373 let mut g = fx.g();
4374 eng.on_node_changed(d_close, Some(("v", old)), &mut g);
4375 }
4376 assert!(!fx.topo.neighbors(et, Direction::Out, s0).contains(&d_close));
4378 assert!(
4379 fx.topo.neighbors(et, Direction::Out, s0).contains(&d_far),
4380 "d_far should backfill after d_close retracted"
4381 );
4382 assert_eq!(eng.provenance()["nw"].len(), 1);
4383 assert!(eng.by_node_consistent());
4384 }
4385
4386 #[test]
4388 fn topk_tie_broken_by_dst_key() {
4389 let mut fx = Fx::new();
4391 let mut eng = RuleEngine::new();
4392 {
4393 let mut g = fx.g();
4394 eng.create_rule(topk_eq_rule(2), &mut g).unwrap();
4395 }
4396 for name in ["a", "b", "c", "d", "e"] {
4399 let id = fx.add("N", name, vec![("k", Value::Str("x".into()))]);
4400 let mut g = fx.g();
4401 eng.on_node_changed(id, None, &mut g);
4402 }
4403 let et = fx.syms.get("EQ").unwrap();
4404 let get_id = |key: &str| fx.ids.get(key).unwrap();
4405 let a = get_id("a");
4407 let b = get_id("b");
4408 let c = get_id("c");
4409 let out_a: BTreeSet<u32> = fx
4410 .topo
4411 .neighbors(et, Direction::Out, a)
4412 .iter()
4413 .copied()
4414 .collect();
4415 assert!(out_a.contains(&b), "a→b (b is best key after a)");
4416 assert!(out_a.contains(&c), "a→c (c is 2nd best key)");
4417 assert_eq!(out_a.len(), 2);
4418 let e = get_id("e");
4420 let out_e: BTreeSet<u32> = fx
4421 .topo
4422 .neighbors(et, Direction::Out, e)
4423 .iter()
4424 .copied()
4425 .collect();
4426 assert!(out_e.contains(&a), "e→a");
4427 assert!(out_e.contains(&b), "e→b");
4428 assert_eq!(out_e.len(), 2);
4429 assert!(eng.by_node_consistent());
4430 }
4431
4432 #[test]
4434 fn topk_k_larger_than_candidate_count() {
4435 let mut fx = Fx::new();
4436 let mut eng = RuleEngine::new();
4437 {
4438 let mut g = fx.g();
4439 eng.create_rule(topk_eq_rule(100), &mut g).unwrap();
4441 }
4442 for i in 0..4usize {
4443 let id = fx.add("N", &format!("n{i}"), vec![("k", Value::Str("c".into()))]);
4444 let mut g = fx.g();
4445 eng.on_node_changed(id, None, &mut g);
4446 }
4447 assert_eq!(eng.provenance()["eq"].len(), 12);
4449 assert!(!eng.is_tripped("eq"));
4450 }
4451
4452 #[test]
4455 fn topk_rebuild_exact() {
4456 let mut fx = Fx::new();
4457 let mut eng = RuleEngine::new();
4458 {
4459 let mut g = fx.g();
4460 eng.create_rule(topk_eq_rule(1), &mut g).unwrap();
4461 }
4462 let _a = fx.add("N", "a", vec![("k", Value::Str("x".into()))]);
4464 let _b = fx.add("N", "b", vec![("k", Value::Str("x".into()))]);
4465 let _c = fx.add("N", "c", vec![("k", Value::Str("x".into()))]);
4466 {
4467 let mut g = fx.g();
4468 eng.on_node_changed(_a, None, &mut g);
4469 eng.on_node_changed(_b, None, &mut g);
4470 eng.on_node_changed(_c, None, &mut g);
4471 }
4472 assert_eq!(eng.provenance()["eq"].len(), 3);
4473
4474 {
4476 let mut g = fx.g();
4477 eng.rebuild("eq", &mut g).unwrap();
4478 }
4479 assert_eq!(eng.provenance()["eq"].len(), 3);
4480 assert!(!eng.is_tripped("eq"));
4481 assert!(eng.by_node_consistent());
4482 }
4483
4484 #[test]
4486 fn topk_by_node_consistent() {
4487 let mut fx = Fx::new();
4488 let mut eng = RuleEngine::new();
4489 {
4490 let mut g = fx.g();
4491 eng.create_rule(topk_eq_rule(2), &mut g).unwrap();
4492 }
4493 for i in 0..5usize {
4494 let id = fx.add(
4495 "N",
4496 &format!("n{i}"),
4497 vec![("k", Value::Str("const".into()))],
4498 );
4499 let mut g = fx.g();
4500 eng.on_node_changed(id, None, &mut g);
4501 }
4502 assert!(eng.by_node_consistent(), "consistent after insertions");
4503
4504 let id2 = fx.ids.get("n2").unwrap();
4506 let old = fx.props.get(id2, "k").cloned();
4507 fx.props.set(id2, "k", Value::Str("other".into()));
4508 {
4509 let mut g = fx.g();
4510 eng.on_node_changed(id2, Some(("k", old)), &mut g);
4511 }
4512 assert!(eng.by_node_consistent(), "consistent after eviction");
4513
4514 {
4515 let mut g = fx.g();
4516 eng.rebuild("eq", &mut g).unwrap();
4517 }
4518 assert!(eng.by_node_consistent(), "consistent after rebuild");
4519 }
4520
4521 fn numeric_rule() -> RuleDef {
4522 RuleDef {
4523 name: "nw".into(),
4524 src_label: "C".into(),
4525 dst_label: "C".into(),
4526 predicate: Predicate::NumericWithin {
4527 field: "year".into(),
4528 tolerance: 2.0,
4529 },
4530 edge_type: "NEAR".into(),
4531 weight_prop: Some("score".into()),
4532 max_edges: None,
4533 approximate: false,
4534 via_label: None,
4535 via_edge: None,
4536 via_dir: None,
4537 }
4538 }
4539
4540 fn geo_rule() -> RuleDef {
4541 RuleDef {
4542 name: "geo".into(),
4543 src_label: "City".into(),
4544 dst_label: "City".into(),
4545 predicate: Predicate::GeoRadius {
4546 field: "loc".into(),
4547 km: 400.0,
4548 },
4549 edge_type: "NEAR_GEO".into(),
4550 weight_prop: Some("score".into()),
4551 max_edges: None,
4552 approximate: false,
4553 via_label: None,
4554 via_edge: None,
4555 via_dir: None,
4556 }
4557 }
4558
4559 fn vec_rule() -> RuleDef {
4560 RuleDef {
4561 name: "vec".into(),
4562 src_label: "Doc".into(),
4563 dst_label: "Doc".into(),
4564 predicate: Predicate::VectorSimilar {
4565 field: "emb".into(),
4566 min: 0.9,
4567 },
4568 edge_type: "SIM".into(),
4569 weight_prop: Some("score".into()),
4570 max_edges: None,
4571 approximate: false,
4572 via_label: None,
4573 via_edge: None,
4574 via_dir: None,
4575 }
4576 }
4577
4578 fn pair_edges(topo: &Topology, et: u32, a: u32, b: u32) -> bool {
4579 topo.neighbors(et, Direction::Out, a).contains(&b)
4580 && topo.neighbors(et, Direction::Out, b).contains(&a)
4581 }
4582
4583 #[test]
4584 fn numeric_within_incremental_crosses_bucket_and_clears_old_index() {
4585 let mut fx = Fx::new();
4586 let a = fx.add("C", "a", vec![("year", Value::Float(10.0))]);
4587 let b = fx.add("C", "b", vec![("year", Value::Float(12.0))]);
4588 let et = fx.syms.intern("NEAR");
4589 let mut eng = RuleEngine::new();
4590 {
4591 let mut g = fx.g();
4592 eng.create_rule(numeric_rule(), &mut g).unwrap();
4593 assert!(pair_edges(g.topo, et, a, b));
4595 }
4596
4597 let old = fx.props.get(b, "year").cloned();
4600 fx.props.set(b, "year", Value::Float(16.1));
4601 {
4602 let mut g = fx.g();
4603 eng.on_node_changed(b, Some(("year", old)), &mut g);
4604 assert!(!pair_edges(g.topo, et, a, b));
4605 assert_eq!(g.topo.edge_count(), 0);
4606 }
4607 let def = numeric_rule();
4608 let spec = candidate_spec_for(&def);
4609 let old_map: std::collections::HashMap<_, _> =
4610 [("year".to_string(), Value::Float(12.0))].into();
4611 let old_get = |f: &str| old_map.get(f).cloned();
4612 let src_hits = eng.indexes["nw"].src_side.candidates(&spec, &old_get);
4613 let dst_hits = eng.indexes["nw"].dst_side.candidates(&spec, &old_get);
4614 assert!(!src_hits.contains(&b), "old src bucket must drop b");
4615 assert!(!dst_hits.contains(&b), "old dst bucket must drop b");
4616 assert!(src_hits.contains(&a));
4617
4618 let old = fx.props.get(b, "year").cloned();
4620 fx.props.set(b, "year", Value::Float(11.9));
4621 let mut g = fx.g();
4622 eng.on_node_changed(b, Some(("year", old)), &mut g);
4623 assert!(pair_edges(g.topo, et, a, b));
4624 }
4625
4626 fn loc_val(lat: f64, lon: f64) -> Value {
4627 Value::List(vec![Value::Float(lat), Value::Float(lon)])
4628 }
4629
4630 fn emb_val(vals: &[f64]) -> Value {
4631 Value::List(vals.iter().copied().map(Value::Float).collect())
4632 }
4633
4634 #[test]
4635 fn rebuild_is_noop_for_numeric_geo_and_vector() {
4636 let mut fx = Fx::new();
4637 let ca = fx.add("C", "ca", vec![("year", Value::Int(1998))]);
4638 let cb = fx.add("C", "cb", vec![("year", Value::Float(2000.0))]);
4639 let pa = fx.add("City", "paris", vec![("loc", loc_val(48.8566, 2.3522))]);
4640 let lo = fx.add("City", "london", vec![("loc", loc_val(51.5074, -0.1278))]);
4641 let da = fx.add("Doc", "d1", vec![("emb", emb_val(&[1.0, 0.0]))]);
4642 let db = fx.add("Doc", "d2", vec![("emb", emb_val(&[1.0, 0.0]))]);
4643
4644 let mut eng = RuleEngine::new();
4645 {
4646 let mut g = fx.g();
4647 eng.create_rule(numeric_rule(), &mut g).unwrap();
4648 eng.create_rule(geo_rule(), &mut g).unwrap();
4649 eng.create_rule(vec_rule(), &mut g).unwrap();
4650 }
4651
4652 let (near, ngeo, sim) = (
4653 fx.syms.get("NEAR").unwrap(),
4654 fx.syms.get("NEAR_GEO").unwrap(),
4655 fx.syms.get("SIM").unwrap(),
4656 );
4657 assert!(pair_edges(&fx.topo, near, ca, cb));
4658 assert!(pair_edges(&fx.topo, ngeo, pa, lo));
4659 assert!(pair_edges(&fx.topo, sim, da, db));
4660 let before = fx.topo.edge_count();
4661
4662 {
4663 let mut g = fx.g();
4664 eng.rebuild("nw", &mut g).unwrap();
4665 eng.rebuild("geo", &mut g).unwrap();
4666 eng.rebuild("vec", &mut g).unwrap();
4667 }
4668 assert_eq!(fx.topo.edge_count(), before);
4669 assert!(pair_edges(&fx.topo, near, ca, cb));
4670 assert!(pair_edges(&fx.topo, ngeo, pa, lo));
4671 assert!(pair_edges(&fx.topo, sim, da, db));
4672 }
4673
4674 fn fk_rule() -> RuleDef {
4675 RuleDef {
4676 name: "works_at".into(),
4677 src_label: "T".into(),
4678 dst_label: "C".into(),
4679 predicate: Predicate::KeyMatch {
4680 field: "cid".into(),
4681 },
4682 edge_type: "AT".into(),
4683 weight_prop: None,
4684 max_edges: None,
4685 approximate: false,
4686 via_label: None,
4687 via_edge: None,
4688 via_dir: None,
4689 }
4690 }
4691
4692 #[test]
4693 fn by_node_matches_rebuild_after_mutation_storm() {
4694 let mut fx = Fx::new();
4695 let hub = fx.add("C", "hub", vec![]);
4696 let other = fx.add("C", "other", vec![]);
4697 let mut people = Vec::new();
4698 for i in 0..40 {
4699 let cid = if i < 30 { "hub" } else { "other" };
4700 people.push(fx.add(
4701 "T",
4702 &format!("t{i}"),
4703 vec![("cid", Value::Str(cid.into())), ("tags", tags(&["x", "y"]))],
4704 ));
4705 }
4706 let mut overlap = overlap_rule();
4707 overlap.src_label = "T".into();
4708 overlap.dst_label = "T".into();
4709 let mut eng = RuleEngine::new();
4710 {
4711 let mut g = fx.g();
4712 eng.create_rule(fk_rule(), &mut g).unwrap();
4713 eng.create_rule(overlap, &mut g).unwrap();
4714 }
4715 assert!(eng.by_node_consistent());
4716 assert_eq!(eng.provenance_touching_len(hub), 30);
4717
4718 for (i, &id) in people.iter().enumerate().take(15) {
4720 let old = fx.props.get(id, "cid").cloned();
4721 fx.props.set(id, "cid", Value::Str("other".into()));
4722 let mut g = fx.g();
4723 eng.on_node_changed(id, Some(("cid", old)), &mut g);
4724 assert!(
4725 eng.by_node_consistent(),
4726 "inconsistent after cid update {i}"
4727 );
4728 }
4729 for &id in people.iter().take(8) {
4730 let old = fx.props.get(id, "tags").cloned();
4731 fx.props.set(id, "tags", tags(&["q"]));
4732 let mut g = fx.g();
4733 eng.on_node_changed(id, Some(("tags", old)), &mut g);
4734 }
4735 assert!(eng.by_node_consistent());
4736
4737 {
4739 let mut g = fx.g();
4740 eng.on_node_removed(people[0], &mut g);
4741 }
4742 fx.labels[people[0] as usize] = u32::MAX;
4743 assert!(eng.by_node_consistent());
4744 assert_eq!(eng.provenance_touching_len(people[0]), 0);
4745
4746 {
4747 let mut g = fx.g();
4748 eng.rebuild("works_at", &mut g).unwrap();
4749 eng.rebuild("rel", &mut g).unwrap();
4750 }
4751 assert!(eng.by_node_consistent());
4752
4753 {
4754 let mut g = fx.g();
4755 eng.delete_rule("rel", &mut g).unwrap();
4756 }
4757 assert!(eng.by_node_consistent());
4758 assert_eq!(eng.provenance_touching(people[1]).count(), 1);
4759
4760 let (defs, prov, tripped, fires) = eng.to_persist();
4762 let restored = RuleEngine::from_persist(defs, prov, tripped, fires);
4763 assert!(restored.by_node_consistent());
4764 assert_eq!(
4765 restored.provenance_touching_len(hub),
4766 eng.provenance_touching_len(hub)
4767 );
4768 assert_eq!(
4769 restored.provenance_touching_len(other),
4770 eng.provenance_touching_len(other)
4771 );
4772 }
4773
4774 #[test]
4775 fn provenance_touching_high_degree_hub() {
4776 let mut fx = Fx::new();
4777 let hub = fx.add("C", "hub", vec![]);
4778 let mut first = None;
4779 for i in 0..256 {
4780 let id = fx.add(
4781 "T",
4782 &format!("t{i}"),
4783 vec![("cid", Value::Str("hub".into()))],
4784 );
4785 if first.is_none() {
4786 first = Some(id);
4787 }
4788 }
4789 let first = first.unwrap();
4790 let mut eng = RuleEngine::new();
4791 {
4792 let mut g = fx.g();
4793 eng.create_rule(fk_rule(), &mut g).unwrap();
4794 }
4795 assert!(eng.by_node_consistent());
4796 assert_eq!(eng.provenance_touching_len(hub), 256);
4797 assert_eq!(eng.provenance_touching_len(first), 1);
4798 let hits: Vec<_> = eng.provenance_touching(first).collect();
4799 assert_eq!(hits.len(), 1);
4800 assert_eq!(hits[0].0, "works_at");
4801 assert_eq!(hits[0].2, first);
4802 assert_eq!(hits[0].3, hub);
4803 }
4804
4805 #[test]
4813 fn by_node_consistent_across_inserts_and_rebuild() {
4814 let mut fx = Fx::new();
4815 let mut eng = RuleEngine::new();
4816 let rule = RuleDef {
4817 name: "eq".into(),
4818 src_label: "N".into(),
4819 dst_label: "N".into(),
4820 predicate: Predicate::FieldEqual { field: "k".into() },
4821 edge_type: "EQ".into(),
4822 weight_prop: None,
4823 max_edges: None, approximate: false,
4825 via_label: None,
4826 via_edge: None,
4827 via_dir: None,
4828 };
4829 {
4830 let mut g = fx.g();
4831 eng.create_rule(rule, &mut g).unwrap();
4832 }
4833 let mut ids = Vec::new();
4834 for i in 0..6 {
4835 let id = fx.add(
4836 "N",
4837 &format!("n{i}"),
4838 vec![("k", Value::Str("const".into()))],
4839 );
4840 ids.push(id);
4841 let mut g = fx.g();
4842 eng.on_node_changed(id, None, &mut g);
4843 }
4844 assert_eq!(eng.provenance()["eq"].len(), 30);
4846 assert!(!eng.is_tripped("eq"));
4847 assert!(eng.by_node_consistent(), "consistent after insertions");
4848
4849 let old = fx.props.get(ids[3], "k").cloned();
4851 fx.props.set(ids[3], "k", Value::Str("other".into()));
4852 {
4853 let mut g = fx.g();
4854 eng.on_node_changed(ids[3], Some(("k", old)), &mut g);
4855 }
4856 assert!(eng.by_node_consistent(), "consistent after property change");
4857
4858 {
4859 let mut g = fx.g();
4860 eng.rebuild("eq", &mut g).unwrap();
4861 }
4862 assert!(!eng.is_tripped("eq"));
4863 assert!(eng.by_node_consistent(), "consistent after rebuild");
4864 }
4865
4866 fn mix64(mut x: u64) -> u64 {
4867 x = x.wrapping_add(0x9E3779B97F4A7C15);
4868 x = (x ^ (x >> 30)).wrapping_mul(0xBF58476D1CE4E5B9);
4869 x = (x ^ (x >> 27)).wrapping_mul(0x94D049BB133111EB);
4870 x ^ (x >> 31)
4871 }
4872
4873 fn rand_emb(seed: u64, i: u32, dim: usize) -> Value {
4874 let vals: Vec<f64> = (0..dim)
4875 .map(|d| {
4876 let bits = mix64(seed ^ ((i as u64 + 1).wrapping_mul(0x100000001)) ^ (d as u64));
4877 let mut f = (bits as f64) / (u64::MAX as f64) * 2.0 - 1.0;
4878 if f == 0.0 {
4879 f = 1.0;
4880 }
4881 f
4882 })
4883 .collect();
4884 emb_val(&vals)
4885 }
4886
4887 fn seed_docs(n: u32, seed: u64) -> (Fx, Vec<u32>) {
4888 let dims = [2usize, 3, 4, 8];
4889 let mut fx = Fx::new();
4890 let mut ids = Vec::new();
4891 for i in 0..n {
4892 let dim = dims[(i as usize) % dims.len()];
4893 ids.push(fx.add(
4894 "Doc",
4895 &format!("d{i}"),
4896 vec![("emb", rand_emb(seed, i, dim))],
4897 ));
4898 }
4899 (fx, ids)
4900 }
4901
4902 #[test]
4905 fn vector_dim_reject_matches_unfiltered_and_oracle() {
4906 const N: u32 = 500;
4907 const SEED: u64 = 0xC0FF_EE00_D15C;
4908 let def = vec_rule();
4909
4910 let (mut fx_on, ids) = seed_docs(N, SEED);
4911 let mut eng_on = RuleEngine::new();
4912 {
4913 let mut g = fx_on.g();
4914 eng_on.create_rule(def.clone(), &mut g).unwrap();
4915 }
4916 let on = prov_pairs(&eng_on, "vec");
4917 assert!(!on.is_empty(), "seeded set must produce some edges");
4918
4919 let (mut fx_off, _) = seed_docs(N, SEED);
4920 let mut eng_off = RuleEngine::new();
4921 {
4922 let mut g = fx_off.g();
4923 with_vector_dim_reject(false, || {
4924 eng_off.create_rule(def.clone(), &mut g).unwrap();
4925 });
4926 }
4927 assert_eq!(on, prov_pairs(&eng_off, "vec"), "filter vs no-filter");
4928
4929 let mut brute = BTreeSet::new();
4930 for &s in &ids {
4931 for &d in &ids {
4932 if s == d {
4933 continue;
4934 }
4935 let skey = fx_on.ids.key_of(s).unwrap();
4936 let dkey = fx_on.ids.key_of(d).unwrap();
4937 let sget = |f: &str| fx_on.props.get(s, f).cloned();
4938 let dget = |f: &str| fx_on.props.get(d, f).cloned();
4939 if evaluate(
4940 &def.predicate,
4941 &NodeView {
4942 key: skey,
4943 props: &sget,
4944 },
4945 &NodeView {
4946 key: dkey,
4947 props: &dget,
4948 },
4949 )
4950 .is_some()
4951 {
4952 brute.insert((s, d));
4953 }
4954 }
4955 }
4956 assert_eq!(on, brute, "filter vs brute-force evaluate");
4957 }
4958
4959 #[test]
4962 fn vector_dim_change_updates_cache_and_matches_fresh_build() {
4963 let mut fx = Fx::new();
4964 let a = fx.add("Doc", "a", vec![("emb", emb_val(&[1.0, 0.0]))]);
4965 let b = fx.add("Doc", "b", vec![("emb", emb_val(&[1.0, 0.0]))]);
4966 let c = fx.add("Doc", "c", vec![("emb", emb_val(&[1.0, 0.0, 0.0]))]);
4967 let mut eng = RuleEngine::new();
4968 {
4969 let mut g = fx.g();
4970 eng.create_rule(vec_rule(), &mut g).unwrap();
4971 }
4972 assert_eq!(eng.indexes["vec"].src_side.vec_dim(a), Some(2));
4973 assert_eq!(eng.indexes["vec"].src_side.vec_dim(c), Some(3));
4974 assert_eq!(prov_pairs(&eng, "vec"), BTreeSet::from([(a, b), (b, a)]));
4975
4976 let old = fx.props.get(b, "emb").cloned();
4977 fx.props.set(b, "emb", emb_val(&[1.0, 0.0, 0.0]));
4978 {
4979 let mut g = fx.g();
4980 eng.on_node_changed(b, Some(("emb", old)), &mut g);
4981 }
4982 assert_eq!(eng.indexes["vec"].src_side.vec_dim(b), Some(3));
4983 assert_eq!(eng.indexes["vec"].dst_side.vec_dim(b), Some(3));
4984 let after = prov_pairs(&eng, "vec");
4985 assert_eq!(after, BTreeSet::from([(b, c), (c, b)]));
4986
4987 let mut fresh_fx = Fx::new();
4989 let fa = fresh_fx.add("Doc", "a", vec![("emb", emb_val(&[1.0, 0.0]))]);
4990 let fb = fresh_fx.add("Doc", "b", vec![("emb", emb_val(&[1.0, 0.0, 0.0]))]);
4991 let fc = fresh_fx.add("Doc", "c", vec![("emb", emb_val(&[1.0, 0.0, 0.0]))]);
4992 let mut fresh = RuleEngine::new();
4993 {
4994 let mut g = fresh_fx.g();
4995 fresh.create_rule(vec_rule(), &mut g).unwrap();
4996 }
4997 assert_eq!(
4998 prov_pairs(&fresh, "vec"),
4999 BTreeSet::from([(fb, fc), (fc, fb)])
5000 );
5001 assert_eq!(fresh.indexes["vec"].src_side.vec_dim(fb), Some(3));
5002 assert_eq!(fresh.indexes["vec"].src_side.vec_dim(fa), Some(2));
5003 }
5004
5005 #[test]
5025 fn streaming_topk_order_identity_property_test() {
5026 fn reference_topk(rule: &RuleDef, k: u64, fx: &mut Fx) -> BTreeSet<(u32, u32)> {
5029 let mut idx = RuleIndex::default();
5030 for id in 0..fx.ids.len() as u32 {
5031 let label_sym = match fx.labels.get(id as usize).copied() {
5032 Some(s) if s != u32::MAX => s,
5033 _ => continue,
5034 };
5035 index_node_for_rule(
5036 id,
5037 label_sym,
5038 rule,
5039 &mut idx,
5040 &fx.syms,
5041 ColumnsView::owned(&fx.props),
5042 );
5043 }
5044 let src_sym = fx.syms.get(&rule.src_label);
5045 let mut out = BTreeSet::new();
5046 let ids_snap: Vec<u32> = (0..fx.ids.len() as u32).collect();
5047 for id in ids_snap {
5048 let label_sym = match fx.labels.get(id as usize).copied() {
5049 Some(s) if s != u32::MAX => s,
5050 _ => continue,
5051 };
5052 if src_sym != Some(label_sym) {
5053 continue;
5054 }
5055 let g = GraphMut {
5056 ids: &fx.ids,
5057 syms: &mut fx.syms,
5058 labels: &fx.labels,
5059 props: ColumnsView::owned(&fx.props),
5060 topo: &mut fx.topo,
5061 base_topo: None,
5062 edge_props: &mut fx.eprops,
5063 };
5064 let per_src = compute_desired(rule, &idx, id, true, &g);
5065 let mut candidates: Vec<((u32, u32), f64)> = per_src.into_iter().collect();
5067 candidates.sort_by(|&((_, da), sa), &((_, db), sb)| {
5068 sb.total_cmp(&sa).then_with(|| {
5069 let ka = fx.ids.key_of(da).unwrap_or("");
5070 let kb = fx.ids.key_of(db).unwrap_or("");
5071 ka.cmp(kb)
5072 })
5073 });
5074 candidates.truncate(k as usize);
5075 out.extend(candidates.into_iter().map(|(k, _)| k));
5076 }
5077 out
5078 }
5079
5080 fn streaming_pairs(rule: RuleDef, fx: &mut Fx) -> BTreeSet<(u32, u32)> {
5082 let name = rule.name.clone();
5083 let mut eng = RuleEngine::new();
5084 eng.create_rule(rule, &mut fx.g()).unwrap();
5085 eng.provenance()
5086 .get(&name)
5087 .map(|s| s.iter().map(|&(_, a, b)| (a, b)).collect())
5088 .unwrap_or_default()
5089 }
5090
5091 for seed in [0u64, 1, 42, 0xDEAD_BEEF, 0x1234_5678, 99, 12_648_430, 7] {
5096 for k in [1u64, 2, 3, 5] {
5097 let rule = RuleDef {
5098 name: "eq".into(),
5099 src_label: "N".into(),
5100 dst_label: "N".into(),
5101 predicate: Predicate::FieldEqual { field: "k".into() },
5102 edge_type: "EQ".into(),
5103 weight_prop: None,
5104 max_edges: Some(k),
5105 approximate: false,
5106 via_label: None,
5107 via_edge: None,
5108 via_dir: None,
5109 };
5110
5111 let build = || {
5112 let mut fx = Fx::new();
5113 for i in 0..12u32 {
5114 let h = mix64(seed ^ (i as u64 + 1));
5115 let val = match h % 3 {
5116 0 => "a",
5117 1 => "b",
5118 _ => "c",
5119 };
5120 fx.add(
5121 "N",
5122 &format!("n{i:02}"),
5123 vec![("k", Value::Str(val.into()))],
5124 );
5125 }
5126 fx
5127 };
5128
5129 let expected = reference_topk(&rule, k, &mut build());
5130 let actual = streaming_pairs(rule, &mut build());
5131
5132 assert_eq!(
5133 expected, actual,
5134 "FieldEqual seed={seed} k={k}: streaming top-k must match brute-force top-k"
5135 );
5136 }
5137 }
5138
5139 for seed in [0u64, 1, 42, 7] {
5144 for k in [1u64, 2, 4] {
5145 let rule = RuleDef {
5146 name: "nw".into(),
5147 src_label: "S".into(),
5148 dst_label: "D".into(),
5149 predicate: Predicate::NumericWithin {
5150 field: "v".into(),
5151 tolerance: 10.0,
5152 },
5153 edge_type: "NEAR".into(),
5154 weight_prop: Some("score".into()),
5155 max_edges: Some(k),
5156 approximate: false,
5157 via_label: None,
5158 via_edge: None,
5159 via_dir: None,
5160 };
5161
5162 let build = || {
5163 let mut fx = Fx::new();
5164 for i in 0..6u32 {
5165 let h = mix64(seed ^ (i as u64 + 1));
5166 let v = (h % 20) as f64;
5167 fx.add("S", &format!("s{i}"), vec![("v", Value::Float(v))]);
5168 }
5169 for i in 0..8u32 {
5170 let h = mix64(seed ^ (i as u64 + 101));
5171 let v = (h % 20) as f64;
5172 fx.add("D", &format!("d{i}"), vec![("v", Value::Float(v))]);
5173 }
5174 fx
5175 };
5176
5177 let expected = reference_topk(&rule, k, &mut build());
5178 let actual = streaming_pairs(rule, &mut build());
5179
5180 assert_eq!(
5181 expected, actual,
5182 "NumericWithin seed={seed} k={k}: streaming top-k must match brute-force top-k"
5183 );
5184 }
5185 }
5186
5187 for seed in [0u64, 1, 42, 7] {
5194 for k in [1u64, 2] {
5195 let rule = RuleDef {
5196 name: "fk".into(),
5197 src_label: "T".into(),
5198 dst_label: "C".into(),
5199 predicate: Predicate::KeyMatch {
5200 field: "cid".into(),
5201 },
5202 edge_type: "AT".into(),
5203 weight_prop: None,
5204 max_edges: Some(k),
5205 approximate: false,
5206 via_label: None,
5207 via_edge: None,
5208 via_dir: None,
5209 };
5210
5211 let build = || {
5212 let mut fx = Fx::new();
5213 for i in 0..4u32 {
5215 fx.add("C", &format!("c{i}"), vec![]);
5216 }
5217 for i in 0..8u32 {
5219 let h = mix64(seed ^ (i as u64 + 1));
5220 let cid = format!("c{}", h % 4);
5221 fx.add("T", &format!("t{i}"), vec![("cid", Value::Str(cid))]);
5222 }
5223 fx
5224 };
5225
5226 let expected = reference_topk(&rule, k, &mut build());
5227 let actual = streaming_pairs(rule, &mut build());
5228
5229 assert_eq!(
5230 expected, actual,
5231 "KeyMatch seed={seed} k={k}: streaming top-k must match brute-force top-k"
5232 );
5233 }
5234 }
5235
5236 {
5242 let cluster_a: &[(&str, f64, f64)] = &[
5244 ("va0", 1.0_f64, 0.0_f64),
5245 ("va1", 0.98_f64, 0.199_f64), ("va2", 0.97_f64, 0.243_f64), ];
5248 let cluster_b: &[(&str, f64, f64)] = &[
5249 ("vb0", 0.0_f64, 1.0_f64),
5250 ("vb1", 0.1_f64, 0.995_f64),
5251 ("vb2", 0.05_f64, 0.999_f64),
5252 ];
5253 for k in [1u64, 2] {
5254 let rule = RuleDef {
5255 name: "vsim".into(),
5256 src_label: "V".into(),
5257 dst_label: "V".into(),
5258 predicate: Predicate::VectorSimilar {
5259 field: "emb".into(),
5260 min: 0.9,
5261 },
5262 edge_type: "VSIM".into(),
5263 weight_prop: Some("score".into()),
5264 max_edges: Some(k),
5265 approximate: false,
5266 via_label: None,
5267 via_edge: None,
5268 via_dir: None,
5269 };
5270
5271 let build = || {
5272 let mut fx = Fx::new();
5273 let mut add_v = |key: &str, x: f64, y: f64| {
5274 let norm = (x * x + y * y).sqrt();
5275 let v = Value::List(vec![Value::Float(x / norm), Value::Float(y / norm)]);
5276 fx.add("V", key, vec![("emb", v)]);
5277 };
5278 for &(k, x, y) in cluster_a.iter().chain(cluster_b.iter()) {
5279 add_v(k, x, y);
5280 }
5281 fx
5282 };
5283
5284 let expected = reference_topk(&rule, k, &mut build());
5285 let actual = streaming_pairs(rule, &mut build());
5286
5287 assert_eq!(
5288 expected, actual,
5289 "VectorSimilar/ScanAll k={k}: streaming top-k must match brute-force top-k"
5290 );
5291 }
5292 }
5293 }
5294
5295 #[test]
5321 #[ignore]
5322 fn streaming_peak_transient_bound() {
5323 use std::sync::{
5324 atomic::{AtomicBool, AtomicU64, Ordering},
5325 Arc,
5326 };
5327
5328 fn peak_rss_during<F: FnOnce()>(f: F) -> u64 {
5331 let done = Arc::new(AtomicBool::new(false));
5332 let peak = Arc::new(AtomicU64::new(0));
5333 let done2 = done.clone();
5334 let peak2 = peak.clone();
5335 let pid = std::process::id().to_string();
5336
5337 let handle = std::thread::spawn(move || {
5338 while !done2.load(Ordering::Relaxed) {
5339 let rss = std::process::Command::new("ps")
5340 .args(["-o", "rss=", "-p", &pid])
5341 .output()
5342 .ok()
5343 .and_then(|o| String::from_utf8(o.stdout).ok())
5344 .and_then(|s| s.trim().parse::<u64>().ok())
5345 .unwrap_or(0)
5346 * 1024;
5347 peak2.fetch_max(rss, Ordering::Relaxed);
5348 std::thread::sleep(std::time::Duration::from_millis(1));
5349 }
5350 });
5351
5352 f();
5353
5354 done.store(true, Ordering::Relaxed);
5355 let _ = handle.join();
5356 peak.load(Ordering::Relaxed)
5357 }
5358
5359 let mut fx = Fx::new();
5363 for i in 0..500u32 {
5364 fx.add(
5365 "Talent",
5366 &format!("t{i}"),
5367 vec![("k", Value::Str("same".into()))],
5368 );
5369 }
5370 for i in 0..500u32 {
5371 fx.add(
5372 "Company",
5373 &format!("c{i}"),
5374 vec![("k", Value::Str("same".into()))],
5375 );
5376 }
5377 let rule = RuleDef {
5378 name: "eq_tc".into(),
5379 src_label: "Talent".into(),
5380 dst_label: "Company".into(),
5381 predicate: Predicate::FieldEqual { field: "k".into() },
5382 edge_type: "EQ".into(),
5383 weight_prop: None,
5384 max_edges: Some(2), approximate: false,
5386 via_label: None,
5387 via_edge: None,
5388 via_dir: None,
5389 };
5390
5391 let pid = std::process::id().to_string();
5393 let baseline = std::process::Command::new("ps")
5394 .args(["-o", "rss=", "-p", &pid])
5395 .output()
5396 .ok()
5397 .and_then(|o| String::from_utf8(o.stdout).ok())
5398 .and_then(|s| s.trim().parse::<u64>().ok())
5399 .unwrap_or(0)
5400 * 1024;
5401
5402 let mut eng = RuleEngine::new();
5403 let peak = peak_rss_during(|| {
5404 eng.create_rule(rule, &mut fx.g()).unwrap();
5405 });
5406
5407 let peak_delta = peak.saturating_sub(baseline);
5408
5409 assert!(
5413 peak_delta < 3 * 1024 * 1024,
5414 "peak transient delta {} bytes ({} KiB) exceeded 3 MiB; \
5415 streaming path may be building the full pairs map",
5416 peak_delta,
5417 peak_delta / 1024
5418 );
5419 assert_eq!(eng.provenance()["eq_tc"].len(), 1_000); assert!(!eng.is_tripped("eq_tc")); eprintln!(
5422 "streaming_peak_transient_bound: baseline={baseline} peak={peak} \
5423 delta={peak_delta} bytes ({} KiB)",
5424 peak_delta / 1024
5425 );
5426 }
5427
5428 fn near_threshold_pair(dim: usize, min: f64) -> (Vec<f64>, Vec<f64>) {
5435 let cos_target = min + 1e-6; let sin_small = (1.0 - cos_target * cos_target).sqrt();
5439 let mut a = vec![0.0f64; dim];
5440 a[0] = 1.0;
5441 let mut b = vec![0.0f64; dim];
5442 b[0] = cos_target;
5443 if dim > 1 {
5444 b[1] = sin_small;
5445 }
5446 (a, b)
5447 }
5448
5449 fn emb_val2(xs: &[f64]) -> Value {
5450 Value::List(xs.iter().copied().map(Value::Float).collect())
5451 }
5452
5453 fn make_early_exit_fixture(seed: u64, min: f64) -> (Fx, Vec<u32>, usize, usize) {
5457 let dims = [2usize, 4, 8, 16];
5458 let n = 100u32;
5459 let mut fx = Fx::new();
5460 let mut ids = Vec::new();
5461 for i in 0..n {
5462 let dim = dims[(i as usize) % dims.len()];
5463 let emb = rand_emb(seed, i, dim);
5464 ids.push(fx.add("Doc", &format!("d{i}"), vec![("emb", emb)]));
5465 }
5466 let (va, vb) = near_threshold_pair(8, min);
5468 let nt_a = fx.add("Doc", "nt_a", vec![("emb", emb_val2(&va))]);
5469 let nt_b = fx.add("Doc", "nt_b", vec![("emb", emb_val2(&vb))]);
5470 ids.push(nt_a);
5471 ids.push(nt_b);
5472 (fx, ids, nt_a as usize, nt_b as usize)
5473 }
5474
5475 #[test]
5479 fn vector_early_exit_identity_proof() {
5480 const SEED: u64 = 0xEA_4E_5A;
5481 const MIN: f64 = 0.85;
5482
5483 let def = RuleDef {
5484 name: "vec".into(),
5485 src_label: "Doc".into(),
5486 dst_label: "Doc".into(),
5487 predicate: Predicate::VectorSimilar {
5488 field: "emb".into(),
5489 min: MIN,
5490 },
5491 edge_type: "SIM".into(),
5492 weight_prop: Some("score".into()),
5493 max_edges: None,
5494 approximate: false,
5495 via_label: None,
5496 via_edge: None,
5497 via_dir: None,
5498 };
5499
5500 let (mut fx_on, ids, nt_a, nt_b) = make_early_exit_fixture(SEED, MIN);
5502 let (mut fx_off, _, _, _) = make_early_exit_fixture(SEED, MIN);
5503 let (fx_oracle, _, _, _) = make_early_exit_fixture(SEED, MIN);
5504
5505 let nt_a = nt_a as u32;
5506 let nt_b = nt_b as u32;
5507
5508 let mut eng_on = RuleEngine::new();
5510 {
5511 let mut g = fx_on.g();
5512 eng_on.create_rule(def.clone(), &mut g).unwrap();
5513 }
5514 let edges_on = prov_pairs(&eng_on, "vec");
5515 assert!(!edges_on.is_empty(), "should produce some edges");
5516
5517 assert!(
5519 edges_on.contains(&(nt_a, nt_b)),
5520 "near-threshold pair nt_a→nt_b must match with early-exit ON"
5521 );
5522 assert!(
5523 edges_on.contains(&(nt_b, nt_a)),
5524 "near-threshold pair nt_b→nt_a must match with early-exit ON"
5525 );
5526
5527 let mut eng_off = RuleEngine::new();
5529 {
5530 let mut g = fx_off.g();
5531 with_vector_early_exit(false, || {
5532 eng_off.create_rule(def.clone(), &mut g).unwrap();
5533 });
5534 }
5535 let edges_off = prov_pairs(&eng_off, "vec");
5536 assert_eq!(
5537 edges_on, edges_off,
5538 "early-exit ON vs OFF must produce identical edges"
5539 );
5540
5541 let mut oracle = BTreeSet::new();
5543 for &s in &ids {
5544 for &d in &ids {
5545 if s == d {
5546 continue;
5547 }
5548 let skey = fx_oracle.ids.key_of(s).unwrap();
5549 let dkey = fx_oracle.ids.key_of(d).unwrap();
5550 let sg = |f: &str| fx_oracle.props.get(s, f).cloned();
5551 let dg = |f: &str| fx_oracle.props.get(d, f).cloned();
5552 if evaluate(
5553 &def.predicate,
5554 &NodeView {
5555 key: skey,
5556 props: &sg,
5557 },
5558 &NodeView {
5559 key: dkey,
5560 props: &dg,
5561 },
5562 )
5563 .is_some()
5564 {
5565 oracle.insert((s, d));
5566 }
5567 }
5568 }
5569 assert_eq!(
5570 edges_on, oracle,
5571 "early-exit ON vs brute-force oracle must be identical"
5572 );
5573 }
5574
5575 #[test]
5578 fn vector_early_exit_checkpoint_coherence() {
5579 let mut fx = Fx::new();
5580 let a = fx.add("Doc", "a", vec![("emb", emb_val(&[1.0, 0.0, 0.0, 0.0]))]);
5582 let b = fx.add("Doc", "b", vec![("emb", emb_val(&[1.0, 0.0, 0.0, 0.0]))]);
5583 let c = fx.add(
5585 "Doc",
5586 "c",
5587 vec![("emb", emb_val(&[1.0, 0.0, 0.0, 0.0, 0.0, 0.0]))],
5588 );
5589 let def = RuleDef {
5590 name: "vec".into(),
5591 src_label: "Doc".into(),
5592 dst_label: "Doc".into(),
5593 predicate: Predicate::VectorSimilar {
5594 field: "emb".into(),
5595 min: 0.9,
5596 },
5597 edge_type: "SIM".into(),
5598 weight_prop: None,
5599 max_edges: None,
5600 approximate: false,
5601 via_label: None,
5602 via_edge: None,
5603 via_dir: None,
5604 };
5605
5606 let mut eng = RuleEngine::new();
5607 {
5608 let mut g = fx.g();
5609 eng.create_rule(def.clone(), &mut g).unwrap();
5610 }
5611
5612 assert!(
5614 eng.indexes["vec"].src_side.vec_ckpts(a).is_some(),
5615 "a must have src checkpoints"
5616 );
5617 assert!(
5618 eng.indexes["vec"].dst_side.vec_ckpts(b).is_some(),
5619 "b must have dst checkpoints"
5620 );
5621 assert!(
5622 eng.indexes["vec"].src_side.vec_ckpts(c).is_some(),
5623 "c must have src checkpoints (dim=6)"
5624 );
5625
5626 let ckpts_a = *eng.indexes["vec"].src_side.vec_ckpts(a).unwrap();
5628 let norm_a = eng.indexes["vec"].src_side.vec_meta(a).unwrap().1;
5629 assert!(
5630 (ckpts_a[0] - norm_a).abs() < 1e-12,
5631 "ckpts[0] must equal the full L2 norm"
5632 );
5633
5634 assert_eq!(prov_pairs(&eng, "vec"), BTreeSet::from([(a, b), (b, a)]));
5636
5637 let old_b = fx.props.get(b, "emb").cloned();
5639 fx.props
5640 .set(b, "emb", emb_val(&[1.0, 0.0, 0.0, 0.0, 0.0, 0.0]));
5641 {
5642 let mut g = fx.g();
5643 eng.on_node_changed(b, Some(("emb", old_b)), &mut g);
5644 }
5645 assert_eq!(eng.indexes["vec"].src_side.vec_dim(b), Some(6));
5647 assert_eq!(eng.indexes["vec"].dst_side.vec_dim(b), Some(6));
5648 assert!(eng.indexes["vec"].src_side.vec_ckpts(b).is_some());
5650 assert_eq!(prov_pairs(&eng, "vec"), BTreeSet::from([(b, c), (c, b)]));
5652
5653 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);
5657 assert!(
5658 gate_result.is_none(),
5659 "freshness gate must reject a mismatched-norm live vector"
5660 );
5661
5662 let correct_live = vec![1.0f64, 0.0, 0.0, 0.0, 0.0, 0.0];
5664 let gate_result = eng.indexes["vec"]
5665 .src_side
5666 .fresh_ckpts_for(b, &correct_live);
5667 assert!(
5668 gate_result.is_some(),
5669 "freshness gate must accept the matching live vector"
5670 );
5671 }
5672
5673 #[test]
5682 fn vector_early_exit_razor_dim1536() {
5683 const MIN: f64 = 0.85;
5684 const DIM: usize = 1536;
5685 let target = MIN + 5e-13;
5687 let inv_sqrt = 1.0 / (DIM as f64).sqrt();
5688
5689 let a: Vec<f64> = vec![inv_sqrt; DIM];
5691
5692 let perp_scale = (1.0 - target * target).sqrt() / (2.0f64).sqrt();
5698 let mut b: Vec<f64> = vec![target * inv_sqrt; DIM];
5699 b[0] += perp_scale;
5700 b[1] -= perp_scale;
5701
5702 let def = RuleDef {
5703 name: "razor".into(),
5704 src_label: "Doc".into(),
5705 dst_label: "Doc".into(),
5706 predicate: Predicate::VectorSimilar {
5707 field: "emb".into(),
5708 min: MIN,
5709 },
5710 edge_type: "SIM".into(),
5711 weight_prop: None,
5712 max_edges: None,
5713 approximate: false,
5714 via_label: None,
5715 via_edge: None,
5716 via_dir: None,
5717 };
5718
5719 let build_fx = || {
5721 let mut fx = Fx::new();
5722 let na = fx.add("Doc", "razor_a", vec![("emb", emb_val2(&a))]);
5723 let nb = fx.add("Doc", "razor_b", vec![("emb", emb_val2(&b))]);
5724 (fx, na, nb)
5725 };
5726
5727 let (mut fx_on, na, nb) = build_fx();
5728 let (mut fx_off, _, _) = build_fx();
5729 let (fx_oracle, _, _) = build_fx();
5730
5731 let mut eng_on = RuleEngine::new();
5733 {
5734 let mut g = fx_on.g();
5735 eng_on.create_rule(def.clone(), &mut g).unwrap();
5736 }
5737 let edges_on = prov_pairs(&eng_on, "razor");
5738 assert!(
5739 edges_on.contains(&(na, nb)),
5740 "razor pair razor_a→razor_b must be present with early-exit ON (cos={target:.15}, min={MIN})"
5741 );
5742 assert!(
5743 edges_on.contains(&(nb, na)),
5744 "razor pair razor_b→razor_a must be present with early-exit ON"
5745 );
5746
5747 let mut eng_off = RuleEngine::new();
5749 {
5750 let mut g = fx_off.g();
5751 with_vector_early_exit(false, || {
5752 eng_off.create_rule(def.clone(), &mut g).unwrap();
5753 });
5754 }
5755 let edges_off = prov_pairs(&eng_off, "razor");
5756 assert_eq!(
5757 edges_on, edges_off,
5758 "razor dim=1536: early-exit ON vs OFF must produce identical edges"
5759 );
5760
5761 let ids = [na, nb];
5763 let mut oracle = BTreeSet::new();
5764 for &s in &ids {
5765 for &d in &ids {
5766 if s == d {
5767 continue;
5768 }
5769 let skey = fx_oracle.ids.key_of(s).unwrap();
5770 let dkey = fx_oracle.ids.key_of(d).unwrap();
5771 let sg = |f: &str| fx_oracle.props.get(s, f).cloned();
5772 let dg = |f: &str| fx_oracle.props.get(d, f).cloned();
5773 if evaluate(
5774 &def.predicate,
5775 &NodeView {
5776 key: skey,
5777 props: &sg,
5778 },
5779 &NodeView {
5780 key: dkey,
5781 props: &dg,
5782 },
5783 )
5784 .is_some()
5785 {
5786 oracle.insert((s, d));
5787 }
5788 }
5789 }
5790 assert_eq!(
5791 edges_on, oracle,
5792 "razor dim=1536: early-exit ON vs brute-force oracle must be identical"
5793 );
5794 }
5795
5796 fn field_equal_rule(
5840 src_label: &str,
5841 dst_label: &str,
5842 field: &str,
5843 edge_type: &str,
5844 max_edges: Option<u64>,
5845 ) -> RuleDef {
5846 RuleDef {
5847 name: format!("{src_label}_{dst_label}_{field}"),
5848 src_label: src_label.into(),
5849 dst_label: dst_label.into(),
5850 predicate: Predicate::FieldEqual {
5851 field: field.into(),
5852 },
5853 edge_type: edge_type.into(),
5854 weight_prop: None,
5855 max_edges,
5856 approximate: false,
5857 via_label: None,
5858 via_edge: None,
5859 via_dir: None,
5860 }
5861 }
5862
5863 #[test]
5864 fn backfill_does_not_materialize_the_cross_product() {
5865 use std::sync::atomic::Ordering;
5866 let mut fx = Fx::new();
5871 for i in 0..400u32 {
5872 fx.add(
5873 "Person",
5874 &format!("p{i}"),
5875 vec![("city", Value::Str("austin".into()))],
5876 );
5877 }
5878 for i in 0..400u32 {
5879 fx.add(
5880 "Org",
5881 &format!("o{i}"),
5882 vec![("city", Value::Str("austin".into()))],
5883 );
5884 }
5885
5886 let mut eng = RuleEngine::new();
5887 PEAK_DESIRED_PAIRS.store(0, Ordering::Relaxed);
5888 {
5889 let mut g = fx.g();
5890 eng.create_rule(
5891 field_equal_rule("Person", "Org", "city", "IN_CITY", Some(5)),
5892 &mut g,
5893 )
5894 .unwrap();
5895 }
5896
5897 let edges = fx.topo.edge_count();
5898 assert_eq!(
5899 edges,
5900 400 * 5,
5901 "per-source top-k must yield exactly k per source"
5902 );
5903
5904 let peak = PEAK_DESIRED_PAIRS.load(Ordering::Relaxed);
5905 assert!(
5906 peak <= 400 * 5 * 4, "backfill must not materialize the full cross-product; peak was {peak}"
5908 );
5909 }
5910
5911 #[test]
5927 fn global_budget_backfill_stays_per_source_bounded() {
5928 use std::sync::atomic::Ordering;
5929 let mut fx = Fx::new();
5932 for i in 0..400u32 {
5933 fx.add(
5934 "Person",
5935 &format!("p{i}"),
5936 vec![("city", Value::Str("austin".into()))],
5937 );
5938 }
5939 for i in 0..400u32 {
5940 fx.add(
5941 "Org",
5942 &format!("o{i}"),
5943 vec![("city", Value::Str("austin".into()))],
5944 );
5945 }
5946
5947 let mut eng = RuleEngine::new();
5948 PEAK_DESIRED_PAIRS.store(0, Ordering::Relaxed);
5949 {
5950 let mut g = fx.g();
5951 eng.create_rule(
5952 field_equal_rule("Person", "Org", "city", "IN_CITY", None),
5953 &mut g,
5954 )
5955 .unwrap();
5956 }
5957
5958 let edges = fx.topo.edge_count();
5961 assert_eq!(
5962 edges,
5963 400 * 400,
5964 "none-path must apply all pairs when under budget; got {edges}"
5965 );
5966
5967 let peak = PEAK_DESIRED_PAIRS.load(Ordering::Relaxed);
5971 assert!(
5972 peak <= 400 * 4, "none-path backfill must not accumulate a global desired-map; peak was {peak}"
5974 );
5975 }
5976}