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>,
261 chain_depth: usize,
265 chain_fired: BTreeSet<(u32, u32)>,
271 doomed: Option<u32>,
280 chain_truncations: u64,
283}
284
285fn candidate_spec_for(def: &RuleDef) -> CandidateSpec<'_> {
292 if def.approximate {
293 let k = def.max_edges.map(|me| me.max(128)).unwrap_or(128) as usize;
297 candidate_spec_approx_with_k(&def.predicate, k)
298 } else {
299 candidate_spec(&def.predicate)
300 }
301}
302
303fn src_lookup_spec_for(def: &RuleDef) -> CandidateSpec<'_> {
310 if is_keymatch_rooted(&def.predicate) {
311 let field =
312 keymatch_field(&def.predicate).expect("keymatch-rooted predicate has a KeyMatch field");
313 CandidateSpec::ScalarOrElements { field }
314 } else {
315 candidate_spec_for(def)
316 }
317}
318
319fn predicate_covers_field(p: &Predicate, field: &str) -> bool {
321 match p {
322 Predicate::VectorSimilar { field: f, .. } => f == field,
323 Predicate::All(parts) | Predicate::Any(parts) => {
324 parts.iter().any(|q| predicate_covers_field(q, field))
325 }
326 _ => false,
327 }
328}
329
330fn keymatch_field(p: &Predicate) -> Option<&str> {
332 match p {
333 Predicate::KeyMatch { field } => Some(field),
334 Predicate::All(parts) => parts.first().and_then(keymatch_field),
335 Predicate::Any(_) => None,
336 _ => None,
337 }
338}
339
340fn all_node_ids(g: &GraphMut<'_>) -> BTreeSet<u32> {
351 (0..g.ids.len() as u32).collect()
352}
353
354fn compute_desired(
357 def: &RuleDef,
358 index: &RuleIndex,
359 n: u32,
360 on_src_side: bool,
361 g: &GraphMut<'_>,
362) -> BTreeMap<(u32, u32), f64> {
363 let (my_label, other_label) = if on_src_side {
364 (&def.src_label, &def.dst_label)
365 } else {
366 (&def.dst_label, &def.src_label)
367 };
368
369 let Some(my_sym) = g.syms.get(my_label) else {
370 return BTreeMap::new();
371 };
372 if g.labels.get(n as usize).copied() != Some(my_sym) {
373 return BTreeMap::new();
374 }
375 let other_sym = g.syms.get(other_label);
376
377 let n_key = match g.ids.key_of(n) {
378 Some(k) => k,
379 None => return BTreeMap::new(),
380 };
381 let n_get = |f: &str| g.props.get(n, f).map(|vr| vr.into_value());
382
383 let spec = candidate_spec_for(def);
384 let candidates: BTreeSet<u32> = if on_src_side {
385 if is_keymatch_rooted(&def.predicate) {
386 let field = keymatch_field(&def.predicate).expect("ByKey always comes from KeyMatch");
390 match n_get(field) {
391 Some(Value::Str(ref target_key)) => match g.ids.get(target_key) {
392 Some(dst_id) => std::iter::once(dst_id).collect(),
393 None => BTreeSet::new(),
394 },
395 Some(Value::List(items)) => items
399 .iter()
400 .take(MAX_KEYMATCH_LIST)
401 .filter_map(|v| match v {
402 Value::Str(target_key) => g.ids.get(target_key),
403 _ => None,
404 })
405 .collect(),
406 _ => BTreeSet::new(),
407 }
408 } else if predicate_contains_keymatch(&def.predicate) {
409 all_node_ids(g)
410 } else {
411 index.dst_side.candidates(&spec, &n_get)
412 }
413 } else {
414 let src_spec = src_lookup_spec_for(def);
416 if is_keymatch_rooted(&def.predicate) {
417 let key_getter = |_: &str| Some(Value::Str(n_key.to_string()));
420 index.src_side.candidates(&src_spec, &key_getter)
421 } else if predicate_contains_keymatch(&def.predicate) {
422 all_node_ids(g)
423 } else {
424 index.src_side.candidates(&src_spec, &n_get)
425 }
426 };
427
428 let n_early_exit_hint: Option<(Vec<f64>, f64, [f64; 8])> = if !def.approximate {
443 if let Predicate::VectorSimilar { field, .. } = &def.predicate {
444 if crate::index::vector_early_exit_enabled() {
445 let n_side = if on_src_side {
446 &index.src_side
447 } else {
448 &index.dst_side
449 };
450 if let Some(vn_v) = n_get(field) {
451 if let Some(vn) = crate::index::as_numeric_list(&vn_v) {
452 if let Some((norm_n, ckpts_n)) = n_side.fresh_ckpts_for(n, &vn) {
453 Some((vn, norm_n, *ckpts_n))
454 } else {
455 None
456 }
457 } else {
458 None
459 }
460 } else {
461 None
462 }
463 } else {
464 None
465 }
466 } else {
467 None
468 }
469 } else {
470 None
471 };
472
473 let mut out = BTreeMap::new();
474 for m in candidates {
475 if m == n {
476 continue; }
478 if g.labels.get(m as usize).copied() != other_sym {
479 continue; }
481 let m_key = match g.ids.key_of(m) {
482 Some(k) => k,
483 None => continue,
484 };
485 let m_get = |f: &str| g.props.get(m, f).map(|vr| vr.into_value());
486 let (s_view, d_view, s_id, d_id) = if on_src_side {
487 (
488 NodeView {
489 key: n_key,
490 props: &n_get,
491 },
492 NodeView {
493 key: m_key,
494 props: &m_get,
495 },
496 n,
497 m,
498 )
499 } else {
500 (
501 NodeView {
502 key: m_key,
503 props: &m_get,
504 },
505 NodeView {
506 key: n_key,
507 props: &n_get,
508 },
509 m,
510 n,
511 )
512 };
513
514 if let (Some((ref vn, norm_n, ckpts_n)), Predicate::VectorSimilar { field, min }) =
516 (&n_early_exit_hint, &def.predicate)
517 {
518 let m_side = if on_src_side {
519 &index.dst_side
520 } else {
521 &index.src_side
522 };
523 if let Some(vm_v) = m_get(field) {
524 if let Some(vm) = crate::index::as_numeric_list(&vm_v) {
525 if let Some((norm_m, ckpts_m)) = m_side.fresh_ckpts_for(m, &vm) {
526 let (va, ckpts_a, na, vb, ckpts_b, nb) = if on_src_side {
527 (
528 vn.as_slice(),
529 ckpts_n,
530 *norm_n,
531 vm.as_slice(),
532 ckpts_m,
533 norm_m,
534 )
535 } else {
536 (
537 vm.as_slice(),
538 ckpts_m,
539 norm_m,
540 vn.as_slice(),
541 ckpts_n,
542 *norm_n,
543 )
544 };
545 match crate::def::cosine_early_exit(va, vb, ckpts_a, ckpts_b, na, nb, *min)
546 {
547 None => continue, Some(score) => {
549 out.insert((s_id, d_id), score);
550 continue; }
552 }
553 }
554 }
555 }
556 }
557
558 if let Some(score) = evaluate(&def.predicate, &s_view, &d_view) {
559 out.insert((s_id, d_id), score);
560 }
561 }
562 #[cfg(test)]
563 record_desired_len(out.len());
564 out
565}
566
567fn compute_desired_via(
607 def: &RuleDef,
608 index: Option<&RuleIndex>,
609 anchor: ViaAnchor,
610 doomed: Option<u32>,
611 g: &GraphMut<'_>,
612) -> BTreeMap<(u32, u32), f64> {
613 let via_label = def.via_label.as_deref().unwrap();
614 let via_edge_str = def.via_edge.as_deref().unwrap();
615 let via_dir = def.via_dir.unwrap_or(core_storage::Direction::Out);
616
617 let src_sym = match g.syms.get(&def.src_label) {
618 Some(s) => s,
619 None => return BTreeMap::new(),
620 };
621 let via_sym = match g.syms.get(via_label) {
622 Some(s) => s,
623 None => return BTreeMap::new(),
624 };
625 let dst_sym = match g.syms.get(&def.dst_label) {
626 Some(s) => s,
627 None => return BTreeMap::new(),
628 };
629 let via_etype = match g.syms.get(via_edge_str) {
630 Some(e) => e,
631 None => return BTreeMap::new(),
632 };
633
634 let srcs: Vec<u32> = match anchor {
636 ViaAnchor::Src(src_id) => {
637 if Some(src_id) == doomed {
638 return BTreeMap::new();
639 }
640 if g.labels.get(src_id as usize).copied() == Some(src_sym) {
641 vec![src_id]
642 } else {
643 return BTreeMap::new();
644 }
645 }
646 ViaAnchor::Dst(_) => {
647 (0..g.ids.len() as u32)
649 .filter(|&id| {
650 Some(id) != doomed
651 && matches!(
652 g.labels.get(id as usize).copied(),
653 Some(s) if s != u32::MAX && s == src_sym
654 )
655 })
656 .collect()
657 }
658 };
659
660 let anchored_dst: Option<u32> = match anchor {
662 ViaAnchor::Dst(dst_id) => {
663 if Some(dst_id) == doomed {
664 return BTreeMap::new();
665 }
666 if g.labels.get(dst_id as usize).copied() == Some(dst_sym) {
667 Some(dst_id)
668 } else {
669 return BTreeMap::new();
670 }
671 }
672 _ => None,
673 };
674
675 let ceiling = matches!(def.predicate, Predicate::Overlap { .. });
679
680 let mut out = BTreeMap::new();
681
682 for src in srcs {
683 let _src_key = match g.ids.key_of(src) {
684 Some(k) => k,
685 None => continue,
686 };
687 let via_neighbors: Vec<u32> = g
689 .neighbors(via_etype, via_dir, src)
690 .iter()
691 .copied()
692 .filter(|&v| Some(v) != doomed && g.labels.get(v as usize).copied() == Some(via_sym))
693 .collect();
694
695 if via_neighbors.is_empty() {
696 continue;
697 }
698 let via_set: BTreeSet<u32> = if ceiling {
700 via_neighbors.iter().copied().collect()
701 } else {
702 BTreeSet::new()
703 };
704
705 let indexed = index.filter(|_| !predicate_contains_keymatch(&def.predicate));
713 let dsts: Vec<u32> = if let Some(dst_id) = anchored_dst {
714 vec![dst_id]
715 } else if let Some(idx) = indexed {
716 let spec = candidate_spec_for(def);
717 let mut set = BTreeSet::new();
718 for &via_id in &via_neighbors {
719 let via_get = |f: &str| g.props.get(via_id, f).map(|vr| vr.into_value());
720 set.extend(idx.dst_side.candidates(&spec, &via_get));
721 }
722 set.into_iter()
723 .filter(|&id| {
724 id != src
725 && Some(id) != doomed
726 && matches!(
727 g.labels.get(id as usize).copied(),
728 Some(s) if s != u32::MAX && s == dst_sym
729 )
730 })
731 .collect()
732 } else {
733 (0..g.ids.len() as u32)
734 .filter(|&id| {
735 id != src
736 && Some(id) != doomed
737 && matches!(
738 g.labels.get(id as usize).copied(),
739 Some(s) if s != u32::MAX && s == dst_sym
740 )
741 })
742 .collect()
743 };
744
745 for dst in dsts {
746 if dst == src {
747 continue; }
749 let dst_key = match g.ids.key_of(dst) {
750 Some(k) => k,
751 None => continue,
752 };
753 let dst_get = |f: &str| g.props.get(dst, f).map(|vr| vr.into_value());
754 let dst_view = NodeView {
755 key: dst_key,
756 props: &dst_get,
757 };
758
759 let mut best: Option<f64> = None;
772 let first = (ceiling && via_set.contains(&dst)).then_some(dst);
773 for via_id in first.into_iter().chain(via_neighbors.iter().copied()) {
774 let via_key = match g.ids.key_of(via_id) {
775 Some(k) => k,
776 None => continue,
777 };
778 let via_get = |f: &str| g.props.get(via_id, f).map(|vr| vr.into_value());
779 let via_view = NodeView {
780 key: via_key,
781 props: &via_get,
782 };
783 if let Some(score) = evaluate(&def.predicate, &via_view, &dst_view) {
784 best = Some(match best {
785 None => score,
786 Some(prev) => prev.max(score),
787 });
788 if ceiling && best == Some(1.0) {
789 break;
790 }
791 }
792 }
793
794 if let Some(score) = best {
795 out.insert((src, dst), score);
796 }
797 }
798 }
799
800 out
801}
802
803enum ViaAnchor {
805 Src(u32),
807 Dst(u32),
810}
811
812fn edge_budget(def: &RuleDef) -> u64 {
813 def.max_edges.unwrap_or(DEFAULT_MAX_EDGES)
816}
817
818pub(crate) fn filter_src_top_k(
840 per_src: BTreeMap<(u32, u32), f64>,
841 k: u64,
842 ids: &core_storage::IdMap,
843) -> BTreeMap<(u32, u32), f64> {
844 if per_src.len() as u64 <= k {
845 return per_src;
846 }
847 let mut candidates: Vec<((u32, u32), f64)> = per_src.into_iter().collect();
848 candidates.sort_by(|&((_, da), sa), &((_, db), sb)| {
850 sb.total_cmp(&sa).then_with(|| {
851 let ka = ids.key_of(da).unwrap_or("");
852 let kb = ids.key_of(db).unwrap_or("");
853 ka.cmp(kb)
854 })
855 });
856 candidates.truncate(k as usize);
857 candidates.into_iter().collect()
858}
859
860fn apply_per_src_top_k(
867 def: &RuleDef,
868 src: u32,
869 desired_from_src: BTreeMap<(u32, u32), f64>,
870 prov: &mut ProvSets<'_>,
871 g: &mut GraphMut<'_>,
872) {
873 let et = g.syms.intern(&def.edge_type);
874
875 let current: Vec<Triple> = {
879 let rid = prov.rule_intern.get(&def.name).copied();
880 prov.by_node
881 .get(&src)
882 .into_iter()
883 .flatten()
884 .filter(|(r, t, s, _d)| Some(*r) == rid && *t == et && *s == src)
885 .map(|(_, t, s, d)| (*t, *s, *d))
886 .collect()
887 };
888
889 for (t, s, d) in current {
891 if !desired_from_src.contains_key(&(s, d)) {
892 g.topo.remove_edge(t, s, d);
893 g.edge_props.remove_edge(t, s, d);
894 prov.remove(&def.name, (t, s, d), g.ids, g.syms);
895 }
896 }
897
898 for ((s, d), score) in &desired_from_src {
900 let triple = (et, *s, *d);
901 let already = prov.contains(&triple);
902 if !already {
903 let newly = g.topo.add_edge(et, *s, *d);
904 if newly {
905 prov.insert(&def.name, triple, g.ids, g.syms);
906 }
907 }
908 let is_owned = already || prov.contains(&triple);
909 if is_owned {
910 if let Some(p) = &def.weight_prop {
911 g.edge_props.set(et, *s, *d, p, Value::Float(*score));
912 }
913 }
914 }
915}
916
917fn intern_rule(intern: &mut BTreeMap<String, u32>, names: &mut Vec<String>, rule: &str) -> u32 {
919 if let Some(&id) = intern.get(rule) {
920 return id;
921 }
922 let id = names.len() as u32;
923 intern.insert(rule.to_string(), id);
924 names.push(rule.to_string());
925 id
926}
927
928type ByNodeRebuild = (
929 BTreeMap<u32, BTreeSet<Touch>>,
930 BTreeMap<String, u32>,
931 Vec<String>,
932);
933
934fn rebuild_by_node(provenance: &BTreeMap<String, BTreeSet<Triple>>) -> ByNodeRebuild {
935 let mut by_node = BTreeMap::new();
936 let mut intern = BTreeMap::new();
937 let mut names = Vec::new();
938 for (rule, set) in provenance {
939 let rid = intern_rule(&mut intern, &mut names, rule);
940 for &triple in set {
941 touch_insert(&mut by_node, rid, triple);
942 }
943 }
944 (by_node, intern, names)
945}
946
947fn touch_insert(by_node: &mut BTreeMap<u32, BTreeSet<Touch>>, rid: u32, triple: Triple) {
948 let (t, s, d) = triple;
949 let entry = (rid, t, s, d);
950 by_node.entry(s).or_default().insert(entry);
951 if s != d {
952 by_node.entry(d).or_default().insert(entry);
953 }
954}
955
956fn touch_remove(by_node: &mut BTreeMap<u32, BTreeSet<Touch>>, rid: u32, triple: Triple) {
957 let (t, s, d) = triple;
958 let entry = (rid, t, s, d);
959 if let Some(set) = by_node.get_mut(&s) {
960 set.remove(&entry);
961 if set.is_empty() {
962 by_node.remove(&s);
963 }
964 }
965 if s != d {
966 if let Some(set) = by_node.get_mut(&d) {
967 set.remove(&entry);
968 if set.is_empty() {
969 by_node.remove(&d);
970 }
971 }
972 }
973}
974
975#[cfg(test)]
976fn resolve_by_node(
977 by_node: &BTreeMap<u32, BTreeSet<Touch>>,
978 names: &[String],
979) -> BTreeMap<u32, BTreeSet<(String, Triple)>> {
980 by_node
981 .iter()
982 .map(|(&n, set)| {
983 let resolved = set
984 .iter()
985 .map(|&(rid, t, s, d)| (names[rid as usize].clone(), (t, s, d)))
986 .collect();
987 (n, resolved)
988 })
989 .collect()
990}
991
992struct ProvSets<'a> {
995 set: &'a mut BTreeSet<Triple>,
996 owned: &'a mut BTreeSet<Triple>,
997 by_node: &'a mut BTreeMap<u32, BTreeSet<Touch>>,
998 rule_intern: &'a mut BTreeMap<String, u32>,
999 intern_rule: &'a mut Vec<String>,
1000 deltas: &'a mut Vec<EngineEdgeDelta>,
1004 emit: bool,
1007}
1008
1009impl ProvSets<'_> {
1010 fn insert(&mut self, rule: &str, triple: Triple, ids: &IdMap, syms: &Interner) -> bool {
1014 if !self.set.insert(triple) {
1015 return false;
1016 }
1017 self.owned.insert(triple);
1018 let rid = intern_rule(self.rule_intern, self.intern_rule, rule);
1019 touch_insert(self.by_node, rid, triple);
1020 let (etype, src, dst) = triple;
1021 if self.emit {
1022 if let (Some(sk), Some(dk), Some(et)) =
1023 (ids.key_of(src), ids.key_of(dst), syms.resolve(etype))
1024 {
1025 self.deltas.push(EngineEdgeDelta {
1026 rule: rule.to_string(),
1027 src_key: sk.to_string(),
1028 dst_key: dk.to_string(),
1029 edge_type: et.to_string(),
1030 etype_sym: etype,
1031 src_id: src,
1032 dst_id: dst,
1033 fired: true,
1034 });
1035 }
1036 }
1037 true
1038 }
1039
1040 fn remove(&mut self, rule: &str, triple: Triple, ids: &IdMap, syms: &Interner) -> bool {
1041 if !self.set.remove(&triple) {
1042 return false;
1043 }
1044 self.owned.remove(&triple);
1045 let rid = intern_rule(self.rule_intern, self.intern_rule, rule);
1046 touch_remove(self.by_node, rid, triple);
1047 let (etype, src, dst) = triple;
1048 if self.emit {
1049 if let (Some(sk), Some(dk), Some(et)) =
1050 (ids.key_of(src), ids.key_of(dst), syms.resolve(etype))
1051 {
1052 self.deltas.push(EngineEdgeDelta {
1053 rule: rule.to_string(),
1054 src_key: sk.to_string(),
1055 dst_key: dk.to_string(),
1056 edge_type: et.to_string(),
1057 etype_sym: etype,
1058 src_id: src,
1059 dst_id: dst,
1060 fired: false,
1061 });
1062 }
1063 }
1064 true
1065 }
1066
1067 fn contains(&self, triple: &Triple) -> bool {
1068 self.set.contains(triple)
1069 }
1070
1071 fn len(&self) -> usize {
1072 self.set.len()
1073 }
1074}
1075
1076fn apply_desired(
1087 def: &RuleDef,
1088 desired: BTreeMap<(u32, u32), f64>,
1089 retract_touching: Option<u32>,
1090 prov: &mut ProvSets<'_>,
1091 tripped: &mut bool,
1092 g: &mut GraphMut<'_>,
1093) {
1094 let budget = edge_budget(def);
1095 let et = g.syms.intern(&def.edge_type);
1096
1097 let current: Vec<Triple> = match retract_touching {
1098 None => prov
1099 .set
1100 .iter()
1101 .filter(|(t, _, _)| *t == et)
1102 .copied()
1103 .collect(),
1104 Some(n) => {
1105 let rid = prov.rule_intern.get(&def.name).copied();
1106 prov.by_node
1107 .get(&n)
1108 .into_iter()
1109 .flatten()
1110 .filter(|(r, t, _, _)| Some(*r) == rid && *t == et)
1111 .map(|(_, t, s, d)| (*t, *s, *d))
1112 .collect()
1113 }
1114 };
1115
1116 for (t, s, d) in current {
1117 if !desired.contains_key(&(s, d)) {
1118 g.topo.remove_edge(t, s, d);
1119 g.edge_props.remove_edge(t, s, d);
1120 prov.remove(&def.name, (t, s, d), g.ids, g.syms);
1121 }
1122 }
1123
1124 for ((s, d), score) in desired {
1125 let triple = (et, s, d);
1126 let already = prov.contains(&triple);
1127 if !already {
1128 if *tripped || prov.len() as u64 >= budget {
1129 *tripped = true;
1130 continue;
1131 }
1132 let newly = g.topo.add_edge(et, s, d);
1133 if newly {
1134 prov.insert(&def.name, triple, g.ids, g.syms);
1135 }
1136 }
1137 let is_owned_here = already || prov.contains(&triple);
1141 if is_owned_here {
1142 if let Some(p) = &def.weight_prop {
1143 g.edge_props.set(et, s, d, p, Value::Float(score));
1144 }
1145 }
1146 }
1147}
1148
1149#[cfg(test)]
1157#[allow(dead_code)]
1158fn compute_full_desired(
1159 def: &RuleDef,
1160 index: &RuleIndex,
1161 g: &GraphMut<'_>,
1162) -> BTreeMap<(u32, u32), f64> {
1163 let mut desired = BTreeMap::new();
1164 let src_sym = g.syms.get(&def.src_label);
1165 for id in 0..g.ids.len() as u32 {
1166 let label_sym = match g.labels.get(id as usize).copied() {
1167 Some(s) if s != u32::MAX => s,
1168 _ => continue,
1169 };
1170 if src_sym == Some(label_sym) {
1171 desired.extend(compute_desired(def, index, id, true, g));
1172 #[cfg(test)]
1173 record_desired_len(desired.len());
1174 }
1175 }
1176 desired
1177}
1178
1179fn pair_still_desired(def: &RuleDef, s: u32, d: u32, g: &GraphMut<'_>) -> bool {
1186 let src_sym = match g.syms.get(&def.src_label) {
1187 Some(sym) => sym,
1188 None => return false,
1189 };
1190 let dst_sym = match g.syms.get(&def.dst_label) {
1191 Some(sym) => sym,
1192 None => return false,
1193 };
1194 if g.labels.get(s as usize).copied() != Some(src_sym) {
1195 return false;
1196 }
1197 if g.labels.get(d as usize).copied() != Some(dst_sym) {
1198 return false;
1199 }
1200 let s_key = match g.ids.key_of(s) {
1201 Some(k) => k,
1202 None => return false,
1203 };
1204 let d_key = match g.ids.key_of(d) {
1205 Some(k) => k,
1206 None => return false,
1207 };
1208 let s_get = |f: &str| g.props.get(s, f).map(|vr| vr.into_value());
1209 let d_get = |f: &str| g.props.get(d, f).map(|vr| vr.into_value());
1210 evaluate(
1211 &def.predicate,
1212 &NodeView {
1213 key: s_key,
1214 props: &s_get,
1215 },
1216 &NodeView {
1217 key: d_key,
1218 props: &d_get,
1219 },
1220 )
1221 .is_some()
1222}
1223
1224fn count_desired_up_to(def: &RuleDef, index: &RuleIndex, limit: u64, g: &GraphMut<'_>) -> u64 {
1229 let mut count = 0u64;
1230 let src_sym = g.syms.get(&def.src_label);
1231 for id in 0..g.ids.len() as u32 {
1232 let label_sym = match g.labels.get(id as usize).copied() {
1233 Some(s) if s != u32::MAX => s,
1234 _ => continue,
1235 };
1236 if src_sym != Some(label_sym) {
1237 continue;
1238 }
1239 count += compute_desired(def, index, id, true, g).len() as u64;
1240 if count > limit {
1241 return count;
1242 }
1243 }
1244 count
1245}
1246
1247fn apply_streaming_create(
1269 def: &RuleDef,
1270 index: &RuleIndex,
1271 prov: &mut ProvSets<'_>,
1272 tripped: &mut bool,
1273 g: &mut GraphMut<'_>,
1274) {
1275 let budget = edge_budget(def);
1276 let et = g.syms.intern(&def.edge_type);
1277 let src_sym = g.syms.get(&def.src_label);
1278
1279 'outer: for id in 0..g.ids.len() as u32 {
1280 let label_sym = match g.labels.get(id as usize).copied() {
1281 Some(s) if s != u32::MAX => s,
1282 _ => continue,
1283 };
1284 if src_sym != Some(label_sym) {
1285 continue;
1286 }
1287 let per_src = compute_desired(def, index, id, true, g);
1288 for ((s, d), score) in per_src {
1289 let triple = (et, s, d);
1290 let already = prov.contains(&triple);
1295 if !already {
1296 if *tripped || prov.len() as u64 >= budget {
1297 *tripped = true;
1298 break 'outer;
1299 }
1300 let newly = g.topo.add_edge(et, s, d);
1301 if newly {
1302 prov.insert(&def.name, triple, g.ids, g.syms);
1303 }
1304 }
1305 let is_owned_here = already || prov.contains(&triple);
1306 if is_owned_here {
1307 if let Some(p) = &def.weight_prop {
1308 g.edge_props.set(et, s, d, p, Value::Float(score));
1309 }
1310 }
1311 }
1312 }
1313}
1314
1315fn apply_streaming_create_top_k(
1322 def: &RuleDef,
1323 k: u64,
1324 index: &RuleIndex,
1325 prov: &mut ProvSets<'_>,
1326 g: &mut GraphMut<'_>,
1327) {
1328 let src_sym = g.syms.get(&def.src_label);
1329 for id in 0..g.ids.len() as u32 {
1330 let label_sym = match g.labels.get(id as usize).copied() {
1331 Some(s) if s != u32::MAX => s,
1332 _ => continue,
1333 };
1334 if src_sym != Some(label_sym) {
1335 continue;
1336 }
1337 let per_src = compute_desired(def, index, id, true, g);
1338 let top_k = filter_src_top_k(per_src, k, g.ids);
1339 apply_per_src_top_k(def, id, top_k, prov, g);
1340 }
1341}
1342
1343fn apply_streaming_rebuild_top_k(
1350 def: &RuleDef,
1351 k: u64,
1352 index: &RuleIndex,
1353 doomed: Option<u32>,
1354 prov: &mut ProvSets<'_>,
1355 g: &mut GraphMut<'_>,
1356) {
1357 let et = g.syms.intern(&def.edge_type);
1358
1359 let existing_srcs: BTreeSet<u32> = prov
1362 .set
1363 .iter()
1364 .filter(|(t, _, _)| *t == et)
1365 .map(|(_, s, _)| *s)
1366 .collect();
1367
1368 let src_sym = g.syms.get(&def.src_label);
1369 let mut all_srcs: BTreeSet<u32> = existing_srcs;
1370 for id in 0..g.ids.len() as u32 {
1371 let label_sym = match g.labels.get(id as usize).copied() {
1372 Some(s) if s != u32::MAX => s,
1373 _ => continue,
1374 };
1375 if src_sym == Some(label_sym) {
1376 all_srcs.insert(id);
1377 }
1378 }
1379
1380 for src in all_srcs {
1381 let desired_src = if def.via_edge.is_some() {
1387 compute_desired_via(def, None, ViaAnchor::Src(src), doomed, g)
1388 } else {
1389 compute_desired(def, index, src, true, g)
1390 };
1391 let top_k = filter_src_top_k(desired_src, k, g.ids);
1392 apply_per_src_top_k(def, src, top_k, prov, g);
1393 }
1394}
1395
1396fn apply_via_rebuild(
1410 def: &RuleDef,
1411 doomed: Option<u32>,
1412 prov: &mut ProvSets<'_>,
1413 tripped: &mut bool,
1414 g: &mut GraphMut<'_>,
1415) {
1416 let budget = edge_budget(def);
1417 let et = g.syms.intern(&def.edge_type);
1418
1419 let mut sources: BTreeSet<u32> = prov
1422 .set
1423 .iter()
1424 .filter(|(t, _, _)| *t == et)
1425 .map(|(_, s, _)| *s)
1426 .collect();
1427 let src_sym = g.syms.get(&def.src_label);
1428 for id in 0..g.ids.len() as u32 {
1429 if let Some(s) = g.labels.get(id as usize).copied() {
1430 if s != u32::MAX && Some(s) == src_sym {
1431 sources.insert(id);
1432 }
1433 }
1434 }
1435 let sources: Vec<u32> = sources.into_iter().collect();
1436
1437 let mut total: u64 = 0;
1440 for &src in &sources {
1441 total += compute_desired_via(def, None, ViaAnchor::Src(src), doomed, g).len() as u64;
1442 if total > budget {
1443 *tripped = true;
1444 return; }
1446 }
1447 *tripped = false;
1448
1449 for src in sources {
1452 let desired = compute_desired_via(def, None, ViaAnchor::Src(src), doomed, g);
1453 let current: Vec<Triple> = prov
1454 .set
1455 .iter()
1456 .filter(|&&(t, s, _)| t == et && s == src)
1457 .copied()
1458 .collect();
1459 for triple in current {
1460 let (t, s, d) = triple;
1461 if !desired.contains_key(&(s, d)) {
1462 g.topo.remove_edge(t, s, d);
1463 g.edge_props.remove_edge(t, s, d);
1464 prov.remove(&def.name, triple, g.ids, g.syms);
1465 }
1466 }
1467 for ((s, d), score) in desired {
1468 let triple = (et, s, d);
1469 let already = prov.contains(&triple);
1470 if !already && g.topo.add_edge(et, s, d) {
1471 prov.insert(&def.name, triple, g.ids, g.syms);
1472 }
1473 if already || prov.contains(&triple) {
1476 if let Some(p) = &def.weight_prop {
1477 g.edge_props.set(et, s, d, p, Value::Float(score));
1478 }
1479 }
1480 }
1481 }
1482}
1483
1484fn apply_streaming_rebuild(
1497 def: &RuleDef,
1498 index: &RuleIndex,
1499 prov: &mut ProvSets<'_>,
1500 tripped: &mut bool,
1501 g: &mut GraphMut<'_>,
1502) {
1503 let budget = edge_budget(def);
1504 let et = g.syms.intern(&def.edge_type);
1505
1506 let total = count_desired_up_to(def, index, budget, g);
1508 if total > budget {
1509 *tripped = true;
1510 return; }
1512
1513 *tripped = false;
1515
1516 let current: Vec<Triple> = prov
1519 .set
1520 .iter()
1521 .filter(|(t, _, _)| *t == et)
1522 .copied()
1523 .collect();
1524 for (t, s, d) in current {
1525 if !pair_still_desired(def, s, d, g) {
1526 g.topo.remove_edge(t, s, d);
1527 g.edge_props.remove_edge(t, s, d);
1528 prov.remove(&def.name, (t, s, d), g.ids, g.syms);
1529 }
1530 }
1531
1532 let src_sym = g.syms.get(&def.src_label);
1535 for id in 0..g.ids.len() as u32 {
1536 let label_sym = match g.labels.get(id as usize).copied() {
1537 Some(s) if s != u32::MAX => s,
1538 _ => continue,
1539 };
1540 if src_sym != Some(label_sym) {
1541 continue;
1542 }
1543 let per_src = compute_desired(def, index, id, true, g);
1544 for ((s, d), score) in per_src {
1545 let triple = (et, s, d);
1546 let already = prov.contains(&triple);
1547 if !already {
1548 let newly = g.topo.add_edge(et, s, d);
1549 if newly {
1550 prov.insert(&def.name, triple, g.ids, g.syms);
1551 }
1552 }
1553 let is_owned_here = already || prov.contains(&triple);
1554 if is_owned_here {
1555 if let Some(p) = &def.weight_prop {
1556 g.edge_props.set(et, s, d, p, Value::Float(score));
1557 }
1558 }
1559 }
1560 }
1561}
1562
1563fn bump_fires_for_participants(def: &RuleDef, g: &GraphMut<'_>, fires: &mut u64) {
1566 let src_sym = g.syms.get(&def.src_label);
1567 let dst_sym = g.syms.get(&def.dst_label);
1568 for id in 0..g.ids.len() as u32 {
1569 let label_sym = match g.labels.get(id as usize).copied() {
1570 Some(s) if s != u32::MAX => s,
1571 _ => continue,
1572 };
1573 if src_sym == Some(label_sym) || dst_sym == Some(label_sym) {
1574 *fires += 1;
1575 }
1576 }
1577}
1578
1579fn index_node_for_rule(
1581 id: u32,
1582 label_sym: u32,
1583 def: &RuleDef,
1584 index: &mut RuleIndex,
1585 syms: &Interner,
1586 props: ColumnsView<'_>,
1587) {
1588 let get = |f: &str| props.get(id, f).map(|vr| vr.into_value());
1589 if syms.get(&def.src_label) == Some(label_sym) {
1590 let spec = src_lookup_spec_for(def);
1591 index.src_side.insert(&spec, id, &get);
1592 }
1593 if syms.get(&def.dst_label) == Some(label_sym) {
1594 let spec = candidate_spec_for(def);
1595 index.dst_side.insert(&spec, id, &get);
1596 }
1597}
1598
1599impl RuleEngine {
1604 pub fn new() -> Self {
1605 Self::default()
1606 }
1607
1608 fn chaining_possible(&self) -> bool {
1611 self.rules.values().any(|r| r.via_edge.is_some())
1612 }
1613
1614 fn begin_chain(&mut self) -> ChainScope {
1634 let active = self.chain_depth == 0 && self.chaining_possible();
1635 let prev_emit = self.emit_deltas;
1636 if active {
1637 self.emit_deltas = true;
1638 }
1639 ChainScope {
1640 cursor: self.pending_deltas.len(),
1641 prev_emit,
1642 active,
1643 }
1644 }
1645
1646 fn end_chain(&mut self, scope: ChainScope, g: &mut GraphMut<'_>) {
1648 if scope.active {
1649 self.chain_from(scope.cursor, g);
1650 }
1651 self.emit_deltas = scope.prev_emit;
1652 }
1653
1654 pub fn reset_chain_state(&mut self) {
1663 self.chain_depth = 0;
1664 self.chain_fired.clear();
1665 self.doomed = None;
1666 }
1667
1668 fn chain_from(&mut self, mut cursor: usize, g: &mut GraphMut<'_>) {
1675 debug_assert_eq!(self.chain_depth, 0);
1676 if self.pending_deltas.len() == cursor {
1677 return; }
1679 let via_edges: BTreeSet<String> = self
1683 .rules
1684 .values()
1685 .filter_map(|r| r.via_edge.clone())
1686 .collect();
1687 let rule_count = self.rules.len();
1688 for level in 1..=MAX_CHAIN_DEPTH {
1689 let end = self.pending_deltas.len();
1690 if end == cursor {
1691 return; }
1693 let batch: Vec<(String, u32, u32)> = self.pending_deltas[cursor..end]
1694 .iter()
1695 .filter(|d| via_edges.contains(&d.edge_type))
1696 .map(|d| (d.edge_type.clone(), d.src_id, d.dst_id))
1697 .collect();
1698 cursor = end;
1699 if batch.is_empty() {
1700 return; }
1702 self.chain_fired.clear();
1709 self.chain_depth = level;
1710 for (etype, src, dst) in batch {
1711 self.on_edge_changed_inner(&etype, src, dst, g);
1712 }
1713 self.chain_depth = 0;
1714 debug_assert_eq!(
1718 self.rules.len(),
1719 rule_count,
1720 "the rule set must not change during a chain"
1721 );
1722 }
1723 let truncated = self.pending_deltas[cursor..]
1727 .iter()
1728 .any(|d| via_edges.contains(&d.edge_type));
1729 if truncated {
1730 self.chain_truncations = self.chain_truncations.saturating_add(1);
1731 }
1732 }
1733
1734 pub fn rules(&self) -> impl Iterator<Item = &RuleDef> {
1735 self.rules.values()
1736 }
1737
1738 pub fn chain_truncations(&self) -> u64 {
1745 self.chain_truncations
1746 }
1747
1748 pub fn is_owned(&self, etype: u32, src: u32, dst: u32) -> bool {
1749 self.owned.contains(&(etype, src, dst))
1750 }
1751
1752 fn provenance_is_retained(&self) -> bool {
1757 self.retained_provenance_bytes
1758 .lock()
1759 .expect("lock poisoned")
1760 .is_some()
1761 }
1762
1763 pub fn provenance(&self) -> &BTreeMap<String, BTreeSet<(u32, u32, u32)>> {
1768 if self.provenance_is_retained() {
1769 self.ensure_provenance_loaded();
1770 &self.lazy_provenance.get().unwrap().provenance
1771 } else {
1772 &self.provenance
1773 }
1774 }
1775
1776 pub fn provenance_touching(
1781 &self,
1782 node: u32,
1783 ) -> impl Iterator<Item = (&str, u32, u32, u32)> + '_ {
1784 let use_lazy = self.provenance_is_retained();
1785 let (by_node, intern_rule): (&BTreeMap<u32, BTreeSet<Touch>>, &Vec<String>) = if use_lazy {
1786 self.ensure_provenance_loaded();
1787 let lp = self.lazy_provenance.get().unwrap();
1788 (&lp.by_node, &lp.intern_rule)
1789 } else {
1790 (&self.by_node, &self.intern_rule)
1791 };
1792 by_node
1793 .get(&node)
1794 .into_iter()
1795 .flatten()
1796 .map(move |&(rid, t, s, d)| (intern_rule[rid as usize].as_str(), t, s, d))
1797 }
1798
1799 pub fn provenance_touching_len(&self, node: u32) -> usize {
1801 if self.provenance_is_retained() {
1802 self.ensure_provenance_loaded();
1803 self.lazy_provenance
1804 .get()
1805 .unwrap()
1806 .by_node
1807 .get(&node)
1808 .map_or(0, BTreeSet::len)
1809 } else {
1810 self.by_node.get(&node).map_or(0, BTreeSet::len)
1811 }
1812 }
1813
1814 pub fn is_tripped(&self, name: &str) -> bool {
1817 self.tripped.get(name).copied().unwrap_or(false)
1818 }
1819
1820 pub fn fire_count(&self, name: &str) -> u64 {
1824 self.fires.get(name).copied().unwrap_or(0)
1825 }
1826
1827 pub fn drain_deltas(&mut self) -> Vec<EngineEdgeDelta> {
1841 std::mem::take(&mut self.pending_deltas)
1842 }
1843
1844 pub fn pending_delta_count(&self) -> usize {
1847 self.pending_deltas.len()
1848 }
1849
1850 pub fn pending_deltas_since(&self, cursor: usize) -> &[EngineEdgeDelta] {
1859 &self.pending_deltas[cursor..]
1860 }
1861
1862 #[allow(clippy::type_complexity)]
1866 pub fn to_persist(
1867 &self,
1868 ) -> (
1869 Vec<RuleDef>,
1870 BTreeMap<String, BTreeSet<(u32, u32, u32)>>,
1871 BTreeMap<String, bool>,
1872 BTreeMap<String, u64>,
1873 ) {
1874 (
1875 self.rules.values().cloned().collect(),
1876 self.provenance.clone(),
1877 self.tripped.clone(),
1878 self.fires.clone(),
1879 )
1880 }
1881
1882 pub fn from_persist(
1884 rules: Vec<RuleDef>,
1885 prov: BTreeMap<String, BTreeSet<(u32, u32, u32)>>,
1886 tripped: BTreeMap<String, bool>,
1887 fires: BTreeMap<String, u64>,
1888 ) -> Self {
1889 let mut owned = BTreeSet::new();
1890 for set in prov.values() {
1891 owned.extend(set.iter().copied());
1892 }
1893 let indexes = rules
1894 .iter()
1895 .map(|r| (r.name.clone(), RuleIndex::default()))
1896 .collect();
1897 let rules: BTreeMap<String, RuleDef> =
1898 rules.into_iter().map(|r| (r.name.clone(), r)).collect();
1899 let mut tripped = tripped;
1901 let mut fires = fires;
1902 for name in rules.keys() {
1903 tripped.entry(name.clone()).or_insert(false);
1904 fires.entry(name.clone()).or_insert(0);
1905 }
1906 let (by_node, rule_intern, intern_rule) = rebuild_by_node(&prov);
1907 Self {
1908 rules,
1909 indexes,
1910 provenance: prov,
1911 owned,
1912 by_node,
1913 rule_intern,
1914 intern_rule,
1915 tripped,
1916 fires,
1917 pending_deltas: Vec::new(),
1918 emit_deltas: false,
1919 rebuild_needed: BTreeSet::new(),
1920 indexes_populated: false,
1924 retained_hnsw_blobs: Mutex::new(BTreeMap::new()),
1925 retained_ivf_bytes: Mutex::new(None),
1926 retained_provenance_bytes: Mutex::new(None),
1927 lazy_provenance: OnceLock::new(),
1928 lazy_hnsw: OnceLock::new(),
1929 chain_depth: 0,
1930 chain_fired: BTreeSet::new(),
1931 doomed: None,
1932 chain_truncations: 0,
1933 }
1934 }
1935
1936 pub fn set_emit_deltas(&mut self, emit: bool) {
1942 self.emit_deltas = emit;
1943 }
1944
1945 pub fn emit_deltas(&self) -> bool {
1947 self.emit_deltas
1948 }
1949
1950 pub fn take_rebuild_needed(&mut self) -> Vec<String> {
1953 std::mem::take(&mut self.rebuild_needed)
1954 .into_iter()
1955 .collect()
1956 }
1957
1958 pub fn queue_rebuild_needed(&mut self, name: String) {
1962 self.rebuild_needed.insert(name);
1963 }
1964
1965 fn maybe_queue_ivf_rebuild(&mut self, rule_name: &str, def: &RuleDef) {
1966 if !def.approximate {
1967 return;
1968 }
1969 let Some(idx) = self.indexes.get(rule_name) else {
1970 return;
1971 };
1972 if idx.dst_side.ivf_drift > ivf_drift_rebuild_threshold() {
1973 self.rebuild_needed.insert(rule_name.to_string());
1974 }
1975 }
1976
1977 pub fn export_ivf_state(&self) -> BTreeMap<String, RuleIvfExport> {
1981 let mut out = BTreeMap::new();
1982 for (name, def) in &self.rules {
1983 if def.approximate {
1984 if let Some(idx) = self.indexes.get(name) {
1985 out.insert(
1986 name.clone(),
1987 (
1988 idx.src_side.export_ivf_state(),
1989 idx.dst_side.export_ivf_state(),
1990 ),
1991 );
1992 }
1993 }
1994 }
1995 out
1996 }
1997
1998 pub fn reindex_all(
2000 &mut self,
2001 ids: &IdMap,
2002 syms: &Interner,
2003 labels: &[u32],
2004 props: ColumnsView<'_>,
2005 ) {
2006 for idx in self.indexes.values_mut() {
2007 *idx = RuleIndex::default();
2008 }
2009 let rule_names: Vec<String> = self.rules.keys().cloned().collect();
2012
2013 for name in &rule_names {
2015 if self.rules[name].approximate {
2016 let idx = self.indexes.get_mut(name).unwrap();
2017 idx.src_side.init_hnsw(name);
2018 idx.dst_side.init_hnsw(name);
2019 }
2020 }
2021
2022 for id in 0..ids.len() as u32 {
2023 let label_sym = match labels.get(id as usize).copied() {
2024 Some(s) if s != u32::MAX => s,
2025 _ => continue,
2026 };
2027 for name in &rule_names {
2028 let def = self.rules[name].clone();
2029 let idx = self.indexes.get_mut(name).unwrap();
2030 index_node_for_rule(id, label_sym, &def, idx, syms, props);
2031 }
2032 }
2033 for name in &rule_names {
2036 if self.rules[name].approximate {
2037 let idx = self.indexes.get_mut(name).unwrap();
2038 idx.src_side.fit_ivf_clusters(name);
2039 idx.dst_side.fit_ivf_clusters(name);
2040 }
2041 }
2042 self.indexes_populated = true;
2043 }
2044
2045 pub fn reindex_all_load_ivf(
2055 &mut self,
2056 ids: &IdMap,
2057 syms: &Interner,
2058 labels: &[u32],
2059 props: ColumnsView<'_>,
2060 ivf_state: BTreeMap<String, RuleIvfExport>,
2061 ) {
2062 for idx in self.indexes.values_mut() {
2063 *idx = RuleIndex::default();
2064 }
2065 let rule_names: Vec<String> = self.rules.keys().cloned().collect();
2066
2067 for name in &rule_names {
2070 if self.rules[name].approximate {
2071 let idx = self.indexes.get_mut(name).unwrap();
2072 idx.src_side.init_hnsw(name);
2073 idx.dst_side.init_hnsw(name);
2074 }
2075 }
2076
2077 for id in 0..ids.len() as u32 {
2078 let label_sym = match labels.get(id as usize).copied() {
2079 Some(s) if s != u32::MAX => s,
2080 _ => continue,
2081 };
2082 for name in &rule_names {
2083 let def = self.rules[name].clone();
2084 let idx = self.indexes.get_mut(name).unwrap();
2085 index_node_for_rule(id, label_sym, &def, idx, syms, props);
2086 }
2087 }
2088 for name in &rule_names {
2092 if !self.rules[name].approximate {
2093 continue;
2094 }
2095 let idx = self.indexes.get_mut(name).unwrap();
2096 if let Some(((sc, sa, sd), (dc, da, dd))) = ivf_state.get(name) {
2097 idx.src_side.load_ivf_state(sc.clone(), sa.clone(), *sd);
2098 idx.dst_side.load_ivf_state(dc.clone(), da.clone(), *dd);
2099 } else {
2100 idx.src_side.fit_ivf_clusters(name);
2102 idx.dst_side.fit_ivf_clusters(name);
2103 }
2104 }
2105 self.indexes_populated = true;
2106 }
2107
2108 pub fn store_snapshot_state(
2116 &self,
2117 hnsw_blobs: BTreeMap<String, (Vec<u8>, Vec<u8>)>,
2118 ivf_bytes: Vec<u8>,
2119 ) {
2120 *self
2121 .retained_hnsw_blobs
2122 .lock()
2123 .expect("retained_hnsw_blobs lock poisoned") = hnsw_blobs;
2124 *self
2125 .retained_ivf_bytes
2126 .lock()
2127 .expect("retained_ivf_bytes lock poisoned") = if ivf_bytes.is_empty() {
2128 None
2129 } else {
2130 Some(ivf_bytes)
2131 };
2132 }
2134
2135 pub fn store_provenance_bytes(&self, bytes: Vec<u8>) {
2142 *self
2143 .retained_provenance_bytes
2144 .lock()
2145 .expect("lock poisoned") = if bytes.is_empty() { None } else { Some(bytes) };
2146 }
2147
2148 pub fn ensure_provenance_loaded(&self) {
2155 self.lazy_provenance.get_or_init(|| {
2156 let guard = self
2159 .retained_provenance_bytes
2160 .lock()
2161 .expect("retained_provenance_bytes lock poisoned");
2162 let bytes = match &*guard {
2163 Some(b) if !b.is_empty() => b,
2164 _ => return LazyProvenance::default(),
2165 };
2166 let prov = decode_provenance_bytes(bytes);
2167 let (by_node, _rule_intern, intern_rule) = rebuild_by_node(&prov);
2168 LazyProvenance {
2169 provenance: prov,
2170 by_node,
2171 intern_rule,
2172 }
2173 });
2174 }
2175
2176 pub fn ensure_provenance_loaded_mut(&mut self) {
2182 let bytes = match self
2183 .retained_provenance_bytes
2184 .lock()
2185 .expect("lock poisoned")
2186 .take()
2187 {
2188 Some(b) => b,
2189 None => return,
2190 };
2191 let prov = decode_provenance_bytes(&bytes);
2192 for set in prov.values() {
2193 self.owned.extend(set.iter().copied());
2194 }
2195 let (by_node, rule_intern, intern_rule) = rebuild_by_node(&prov);
2196 self.provenance = prov;
2197 self.by_node = by_node;
2198 self.rule_intern = rule_intern;
2199 self.intern_rule = intern_rule;
2200 }
2201
2202 pub fn consume_retained_state_eager(
2208 &mut self,
2209 ids: &IdMap,
2210 syms: &Interner,
2211 labels: &[u32],
2212 props: ColumnsView<'_>,
2213 ) {
2214 if self.indexes_populated {
2215 return;
2216 }
2217 self.ensure_provenance_loaded_mut();
2220 let hnsw = std::mem::take(
2221 &mut *self
2222 .retained_hnsw_blobs
2223 .lock()
2224 .expect("retained_hnsw_blobs lock poisoned"),
2225 );
2226 let ivf_bytes = self
2227 .retained_ivf_bytes
2228 .lock()
2229 .expect("retained_ivf_bytes lock poisoned")
2230 .take()
2231 .unwrap_or_default();
2232 let ivf = decode_ivf_bytes_to_export(&ivf_bytes);
2233 self.reindex_all_load_ivf(ids, syms, labels, props, ivf);
2234 self.load_hnsw_state(hnsw);
2236 }
2237
2238 pub fn ensure_hnsw_loaded(&self) {
2249 self.lazy_hnsw.get_or_init(|| {
2250 let snapshot: Vec<(String, Vec<u8>, Vec<u8>)> = {
2254 let guard = self
2255 .retained_hnsw_blobs
2256 .lock()
2257 .expect("retained_hnsw_blobs lock poisoned");
2258 if guard.is_empty() {
2259 return BTreeMap::new();
2260 }
2261 guard
2262 .iter()
2263 .map(|(name, (sb, db))| (name.clone(), sb.clone(), db.clone()))
2264 .collect()
2265 }; snapshot
2267 .into_iter()
2268 .map(|(name, sb, db)| {
2269 let src = if !sb.is_empty() {
2270 bincode::deserialize::<HnswIndex>(&sb).ok()
2271 } else {
2272 None
2273 };
2274 let dst = if !db.is_empty() {
2275 bincode::deserialize::<HnswIndex>(&db).ok()
2276 } else {
2277 None
2278 };
2279 (name, (src, dst))
2280 })
2281 .collect()
2282 });
2283 }
2284
2285 pub fn indexes_populated(&self) -> bool {
2288 self.indexes_populated
2289 }
2290
2291 pub fn export_hnsw_state(&self) -> BTreeMap<String, (Vec<u8>, Vec<u8>)> {
2296 let mut out = BTreeMap::new();
2297 for (name, def) in &self.rules {
2298 if def.approximate {
2299 if let Some(idx) = self.indexes.get(name) {
2300 out.insert(
2301 name.clone(),
2302 (
2303 idx.src_side.export_hnsw_blob(),
2304 idx.dst_side.export_hnsw_blob(),
2305 ),
2306 );
2307 }
2308 }
2309 }
2310 out
2311 }
2312
2313 pub fn export_hnsw_state_passthrough(&self) -> BTreeMap<String, (Vec<u8>, Vec<u8>)> {
2317 if !self.indexes_populated {
2318 let guard = self
2319 .retained_hnsw_blobs
2320 .lock()
2321 .expect("retained_hnsw_blobs lock poisoned");
2322 if !guard.is_empty() {
2323 return guard.clone();
2324 }
2325 }
2326 self.export_hnsw_state()
2327 }
2328
2329 pub fn retained_ivf_bytes_clone(&self) -> Option<Vec<u8>> {
2335 self.retained_ivf_bytes
2336 .lock()
2337 .expect("retained_ivf_bytes lock poisoned")
2338 .clone()
2339 }
2340
2341 pub fn load_hnsw_state(&mut self, blobs: BTreeMap<String, (Vec<u8>, Vec<u8>)>) {
2346 for (name, (src_blob, dst_blob)) in blobs {
2347 if let Some(idx) = self.indexes.get_mut(&name) {
2348 if !src_blob.is_empty() {
2349 idx.src_side.load_hnsw_blob(&src_blob);
2350 }
2351 if !dst_blob.is_empty() {
2352 idx.dst_side.load_hnsw_blob(&dst_blob);
2353 }
2354 }
2355 }
2356 }
2357
2358 pub fn hnsw_search_dst(
2363 &self,
2364 field: &str,
2365 dst_label: &str,
2366 q: &[f64],
2367 k: usize,
2368 ) -> Option<Vec<(u32, f64)>> {
2369 for (name, def) in &self.rules {
2370 if !def.approximate || def.dst_label != dst_label {
2371 continue;
2372 }
2373 if !predicate_covers_field(&def.predicate, field) {
2375 continue;
2376 }
2377 if let Some(idx) = self.indexes.get(name) {
2378 if let Some(h) = idx.dst_side.hnsw_ref() {
2379 if !h.is_empty() {
2380 return Some(h.search(q, k));
2381 }
2382 }
2383 }
2384 if let Some(lazy) = self.lazy_hnsw.get() {
2387 if let Some((_, Some(h))) = lazy.get(name) {
2388 if !h.is_empty() {
2389 return Some(h.search(q, k));
2390 }
2391 }
2392 }
2393 }
2394 None
2395 }
2396
2397 pub fn hnsw_has_rule(&self, field: &str) -> bool {
2404 self.rules
2405 .values()
2406 .any(|def| def.approximate && predicate_covers_field(&def.predicate, field))
2407 }
2408
2409 pub fn hnsw_search_any_dst(&self, field: &str, q: &[f64], k: usize) -> Option<Vec<(u32, f64)>> {
2419 let mut merged: std::collections::BTreeMap<u32, f64> = std::collections::BTreeMap::new();
2420 let mut found_index = false;
2421
2422 for (name, def) in &self.rules {
2423 if !def.approximate {
2424 continue;
2425 }
2426 if !predicate_covers_field(&def.predicate, field) {
2427 continue;
2428 }
2429 let hits: Option<Vec<(u32, f64)>> = if let Some(idx) = self.indexes.get(name) {
2430 if let Some(h) = idx.dst_side.hnsw_ref() {
2431 if !h.is_empty() {
2432 found_index = true;
2433 Some(h.search(q, k))
2434 } else {
2435 None
2436 }
2437 } else {
2438 None
2439 }
2440 } else if let Some(lazy) = self.lazy_hnsw.get() {
2441 if let Some((_, Some(h))) = lazy.get(name) {
2442 if !h.is_empty() {
2443 found_index = true;
2444 Some(h.search(q, k))
2445 } else {
2446 None
2447 }
2448 } else {
2449 None
2450 }
2451 } else {
2452 None
2453 };
2454
2455 if let Some(hits) = hits {
2456 for (id, score) in hits {
2457 merged
2458 .entry(id)
2459 .and_modify(|s| {
2460 if score > *s {
2461 *s = score;
2462 }
2463 })
2464 .or_insert(score);
2465 }
2466 }
2467 }
2468
2469 if !found_index {
2470 return None;
2471 }
2472 let mut result: Vec<(u32, f64)> = merged.into_iter().collect();
2473 result.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
2474 result.truncate(k);
2475 Some(result)
2476 }
2477
2478 fn ensure_indexes_populated(&mut self, g: &GraphMut<'_>) {
2492 if self.indexes_populated || self.rules.is_empty() {
2493 return;
2494 }
2495 let hnsw = std::mem::take(
2496 &mut *self
2497 .retained_hnsw_blobs
2498 .lock()
2499 .expect("retained_hnsw_blobs lock poisoned"),
2500 );
2501 let ivf_bytes = self
2502 .retained_ivf_bytes
2503 .lock()
2504 .expect("retained_ivf_bytes lock poisoned")
2505 .take()
2506 .unwrap_or_default();
2507 let ivf = decode_ivf_bytes_to_export(&ivf_bytes);
2508 self.reindex_all_load_ivf(g.ids, g.syms, g.labels, g.props, ivf);
2509 self.load_hnsw_state(hnsw);
2510 }
2511
2512 pub fn create_rule(&mut self, def: RuleDef, g: &mut GraphMut<'_>) -> Result<(), String> {
2515 def.validate()?;
2516 if self.rules.contains_key(&def.name) {
2517 return Err(format!("rule {:?} already exists", def.name));
2518 }
2519 self.ensure_indexes_populated(g);
2525 let scope = self.begin_chain();
2529 let name = def.name.clone();
2530 self.rules.insert(name.clone(), def);
2531 self.indexes.insert(name.clone(), RuleIndex::default());
2532 self.provenance.entry(name.clone()).or_default();
2533 self.tripped.insert(name.clone(), false);
2534 self.fires.insert(name.clone(), 0);
2535
2536 let n_total = g.ids.len() as u32;
2538 let def = self.rules[&name].clone();
2539
2540 if def.approximate {
2543 let idx = self.indexes.get_mut(&name).unwrap();
2544 idx.src_side.init_hnsw(&name);
2545 idx.dst_side.init_hnsw(&name);
2546 }
2547
2548 for id in 0..n_total {
2549 let label_sym = match g.labels.get(id as usize).copied() {
2550 Some(s) if s != u32::MAX => s,
2551 _ => continue,
2552 };
2553 let idx = self.indexes.get_mut(&name).unwrap();
2554 index_node_for_rule(id, label_sym, &def, idx, g.syms, g.props);
2555 }
2556
2557 if def.approximate {
2560 let idx = self.indexes.get_mut(&name).unwrap();
2561 idx.src_side.fit_ivf_clusters(&name);
2562 idx.dst_side.fit_ivf_clusters(&name);
2563 }
2564
2565 let mut prov = ProvSets {
2570 set: self.provenance.get_mut(&name).unwrap(),
2571 owned: &mut self.owned,
2572 by_node: &mut self.by_node,
2573 rule_intern: &mut self.rule_intern,
2574 intern_rule: &mut self.intern_rule,
2575 deltas: &mut self.pending_deltas,
2576 emit: self.emit_deltas,
2577 };
2578 if def.via_label.is_some() {
2579 let budget = edge_budget(&def);
2581 let et = g.syms.intern(&def.edge_type);
2582 let src_sym = g.syms.get(&def.src_label);
2583 let tripped = self.tripped.get_mut(&name).unwrap();
2584 'via_outer: for id in 0..g.ids.len() as u32 {
2585 let label_sym = match g.labels.get(id as usize).copied() {
2586 Some(s) if s != u32::MAX => s,
2587 _ => continue,
2588 };
2589 if src_sym != Some(label_sym) {
2590 continue;
2591 }
2592 let per_src = compute_desired_via(&def, None, ViaAnchor::Src(id), self.doomed, g);
2593 if let Some(k) = def.max_edges {
2594 let top_k = filter_src_top_k(per_src, k, g.ids);
2595 apply_per_src_top_k(&def, id, top_k, &mut prov, g);
2596 } else {
2597 for ((s, d), score) in per_src {
2598 let triple = (et, s, d);
2599 let already = prov.contains(&triple);
2600 if !already {
2601 if *tripped || prov.len() as u64 >= budget {
2602 *tripped = true;
2603 break 'via_outer;
2604 }
2605 let newly = g.topo.add_edge(et, s, d);
2606 if newly {
2607 prov.insert(&name, triple, g.ids, g.syms);
2608 }
2609 }
2610 let is_owned_here = already || prov.contains(&triple);
2611 if is_owned_here {
2612 if let Some(p) = &def.weight_prop {
2613 g.edge_props.set(et, s, d, p, Value::Float(score));
2614 }
2615 }
2616 }
2617 }
2618 }
2619 } else if let Some(k) = def.max_edges {
2620 apply_streaming_create_top_k(&def, k, &self.indexes[&name], &mut prov, g);
2621 } else {
2622 let tripped = self.tripped.get_mut(&name).unwrap();
2623 apply_streaming_create(&def, &self.indexes[&name], &mut prov, tripped, g);
2624 }
2625 let fires = self.fires.get_mut(&name).unwrap();
2628 bump_fires_for_participants(&def, g, fires);
2629
2630 self.indexes_populated = true;
2634
2635 self.end_chain(scope, g);
2636 Ok(())
2637 }
2638
2639 pub fn delete_rule(&mut self, name: &str, g: &mut GraphMut<'_>) -> Result<(), String> {
2641 if !self.rules.contains_key(name) {
2642 return Err(format!("rule {:?} not found", name));
2643 }
2644 let scope = self.begin_chain();
2648 let def = self.rules.remove(name).unwrap();
2649 self.indexes.remove(name);
2650 self.tripped.remove(name);
2651 self.fires.remove(name);
2652 let mut leftover = self.provenance.remove(name).unwrap_or_default();
2653 let _et = g.syms.intern(&def.edge_type);
2655 let triples: Vec<Triple> = leftover.iter().copied().collect();
2656 let mut sets = ProvSets {
2657 set: &mut leftover,
2658 owned: &mut self.owned,
2659 by_node: &mut self.by_node,
2660 rule_intern: &mut self.rule_intern,
2661 intern_rule: &mut self.intern_rule,
2662 deltas: &mut self.pending_deltas,
2663 emit: self.emit_deltas,
2664 };
2665 for triple in triples {
2666 let (t, s, d) = triple;
2667 g.topo.remove_edge(t, s, d);
2668 g.edge_props.remove_edge(t, s, d);
2669 sets.remove(name, triple, g.ids, g.syms);
2670 }
2671 let same_etype_survivors: Vec<String> = self
2677 .rules
2678 .values()
2679 .filter(|r| r.edge_type == def.edge_type)
2680 .map(|r| r.name.clone())
2681 .collect();
2682 for survivor in same_etype_survivors {
2683 let _ = self.rebuild_inner(&survivor, g);
2685 }
2686 self.end_chain(scope, g);
2687 Ok(())
2688 }
2689
2690 pub fn on_node_changed(
2704 &mut self,
2705 n: u32,
2706 changed: Option<(&str, Option<Value>)>,
2707 g: &mut GraphMut<'_>,
2708 ) {
2709 let scope = self.begin_chain();
2710 self.on_node_changed_inner(n, changed, g);
2711 self.end_chain(scope, g);
2712 }
2713
2714 fn on_node_changed_inner(
2715 &mut self,
2716 n: u32,
2717 changed: Option<(&str, Option<Value>)>,
2718 g: &mut GraphMut<'_>,
2719 ) {
2720 self.ensure_provenance_loaded_mut();
2722 self.ensure_indexes_populated(g);
2728
2729 let n_label = g.labels.get(n as usize).copied();
2730 let rule_names: Vec<String> = self.rules.keys().cloned().collect();
2731
2732 for rule_name in rule_names {
2733 let def = self.rules[&rule_name].clone();
2734
2735 if def.via_label.is_some() {
2736 self.on_node_changed_via(&rule_name, &def, n, n_label, changed.clone(), g);
2738 } else {
2739 let src_sym = g.syms.get(&def.src_label);
2741 let dst_sym = g.syms.get(&def.dst_label);
2742 let as_src = src_sym.is_some() && n_label == src_sym;
2743 let as_dst = dst_sym.is_some() && n_label == dst_sym;
2744
2745 let fires = match changed {
2746 None => as_src || as_dst,
2747 Some((field, _)) => def.watched_fields().contains(field) && (as_src || as_dst),
2748 };
2749 if !fires {
2750 continue;
2751 }
2752 *self.fires.entry(rule_name.clone()).or_default() += 1;
2753
2754 if let Some((field, ref old_val)) = changed {
2756 let old_val_cloned = old_val.clone();
2757 let old_getter = |f: &str| {
2758 if f == field {
2759 old_val_cloned.clone()
2760 } else {
2761 g.props.get(n, f).map(|vr| vr.into_value())
2762 }
2763 };
2764 let idx = self.indexes.get_mut(&rule_name).unwrap();
2765 if as_src {
2766 let spec = src_lookup_spec_for(&def);
2767 idx.src_side.remove(&spec, n, &old_getter);
2768 }
2769 if as_dst {
2770 let spec = candidate_spec_for(&def);
2771 idx.dst_side.remove(&spec, n, &old_getter);
2772 }
2773 }
2774
2775 {
2776 let cur_getter = |f: &str| g.props.get(n, f).map(|vr| vr.into_value());
2777 let idx = self.indexes.get_mut(&rule_name).unwrap();
2778 if as_src {
2779 let spec = src_lookup_spec_for(&def);
2780 idx.src_side.insert(&spec, n, &cur_getter);
2781 }
2782 if as_dst {
2783 let spec = candidate_spec_for(&def);
2784 idx.dst_side.insert(&spec, n, &cur_getter);
2785 }
2786 }
2787
2788 self.maybe_queue_ivf_rebuild(&rule_name, &def);
2789
2790 if let Some(k) = def.max_edges {
2792 let et = g.syms.intern(&def.edge_type);
2793 let affected_srcs_for_n_dst: BTreeSet<u32> = if as_dst {
2794 let rid = self.rule_intern.get(&def.name).copied();
2795 self.by_node
2796 .get(&n)
2797 .into_iter()
2798 .flatten()
2799 .filter(|(r, t, _s, d)| Some(*r) == rid && *t == et && *d == n)
2800 .map(|(_, _, s, _)| *s)
2801 .collect()
2802 } else {
2803 BTreeSet::new()
2804 };
2805
2806 let mut prov = ProvSets {
2807 set: self.provenance.entry(rule_name.clone()).or_default(),
2808 owned: &mut self.owned,
2809 by_node: &mut self.by_node,
2810 rule_intern: &mut self.rule_intern,
2811 intern_rule: &mut self.intern_rule,
2812 deltas: &mut self.pending_deltas,
2813 emit: self.emit_deltas,
2814 };
2815
2816 if as_src {
2817 let desired_n_src =
2818 compute_desired(&def, &self.indexes[&rule_name], n, true, g);
2819 let top_k = filter_src_top_k(desired_n_src, k, g.ids);
2820 apply_per_src_top_k(&def, n, top_k, &mut prov, g);
2821 }
2822
2823 if as_dst {
2824 let new_desired =
2825 compute_desired(&def, &self.indexes[&rule_name], n, false, g);
2826 let new_srcs: BTreeSet<u32> = new_desired.keys().map(|(s, _)| *s).collect();
2827 let affected_srcs: BTreeSet<u32> =
2828 affected_srcs_for_n_dst.union(&new_srcs).copied().collect();
2829 for src in affected_srcs {
2830 if src == n {
2831 continue;
2832 }
2833 let desired_src =
2834 compute_desired(&def, &self.indexes[&rule_name], src, true, g);
2835 let top_k = filter_src_top_k(desired_src, k, g.ids);
2836 apply_per_src_top_k(&def, src, top_k, &mut prov, g);
2837 }
2838 }
2839 } else {
2840 let mut desired = BTreeMap::new();
2841 if as_src {
2842 desired.extend(compute_desired(
2843 &def,
2844 &self.indexes[&rule_name],
2845 n,
2846 true,
2847 g,
2848 ));
2849 }
2850 if as_dst {
2851 desired.extend(compute_desired(
2852 &def,
2853 &self.indexes[&rule_name],
2854 n,
2855 false,
2856 g,
2857 ));
2858 }
2859 let tripped = self.tripped.entry(rule_name.clone()).or_default();
2860 apply_desired(
2861 &def,
2862 desired,
2863 Some(n),
2864 &mut ProvSets {
2865 set: self.provenance.entry(rule_name).or_default(),
2866 owned: &mut self.owned,
2867 by_node: &mut self.by_node,
2868 rule_intern: &mut self.rule_intern,
2869 intern_rule: &mut self.intern_rule,
2870 deltas: &mut self.pending_deltas,
2871 emit: self.emit_deltas,
2872 },
2873 tripped,
2874 g,
2875 );
2876 }
2877 }
2878 }
2879 }
2880
2881 fn on_node_changed_via(
2903 &mut self,
2904 rule_name: &str,
2905 def: &RuleDef,
2906 n: u32,
2907 n_label: Option<u32>,
2908 changed: Option<(&str, Option<Value>)>,
2909 g: &mut GraphMut<'_>,
2910 ) {
2911 let doomed = self.doomed;
2912 let src_sym = g.syms.get(&def.src_label);
2913 let dst_sym = g.syms.get(&def.dst_label);
2914 let via_sym = def.via_label.as_deref().and_then(|l| g.syms.get(l));
2915
2916 let as_src = src_sym.is_some() && n_label == src_sym;
2917 let as_dst = dst_sym.is_some() && n_label == dst_sym;
2918 let as_via = via_sym.is_some() && n_label == via_sym;
2919
2920 let fires = match changed {
2924 None => as_src || as_via || as_dst,
2925 Some((field, _)) => {
2926 let wf = def.watched_fields();
2927 (wf.contains(field)) && (as_src || as_via || as_dst)
2928 }
2929 };
2930 if !fires {
2931 return;
2932 }
2933 *self.fires.entry(rule_name.to_string()).or_default() += 1;
2934
2935 if as_dst {
2944 let spec = candidate_spec_for(def);
2945 let idx = self.indexes.entry(rule_name.to_string()).or_default();
2946 if let Some((field, ref old_val)) = changed {
2947 let old_val_cloned = old_val.clone();
2948 let old_getter = |f: &str| {
2949 if f == field {
2950 old_val_cloned.clone()
2951 } else {
2952 g.props.get(n, f).map(|vr| vr.into_value())
2953 }
2954 };
2955 idx.dst_side.remove(&spec, n, &old_getter);
2956 }
2957 let cur_getter = |f: &str| g.props.get(n, f).map(|vr| vr.into_value());
2958 idx.dst_side.insert(&spec, n, &cur_getter);
2959 }
2960
2961 let mut affected_srcs: BTreeSet<u32> = BTreeSet::new();
2963 if as_src {
2964 affected_srcs.insert(n);
2965 }
2966 if as_via {
2967 let via_edge_str = def.via_edge.as_deref().unwrap();
2969 let via_dir = def.via_dir.unwrap_or(core_storage::Direction::Out);
2970 let rev_dir = match via_dir {
2971 core_storage::Direction::Out => core_storage::Direction::In,
2972 core_storage::Direction::In => core_storage::Direction::Out,
2973 };
2974 if let (Some(via_etype), Some(s_sym)) = (g.syms.get(via_edge_str), src_sym) {
2975 for &src in g.neighbors(via_etype, rev_dir, n).as_ref() {
2976 if g.labels.get(src as usize).copied() == Some(s_sym) {
2977 affected_srcs.insert(src);
2978 }
2979 }
2980 }
2981 }
2982 if as_dst {
2983 let desired_touching_n = compute_desired_via(
2985 def,
2986 self.indexes.get(rule_name),
2987 ViaAnchor::Dst(n),
2988 doomed,
2989 g,
2990 );
2991 for (src, _dst) in desired_touching_n.keys() {
2992 affected_srcs.insert(*src);
2993 }
2994 let et = g.syms.intern(&def.edge_type);
2996 let rid = self.rule_intern.get(rule_name).copied();
2997 let old_srcs: Vec<u32> = self
2998 .by_node
2999 .get(&n)
3000 .into_iter()
3001 .flatten()
3002 .filter(|(r, t, _s, d)| Some(*r) == rid && *t == et && *d == n)
3003 .map(|(_, _, s, _)| *s)
3004 .collect();
3005 affected_srcs.extend(old_srcs);
3006 }
3007
3008 let affected_srcs: Vec<u32> = affected_srcs.into_iter().collect();
3010 let rule_index = self.indexes.get(rule_name);
3013
3014 if let Some(k) = def.max_edges {
3015 let mut prov = ProvSets {
3016 set: self.provenance.entry(rule_name.to_string()).or_default(),
3017 owned: &mut self.owned,
3018 by_node: &mut self.by_node,
3019 rule_intern: &mut self.rule_intern,
3020 intern_rule: &mut self.intern_rule,
3021 deltas: &mut self.pending_deltas,
3022 emit: self.emit_deltas,
3023 };
3024 for src in affected_srcs {
3025 let desired_src =
3026 compute_desired_via(def, rule_index, ViaAnchor::Src(src), doomed, g);
3027 let top_k = filter_src_top_k(desired_src, k, g.ids);
3028 apply_per_src_top_k(def, src, top_k, &mut prov, g);
3029 }
3030 } else {
3031 let tripped = self.tripped.entry(rule_name.to_string()).or_default();
3032 let budget = edge_budget(def);
3033 for src in affected_srcs {
3036 let desired_src =
3037 compute_desired_via(def, rule_index, ViaAnchor::Src(src), doomed, g);
3038 if !*tripped {
3039 let mut prov = ProvSets {
3040 set: self.provenance.entry(rule_name.to_string()).or_default(),
3041 owned: &mut self.owned,
3042 by_node: &mut self.by_node,
3043 rule_intern: &mut self.rule_intern,
3044 intern_rule: &mut self.intern_rule,
3045 deltas: &mut self.pending_deltas,
3046 emit: self.emit_deltas,
3047 };
3048 apply_desired(def, desired_src, Some(src), &mut prov, tripped, g);
3049 }
3050 let _ = budget;
3054 }
3055 }
3056 }
3057
3058 pub fn on_edge_changed(
3072 &mut self,
3073 etype_str: &str,
3074 src_id: u32,
3075 dst_id: u32,
3076 g: &mut GraphMut<'_>,
3077 ) {
3078 let scope = self.begin_chain();
3079 self.on_edge_changed_inner(etype_str, src_id, dst_id, g);
3080 self.end_chain(scope, g);
3081 }
3082
3083 fn on_edge_changed_inner(
3084 &mut self,
3085 etype_str: &str,
3086 src_id: u32,
3087 dst_id: u32,
3088 g: &mut GraphMut<'_>,
3089 ) {
3090 self.ensure_provenance_loaded_mut();
3092 self.ensure_indexes_populated(g);
3095
3096 let rule_names: Vec<String> = self.rules.keys().cloned().collect();
3097 for (rule_idx, rule_name) in rule_names.into_iter().enumerate() {
3098 let def = self.rules[&rule_name].clone();
3099 let Some(ref via_edge) = def.via_edge else {
3100 continue; };
3102 if via_edge != etype_str {
3103 continue; }
3105
3106 let src_sym = match g.syms.get(&def.src_label) {
3108 Some(s) => s,
3109 None => continue,
3110 };
3111 let via_sym = match def.via_label.as_deref().and_then(|l| g.syms.get(l)) {
3112 Some(s) => s,
3113 None => continue,
3114 };
3115 let via_dir = def.via_dir.unwrap_or(core_storage::Direction::Out);
3119 let (rule_src, rule_via) = match via_dir {
3120 core_storage::Direction::Out => (src_id, dst_id),
3121 core_storage::Direction::In => (dst_id, src_id),
3122 };
3123
3124 if g.labels.get(rule_src as usize).copied() != Some(src_sym) {
3125 continue;
3126 }
3127 if g.labels.get(rule_via as usize).copied() != Some(via_sym) {
3128 continue;
3129 }
3130
3131 if self.chain_depth > 0 && !self.chain_fired.insert((rule_idx as u32, rule_src)) {
3142 continue;
3143 }
3144
3145 *self.fires.entry(rule_name.clone()).or_default() += 1;
3147 let desired_src =
3148 compute_desired_via(&def, None, ViaAnchor::Src(rule_src), self.doomed, g);
3149
3150 if let Some(k) = def.max_edges {
3151 let mut prov = ProvSets {
3152 set: self.provenance.entry(rule_name).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 let top_k = filter_src_top_k(desired_src, k, g.ids);
3161 apply_per_src_top_k(&def, rule_src, top_k, &mut prov, g);
3162 } else {
3163 let tripped = self.tripped.entry(rule_name.clone()).or_default();
3164 let mut prov = ProvSets {
3165 set: self.provenance.entry(rule_name).or_default(),
3166 owned: &mut self.owned,
3167 by_node: &mut self.by_node,
3168 rule_intern: &mut self.rule_intern,
3169 intern_rule: &mut self.intern_rule,
3170 deltas: &mut self.pending_deltas,
3171 emit: self.emit_deltas,
3172 };
3173 apply_desired(&def, desired_src, Some(rule_src), &mut prov, tripped, g);
3174 }
3175 }
3176 }
3177
3178 pub fn on_node_removed(&mut self, n: u32, g: &mut GraphMut<'_>) {
3189 let prev_doomed = self.doomed;
3196 self.doomed = Some(n);
3197 let scope = self.begin_chain();
3198 self.on_node_removed_inner(n, g);
3199 self.end_chain(scope, g);
3200 self.doomed = prev_doomed;
3201 }
3202
3203 fn on_node_removed_inner(&mut self, n: u32, g: &mut GraphMut<'_>) {
3204 self.ensure_provenance_loaded_mut();
3206 self.ensure_indexes_populated(g);
3211
3212 let n_label = g.labels.get(n as usize).copied();
3213 let rule_names: Vec<String> = self.rules.keys().cloned().collect();
3214
3215 for rule_name in rule_names {
3216 let def = self.rules[&rule_name].clone();
3217 let src_sym = g.syms.get(&def.src_label);
3218 let dst_sym = g.syms.get(&def.dst_label);
3219 let as_src = src_sym.is_some() && n_label == src_sym;
3220 let as_dst = dst_sym.is_some() && n_label == dst_sym;
3221
3222 {
3223 let cur_getter = |f: &str| g.props.get(n, f).map(|vr| vr.into_value());
3224 let idx = self.indexes.get_mut(&rule_name).unwrap();
3225 if as_src {
3226 let spec = src_lookup_spec_for(&def);
3227 idx.src_side.remove(&spec, n, &cur_getter);
3228 }
3229 if as_dst {
3230 let spec = candidate_spec_for(&def);
3231 idx.dst_side.remove(&spec, n, &cur_getter);
3232 }
3233 }
3234
3235 self.maybe_queue_ivf_rebuild(&rule_name, &def);
3236 }
3237
3238 let touching: Vec<(String, Triple)> = self
3239 .by_node
3240 .get(&n)
3241 .into_iter()
3242 .flatten()
3243 .map(|&(rid, t, s, d)| (self.intern_rule[rid as usize].clone(), (t, s, d)))
3244 .collect();
3245
3246 let topk_backfill: Vec<(String, u32)> = touching
3250 .iter()
3251 .filter_map(|(rule_name, triple)| {
3252 let &(_, s, d) = triple;
3253 let def = self.rules.get(rule_name)?;
3254 def.max_edges?; if d == n && s != n {
3256 Some((rule_name.clone(), s))
3257 } else {
3258 None
3259 }
3260 })
3261 .collect();
3262
3263 for (rule_name, triple) in touching {
3264 let (t, s, d) = triple;
3265 g.topo.remove_edge(t, s, d);
3266 g.edge_props.remove_edge(t, s, d);
3267 if let Some(set) = self.provenance.get_mut(&rule_name) {
3268 ProvSets {
3269 set,
3270 owned: &mut self.owned,
3271 by_node: &mut self.by_node,
3272 rule_intern: &mut self.rule_intern,
3273 intern_rule: &mut self.intern_rule,
3274 deltas: &mut self.pending_deltas,
3275 emit: self.emit_deltas,
3276 }
3277 .remove(&rule_name, triple, g.ids, g.syms);
3278 }
3279 }
3280
3281 for (rule_name, src) in topk_backfill {
3286 let def = self.rules[&rule_name].clone();
3287 let k = def.max_edges.unwrap(); let desired_src = if def.via_edge.is_some() {
3292 compute_desired_via(&def, None, ViaAnchor::Src(src), self.doomed, g)
3293 } else {
3294 compute_desired(&def, &self.indexes[&rule_name], src, true, g)
3295 };
3296 let top_k = filter_src_top_k(desired_src, k, g.ids);
3297 let mut prov = ProvSets {
3298 set: self.provenance.entry(rule_name.clone()).or_default(),
3299 owned: &mut self.owned,
3300 by_node: &mut self.by_node,
3301 rule_intern: &mut self.rule_intern,
3302 intern_rule: &mut self.intern_rule,
3303 deltas: &mut self.pending_deltas,
3304 emit: self.emit_deltas,
3305 };
3306 apply_per_src_top_k(&def, src, top_k, &mut prov, g);
3307 }
3308 }
3309
3310 pub fn rebuild(&mut self, name: &str, g: &mut GraphMut<'_>) -> Result<(), String> {
3323 let scope = self.begin_chain();
3324 let out = self.rebuild_inner(name, g);
3325 self.end_chain(scope, g);
3326 out
3327 }
3328
3329 fn rebuild_inner(&mut self, name: &str, g: &mut GraphMut<'_>) -> Result<(), String> {
3333 if !self.rules.contains_key(name) {
3334 return Err(format!("rule {:?} not found", name));
3335 }
3336 self.rebuild_needed.remove(name);
3337 let def = self.rules[name].clone();
3338
3339 *self.indexes.get_mut(name).unwrap() = RuleIndex::default();
3341
3342 if def.approximate {
3344 let idx = self.indexes.get_mut(name).unwrap();
3345 idx.src_side.init_hnsw(name);
3346 idx.dst_side.init_hnsw(name);
3347 }
3348
3349 let n_total = g.ids.len() as u32;
3350 for id in 0..n_total {
3351 let label_sym = match g.labels.get(id as usize).copied() {
3352 Some(s) if s != u32::MAX => s,
3353 _ => continue,
3354 };
3355 let idx = self.indexes.get_mut(name).unwrap();
3356 index_node_for_rule(id, label_sym, &def, idx, g.syms, g.props);
3357 }
3358
3359 if def.approximate {
3362 let idx = self.indexes.get_mut(name).unwrap();
3363 idx.src_side.fit_ivf_clusters(name);
3364 idx.dst_side.fit_ivf_clusters(name);
3365 }
3366
3367 let doomed = self.doomed;
3374 let mut prov = ProvSets {
3375 set: self.provenance.get_mut(name).unwrap(),
3376 owned: &mut self.owned,
3377 by_node: &mut self.by_node,
3378 rule_intern: &mut self.rule_intern,
3379 intern_rule: &mut self.intern_rule,
3380 deltas: &mut self.pending_deltas,
3381 emit: self.emit_deltas,
3382 };
3383 if let Some(k) = def.max_edges {
3384 apply_streaming_rebuild_top_k(&def, k, &self.indexes[name], doomed, &mut prov, g);
3385 } else {
3386 let tripped = self.tripped.get_mut(name).unwrap();
3387 if def.via_edge.is_some() {
3388 apply_via_rebuild(&def, doomed, &mut prov, tripped, g);
3389 } else {
3390 apply_streaming_rebuild(&def, &self.indexes[name], &mut prov, tripped, g);
3391 }
3392 }
3393 let fires = self.fires.entry(name.to_string()).or_default();
3394 bump_fires_for_participants(&def, g, fires);
3395
3396 Ok(())
3397 }
3398
3399 #[cfg(test)]
3400 fn by_node_consistent(&self) -> bool {
3401 let (rebuilt, intern, names) = rebuild_by_node(&self.provenance);
3402 resolve_by_node(&self.by_node, &self.intern_rule) == resolve_by_node(&rebuilt, &names)
3403 && intern.len() == names.len()
3404 }
3405}
3406
3407#[cfg(test)]
3412mod tests {
3413 use super::*;
3414 use crate::def::{evaluate, NodeView, Predicate, RuleDef};
3415 use core_storage::{ColumnStore, Direction, EdgeProps, IdMap, Interner, Topology, Value};
3416
3417 struct Fx {
3418 ids: IdMap,
3419 syms: Interner,
3420 labels: Vec<u32>,
3421 props: ColumnStore,
3422 topo: Topology,
3423 eprops: EdgeProps,
3424 }
3425 impl Fx {
3426 fn new() -> Self {
3427 Fx {
3428 ids: IdMap::new(),
3429 syms: Interner::new(),
3430 labels: vec![],
3431 props: ColumnStore::new(),
3432 topo: Topology::new(),
3433 eprops: EdgeProps::new(),
3434 }
3435 }
3436 fn add(&mut self, label: &str, key: &str, props: Vec<(&str, Value)>) -> u32 {
3437 let id = self.ids.get_or_insert(key);
3438 let sym = self.syms.intern(label);
3439 self.labels.resize(id as usize + 1, u32::MAX);
3440 self.labels[id as usize] = sym;
3441 for (f, v) in props {
3442 self.props.set(id, f, v);
3443 }
3444 id
3445 }
3446 fn g(&mut self) -> GraphMut<'_> {
3447 GraphMut {
3448 ids: &self.ids,
3449 syms: &mut self.syms,
3450 labels: &self.labels,
3451 props: ColumnsView::owned(&self.props),
3452 topo: &mut self.topo,
3453 base_topo: None,
3454 edge_props: &mut self.eprops,
3455 }
3456 }
3457 }
3458
3459 fn tags(items: &[&str]) -> Value {
3460 Value::List(items.iter().map(|s| Value::Str((*s).into())).collect())
3461 }
3462
3463 fn overlap_rule() -> RuleDef {
3464 RuleDef {
3465 name: "rel".into(),
3466 src_label: "A".into(),
3467 dst_label: "A".into(),
3468 predicate: Predicate::Overlap {
3469 field: "tags".into(),
3470 min: 0.4,
3471 },
3472 edge_type: "REL".into(),
3473 weight_prop: Some("score".into()),
3474 max_edges: None,
3475 approximate: false,
3476 via_label: None,
3477 via_edge: None,
3478 via_dir: None,
3479 }
3480 }
3481
3482 fn emb(xs: &[f64]) -> Value {
3483 Value::List(xs.iter().copied().map(Value::Float).collect())
3484 }
3485
3486 fn approx_vec_rule() -> RuleDef {
3487 RuleDef {
3488 name: "sim".into(),
3489 src_label: "V".into(),
3490 dst_label: "V".into(),
3491 predicate: Predicate::VectorSimilar {
3492 field: "emb".into(),
3493 min: 0.5,
3494 },
3495 edge_type: "SIM".into(),
3496 weight_prop: None,
3497 max_edges: None,
3498 approximate: true,
3499 via_label: None,
3500 via_edge: None,
3501 via_dir: None,
3502 }
3503 }
3504
3505 #[test]
3506 fn approximate_rule_rebuilds_after_drift_threshold() {
3507 with_ivf_drift_rebuild(1, || {
3508 let mut fx = Fx::new();
3509 let mut ids = Vec::new();
3510 for i in 0..6 {
3511 let x = i as f64 * 0.2;
3512 ids.push(fx.add("V", &format!("v{i}"), vec![("emb", emb(&[x, 1.0 - x]))]));
3513 }
3514 let mut eng = RuleEngine::new();
3515 {
3516 let mut g = fx.g();
3517 eng.create_rule(approx_vec_rule(), &mut g).unwrap();
3518 }
3519 assert!(eng.take_rebuild_needed().is_empty());
3520 {
3521 let mut g = fx.g();
3522 eng.on_node_removed(ids[0], &mut g);
3523 }
3524 assert!(
3525 eng.take_rebuild_needed().is_empty(),
3526 "drift=1 is not > threshold 1"
3527 );
3528 {
3529 let mut g = fx.g();
3530 eng.on_node_removed(ids[1], &mut g);
3531 }
3532 assert_eq!(eng.take_rebuild_needed(), vec!["sim".to_string()]);
3533 {
3534 let mut g = fx.g();
3535 eng.rebuild("sim", &mut g).unwrap();
3536 }
3537 assert!(
3538 eng.take_rebuild_needed().is_empty(),
3539 "rebuild must reset drift and not re-queue itself"
3540 );
3541 let drift = eng
3542 .export_ivf_state()
3543 .get("sim")
3544 .map(|(_, dst)| dst.2)
3545 .unwrap();
3546 assert_eq!(drift, 0, "rebuild resets dst-side IVF drift");
3547 });
3548 }
3549
3550 #[test]
3551 fn backfill_creates_edges_with_scores_and_delete_removes_exactly_them() {
3552 let mut fx = Fx::new();
3553 let a = fx.add("A", "a", vec![("tags", tags(&["x", "y"]))]);
3554 let b = fx.add("A", "b", vec![("tags", tags(&["x", "y"]))]);
3555 let _c = fx.add("A", "c", vec![("tags", tags(&["q"]))]);
3556 let et = fx.syms.intern("REL");
3558 fx.topo.add_edge(et, a, b);
3559 let mut eng = RuleEngine::new();
3560 let mut g = fx.g();
3561 eng.create_rule(overlap_rule(), &mut g).unwrap();
3562 assert!(g.topo.neighbors(et, Direction::Out, b).contains(&a));
3564 assert_eq!(
3565 g.edge_props.get(et, b, a, "score"),
3566 Some(&Value::Float(1.0))
3567 );
3568 assert!(!eng.is_owned(et, a, b));
3569 assert!(eng.is_owned(et, b, a));
3570 eng.delete_rule("rel", &mut g).unwrap();
3571 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);
3574 }
3575
3576 #[test]
3577 fn incremental_update_adds_and_removes_edges() {
3578 let mut fx = Fx::new();
3579 let a = fx.add("A", "a", vec![("tags", tags(&["x", "y"]))]);
3580 let b = fx.add("A", "b", vec![("tags", tags(&["y", "z"]))]);
3581 let et = fx.syms.intern("REL");
3582 let mut eng = RuleEngine::new();
3583 {
3584 let mut g = fx.g();
3585 eng.create_rule(overlap_rule(), &mut g).unwrap(); assert_eq!(g.topo.edge_count(), 0);
3587 }
3588 let old = fx.props.get(b, "tags").cloned();
3590 fx.props.set(b, "tags", tags(&["x", "y"]));
3591 {
3592 let mut g = fx.g();
3593 eng.on_node_changed(b, Some(("tags", old)), &mut g);
3594 assert!(g.topo.neighbors(et, Direction::Out, a).contains(&b));
3595 assert!(g.topo.neighbors(et, Direction::Out, b).contains(&a));
3596 }
3597 let old = fx.props.get(b, "tags").cloned();
3599 fx.props.set(b, "tags", tags(&["qqq"]));
3600 let mut g = fx.g();
3601 eng.on_node_changed(b, Some(("tags", old)), &mut g);
3602 assert_eq!(g.topo.edge_count(), 0);
3603 assert_eq!(g.edge_props.get(et, a, b, "score"), None);
3604 }
3605
3606 #[test]
3607 fn key_match_new_node_links_and_rebuild_is_noop() {
3608 let mut fx = Fx::new();
3609 fx.add("C", "c1", vec![]);
3610 let mut eng = RuleEngine::new();
3611 {
3612 let mut g = fx.g();
3613 eng.create_rule(
3614 RuleDef {
3615 name: "fk".into(),
3616 src_label: "T".into(),
3617 dst_label: "C".into(),
3618 predicate: Predicate::KeyMatch {
3619 field: "cid".into(),
3620 },
3621 edge_type: "AT".into(),
3622 weight_prop: None,
3623 max_edges: None,
3624 approximate: false,
3625 via_label: None,
3626 via_edge: None,
3627 via_dir: None,
3628 },
3629 &mut g,
3630 )
3631 .unwrap();
3632 }
3633 let t = fx.add("T", "t1", vec![("cid", Value::Str("c1".into()))]);
3634 let (at, c1, count_before) = {
3635 let mut g = fx.g();
3636 eng.on_node_changed(t, None, &mut g);
3637 let at = g.syms.get("AT").unwrap();
3638 let c1 = g.ids.get("c1").unwrap();
3639 assert!(g.topo.neighbors(at, Direction::Out, t).contains(&c1));
3640 (at, c1, g.topo.edge_count())
3641 };
3642 let mut g = fx.g();
3643 eng.rebuild("fk", &mut g).unwrap();
3644 assert_eq!(g.topo.edge_count(), count_before); assert!(g.topo.neighbors(at, Direction::Out, t).contains(&c1));
3646 }
3647
3648 #[test]
3649 fn score_refresh_on_persisting_owned_edge() {
3650 let mut fx = Fx::new();
3653 let a = fx.add("A", "a", vec![("tags", tags(&["x", "y", "z"]))]);
3654 let b = fx.add("A", "b", vec![("tags", tags(&["x", "y", "q"]))]);
3655 let et = fx.syms.intern("SIM");
3656 let mut eng = RuleEngine::new();
3657 {
3658 let mut g = fx.g();
3659 eng.create_rule(
3660 RuleDef {
3661 name: "sim".into(),
3662 src_label: "A".into(),
3663 dst_label: "A".into(),
3664 predicate: Predicate::Overlap {
3665 field: "tags".into(),
3666 min: 0.2,
3667 },
3668 edge_type: "SIM".into(),
3669 weight_prop: Some("score".into()),
3670 max_edges: None,
3671 approximate: false,
3672 via_label: None,
3673 via_edge: None,
3674 via_dir: None,
3675 },
3676 &mut g,
3677 )
3678 .unwrap();
3679 assert!(g.topo.neighbors(et, Direction::Out, a).contains(&b));
3681 assert!(g.topo.neighbors(et, Direction::Out, b).contains(&a));
3682 assert!(eng.is_owned(et, a, b) || eng.is_owned(et, b, a));
3683 let check = |v: Option<&Value>| {
3684 if let Some(Value::Float(f)) = v {
3685 assert!(
3686 (f - 0.5).abs() < 1e-9,
3687 "initial score should be 0.5, got {f}"
3688 );
3689 }
3690 };
3691 check(g.edge_props.get(et, a, b, "score"));
3692 check(g.edge_props.get(et, b, a, "score"));
3693 }
3694 let old = fx.props.get(b, "tags").cloned();
3696 fx.props.set(b, "tags", tags(&["x", "y", "z"]));
3697 {
3698 let mut g = fx.g();
3699 eng.on_node_changed(b, Some(("tags", old)), &mut g);
3700 assert!(g.topo.neighbors(et, Direction::Out, a).contains(&b));
3702 assert!(g.topo.neighbors(et, Direction::Out, b).contains(&a));
3703 assert_eq!(
3705 g.edge_props.get(et, a, b, "score"),
3706 Some(&Value::Float(1.0)),
3707 "score on a→b must refresh to 1.0"
3708 );
3709 assert_eq!(
3710 g.edge_props.get(et, b, a, "score"),
3711 Some(&Value::Float(1.0)),
3712 "score on b→a must refresh to 1.0"
3713 );
3714 }
3715 }
3716
3717 #[test]
3718 fn dst_side_keymatch_links_when_c_node_inserted_after_t() {
3719 let mut fx = Fx::new();
3721 let t = fx.add("T", "t1", vec![("cid", Value::Str("c9".into()))]);
3723 let mut eng = RuleEngine::new();
3724 {
3725 let mut g = fx.g();
3726 eng.create_rule(
3727 RuleDef {
3728 name: "fk".into(),
3729 src_label: "T".into(),
3730 dst_label: "C".into(),
3731 predicate: Predicate::KeyMatch {
3732 field: "cid".into(),
3733 },
3734 edge_type: "AT".into(),
3735 weight_prop: None,
3736 max_edges: None,
3737 approximate: false,
3738 via_label: None,
3739 via_edge: None,
3740 via_dir: None,
3741 },
3742 &mut g,
3743 )
3744 .unwrap();
3745 let at = g.syms.intern("AT");
3747 assert_eq!(g.topo.edge_count(), 0, "no C node yet → no edge");
3748 let _ = at;
3750 }
3751 let c9 = fx.add("C", "c9", vec![]);
3753 {
3754 let mut g = fx.g();
3755 eng.on_node_changed(c9, None, &mut g);
3756 let at = g.syms.get("AT").unwrap();
3757 assert!(
3759 g.topo.neighbors(at, Direction::Out, t).contains(&c9),
3760 "T→C edge must appear when C node is inserted"
3761 );
3762 assert!(eng.is_owned(at, t, c9));
3763 }
3764 }
3765
3766 #[test]
3767 fn on_node_removed_retracts_both_sides_and_deindexes() {
3768 let mut fx = Fx::new();
3769 let a = fx.add("A", "a", vec![("tags", tags(&["x", "y"]))]);
3770 let b = fx.add("A", "b", vec![("tags", tags(&["x", "y"]))]);
3771 let et = fx.syms.intern("REL");
3772 let mut eng = RuleEngine::new();
3773 {
3774 let mut g = fx.g();
3775 eng.create_rule(overlap_rule(), &mut g).unwrap();
3776 assert!(g.topo.neighbors(et, Direction::Out, a).contains(&b));
3777 assert!(g.topo.neighbors(et, Direction::Out, b).contains(&a));
3778 }
3779 {
3780 let mut g = fx.g();
3781 eng.on_node_removed(a, &mut g);
3782 assert!(!g.topo.neighbors(et, Direction::Out, a).contains(&b));
3783 assert!(!g.topo.neighbors(et, Direction::Out, b).contains(&a));
3784 assert_eq!(g.edge_props.get(et, a, b, "score"), None);
3785 assert_eq!(g.edge_props.get(et, b, a, "score"), None);
3786 assert!(!eng.is_owned(et, a, b));
3787 assert!(!eng.is_owned(et, b, a));
3788 }
3789 let c = fx.add("A", "c", vec![("tags", tags(&["x", "y"]))]);
3791 {
3792 let mut g = fx.g();
3793 eng.on_node_changed(c, None, &mut g);
3794 assert!(g.topo.neighbors(et, Direction::Out, b).contains(&c));
3795 assert!(g.topo.neighbors(et, Direction::Out, c).contains(&b));
3796 assert!(!g.topo.neighbors(et, Direction::Out, c).contains(&a));
3797 assert!(!g.topo.neighbors(et, Direction::Out, a).contains(&c));
3798 }
3799 {
3801 let mut g = fx.g();
3802 eng.on_node_removed(a, &mut g);
3803 assert!(g.topo.neighbors(et, Direction::Out, b).contains(&c));
3804 }
3805 }
3806
3807 #[test]
3808 fn duplicate_name_and_unknown_delete_error() {
3809 let mut fx = Fx::new();
3810 let mut eng = RuleEngine::new();
3811 let mut g = fx.g();
3812 eng.create_rule(overlap_rule(), &mut g).unwrap();
3813 assert!(eng.create_rule(overlap_rule(), &mut g).is_err());
3814 assert!(eng.delete_rule("nope", &mut g).is_err());
3815 }
3816
3817 #[test]
3823 fn coowned_edge_type_survives_first_delete_gone_after_second() {
3824 let mut fx = Fx::new();
3825 let a = fx.add("A", "a", vec![("tags", tags(&["x", "y"]))]);
3826 let b = fx.add("A", "b", vec![("tags", tags(&["x", "y"]))]);
3827 let mut eng = RuleEngine::new();
3828 {
3829 let mut g = fx.g();
3830 eng.create_rule(
3832 RuleDef {
3833 name: "r1".into(),
3834 src_label: "A".into(),
3835 dst_label: "A".into(),
3836 predicate: Predicate::Overlap {
3837 field: "tags".into(),
3838 min: 0.1,
3839 },
3840 edge_type: "REL2".into(),
3841 weight_prop: None,
3842 max_edges: None,
3843 approximate: false,
3844 via_label: None,
3845 via_edge: None,
3846 via_dir: None,
3847 },
3848 &mut g,
3849 )
3850 .unwrap();
3851 eng.create_rule(
3853 RuleDef {
3854 name: "r2".into(),
3855 src_label: "A".into(),
3856 dst_label: "A".into(),
3857 predicate: Predicate::Overlap {
3858 field: "tags".into(),
3859 min: 0.2,
3860 },
3861 edge_type: "REL2".into(),
3862 weight_prop: None,
3863 max_edges: None,
3864 approximate: false,
3865 via_label: None,
3866 via_edge: None,
3867 via_dir: None,
3868 },
3869 &mut g,
3870 )
3871 .unwrap();
3872
3873 let et = g.syms.intern("REL2");
3874 assert!(
3876 g.topo.neighbors(et, Direction::Out, a).contains(&b),
3877 "a→b must exist after both rules created"
3878 );
3879 assert!(
3880 g.topo.neighbors(et, Direction::Out, b).contains(&a),
3881 "b→a must exist after both rules created"
3882 );
3883
3884 eng.delete_rule("r1", &mut g).unwrap();
3886 assert!(
3887 g.topo.neighbors(et, Direction::Out, a).contains(&b),
3888 "a→b must survive R1 deletion (R2 rebuilds and claims it)"
3889 );
3890 assert!(
3891 g.topo.neighbors(et, Direction::Out, b).contains(&a),
3892 "b→a must survive R1 deletion (R2 rebuilds and claims it)"
3893 );
3894 assert!(
3896 eng.is_owned(et, a, b),
3897 "a→b must be owned by R2 after rebuild"
3898 );
3899 assert!(
3900 eng.is_owned(et, b, a),
3901 "b→a must be owned by R2 after rebuild"
3902 );
3903
3904 eng.delete_rule("r2", &mut g).unwrap();
3906 assert!(
3907 !g.topo.neighbors(et, Direction::Out, a).contains(&b),
3908 "a→b must be gone after both rules deleted"
3909 );
3910 assert!(
3911 !g.topo.neighbors(et, Direction::Out, b).contains(&a),
3912 "b→a must be gone after both rules deleted"
3913 );
3914 }
3915 }
3916
3917 fn topk_eq_rule(k: u64) -> RuleDef {
3919 RuleDef {
3920 name: "eq".into(),
3921 src_label: "N".into(),
3922 dst_label: "N".into(),
3923 predicate: Predicate::FieldEqual { field: "k".into() },
3924 edge_type: "EQ".into(),
3925 weight_prop: None,
3926 max_edges: Some(k),
3927 approximate: false,
3928 via_label: None,
3929 via_edge: None,
3930 via_dir: None,
3931 }
3932 }
3933
3934 fn prov_pairs(eng: &RuleEngine, name: &str) -> BTreeSet<(u32, u32)> {
3935 eng.provenance()
3936 .get(name)
3937 .map(|s| s.iter().map(|&(_, a, b)| (a, b)).collect())
3938 .unwrap_or_default()
3939 }
3940
3941 #[test]
3945 fn topk_k1_keeps_best_scored_dst() {
3946 let mut fx = Fx::new();
3947 let mut eng = RuleEngine::new();
3948 {
3949 let mut g = fx.g();
3950 eng.create_rule(topk_eq_rule(1), &mut g).unwrap();
3951 }
3952 let mut ids = Vec::new();
3954 for i in 0..4usize {
3955 let id = fx.add(
3956 "N",
3957 &format!("n{i}"),
3958 vec![("k", Value::Str("const".into()))],
3959 );
3960 ids.push(id);
3961 let mut g = fx.g();
3962 eng.on_node_changed(id, None, &mut g);
3963 }
3964 let et = fx.syms.get("EQ").unwrap();
3965 let expected_dsts = [ids[1], ids[0], ids[0], ids[0]];
3971 for (i, (&src, &expected_dst)) in ids.iter().zip(expected_dsts.iter()).enumerate() {
3972 let out: Vec<u32> = fx.topo.neighbors(et, Direction::Out, src).to_vec();
3973 assert_eq!(
3974 out,
3975 vec![expected_dst],
3976 "src n{i} should point only to the best dst"
3977 );
3978 }
3979 assert_eq!(eng.provenance()["eq"].len(), 4);
3980 assert!(!eng.is_tripped("eq"), "top-k rules never trip");
3981 }
3982
3983 #[test]
3986 fn topk_insert_evict() {
3987 let mut fx = Fx::new();
3991 let rule = RuleDef {
3992 name: "nw".into(),
3993 src_label: "S".into(),
3994 dst_label: "D".into(),
3995 predicate: Predicate::NumericWithin {
3996 field: "v".into(),
3997 tolerance: 10.0,
3998 },
3999 edge_type: "NEAR".into(),
4000 weight_prop: Some("score".into()),
4001 max_edges: Some(1),
4002 approximate: false,
4003 via_label: None,
4004 via_edge: None,
4005 via_dir: None,
4006 };
4007 let mut eng = RuleEngine::new();
4008 {
4009 let mut g = fx.g();
4010 eng.create_rule(rule, &mut g).unwrap();
4011 }
4012
4013 let s0 = fx.add("S", "s0", vec![("v", Value::Float(0.0))]);
4015 let d_far = fx.add("D", "d_far", vec![("v", Value::Float(9.0))]);
4017 {
4018 let mut g = fx.g();
4019 eng.on_node_changed(s0, None, &mut g);
4020 eng.on_node_changed(d_far, None, &mut g);
4021 }
4022 let et = fx.syms.get("NEAR").unwrap();
4023 assert!(fx.topo.neighbors(et, Direction::Out, s0).contains(&d_far));
4025 assert_eq!(eng.provenance()["nw"].len(), 1);
4026
4027 let d_close = fx.add("D", "d_close", vec![("v", Value::Float(1.0))]);
4029 {
4030 let mut g = fx.g();
4031 eng.on_node_changed(d_close, None, &mut g);
4032 }
4033 let out: Vec<u32> = fx.topo.neighbors(et, Direction::Out, s0).to_vec();
4035 assert_eq!(out, vec![d_close], "d_close should evict d_far");
4036 assert!(!fx.topo.neighbors(et, Direction::Out, s0).contains(&d_far));
4037 assert_eq!(eng.provenance()["nw"].len(), 1);
4038 assert!(eng.by_node_consistent());
4039 }
4040
4041 #[test]
4043 fn topk_retract_backfill() {
4044 let mut fx = Fx::new();
4045 let rule = RuleDef {
4046 name: "nw".into(),
4047 src_label: "S".into(),
4048 dst_label: "D".into(),
4049 predicate: Predicate::NumericWithin {
4050 field: "v".into(),
4051 tolerance: 10.0,
4052 },
4053 edge_type: "NEAR".into(),
4054 weight_prop: Some("score".into()),
4055 max_edges: Some(1),
4056 approximate: false,
4057 via_label: None,
4058 via_edge: None,
4059 via_dir: None,
4060 };
4061 let mut eng = RuleEngine::new();
4062
4063 let s0 = fx.add("S", "s0", vec![("v", Value::Float(0.0))]);
4064 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))]); {
4067 let mut g = fx.g();
4068 eng.create_rule(rule, &mut g).unwrap();
4069 }
4070 let et = fx.syms.get("NEAR").unwrap();
4071 assert!(fx.topo.neighbors(et, Direction::Out, s0).contains(&d_close));
4073 assert!(!fx.topo.neighbors(et, Direction::Out, s0).contains(&d_far));
4074 assert_eq!(eng.provenance()["nw"].len(), 1);
4075
4076 let old = fx.props.get(d_close, "v").cloned();
4078 fx.props.set(d_close, "v", Value::Float(50.0));
4079 {
4080 let mut g = fx.g();
4081 eng.on_node_changed(d_close, Some(("v", old)), &mut g);
4082 }
4083 assert!(!fx.topo.neighbors(et, Direction::Out, s0).contains(&d_close));
4085 assert!(
4086 fx.topo.neighbors(et, Direction::Out, s0).contains(&d_far),
4087 "d_far should backfill after d_close retracted"
4088 );
4089 assert_eq!(eng.provenance()["nw"].len(), 1);
4090 assert!(eng.by_node_consistent());
4091 }
4092
4093 #[test]
4095 fn topk_tie_broken_by_dst_key() {
4096 let mut fx = Fx::new();
4098 let mut eng = RuleEngine::new();
4099 {
4100 let mut g = fx.g();
4101 eng.create_rule(topk_eq_rule(2), &mut g).unwrap();
4102 }
4103 for name in ["a", "b", "c", "d", "e"] {
4106 let id = fx.add("N", name, vec![("k", Value::Str("x".into()))]);
4107 let mut g = fx.g();
4108 eng.on_node_changed(id, None, &mut g);
4109 }
4110 let et = fx.syms.get("EQ").unwrap();
4111 let get_id = |key: &str| fx.ids.get(key).unwrap();
4112 let a = get_id("a");
4114 let b = get_id("b");
4115 let c = get_id("c");
4116 let out_a: BTreeSet<u32> = fx
4117 .topo
4118 .neighbors(et, Direction::Out, a)
4119 .iter()
4120 .copied()
4121 .collect();
4122 assert!(out_a.contains(&b), "a→b (b is best key after a)");
4123 assert!(out_a.contains(&c), "a→c (c is 2nd best key)");
4124 assert_eq!(out_a.len(), 2);
4125 let e = get_id("e");
4127 let out_e: BTreeSet<u32> = fx
4128 .topo
4129 .neighbors(et, Direction::Out, e)
4130 .iter()
4131 .copied()
4132 .collect();
4133 assert!(out_e.contains(&a), "e→a");
4134 assert!(out_e.contains(&b), "e→b");
4135 assert_eq!(out_e.len(), 2);
4136 assert!(eng.by_node_consistent());
4137 }
4138
4139 #[test]
4141 fn topk_k_larger_than_candidate_count() {
4142 let mut fx = Fx::new();
4143 let mut eng = RuleEngine::new();
4144 {
4145 let mut g = fx.g();
4146 eng.create_rule(topk_eq_rule(100), &mut g).unwrap();
4148 }
4149 for i in 0..4usize {
4150 let id = fx.add("N", &format!("n{i}"), vec![("k", Value::Str("c".into()))]);
4151 let mut g = fx.g();
4152 eng.on_node_changed(id, None, &mut g);
4153 }
4154 assert_eq!(eng.provenance()["eq"].len(), 12);
4156 assert!(!eng.is_tripped("eq"));
4157 }
4158
4159 #[test]
4162 fn topk_rebuild_exact() {
4163 let mut fx = Fx::new();
4164 let mut eng = RuleEngine::new();
4165 {
4166 let mut g = fx.g();
4167 eng.create_rule(topk_eq_rule(1), &mut g).unwrap();
4168 }
4169 let _a = fx.add("N", "a", vec![("k", Value::Str("x".into()))]);
4171 let _b = fx.add("N", "b", vec![("k", Value::Str("x".into()))]);
4172 let _c = fx.add("N", "c", vec![("k", Value::Str("x".into()))]);
4173 {
4174 let mut g = fx.g();
4175 eng.on_node_changed(_a, None, &mut g);
4176 eng.on_node_changed(_b, None, &mut g);
4177 eng.on_node_changed(_c, None, &mut g);
4178 }
4179 assert_eq!(eng.provenance()["eq"].len(), 3);
4180
4181 {
4183 let mut g = fx.g();
4184 eng.rebuild("eq", &mut g).unwrap();
4185 }
4186 assert_eq!(eng.provenance()["eq"].len(), 3);
4187 assert!(!eng.is_tripped("eq"));
4188 assert!(eng.by_node_consistent());
4189 }
4190
4191 #[test]
4193 fn topk_by_node_consistent() {
4194 let mut fx = Fx::new();
4195 let mut eng = RuleEngine::new();
4196 {
4197 let mut g = fx.g();
4198 eng.create_rule(topk_eq_rule(2), &mut g).unwrap();
4199 }
4200 for i in 0..5usize {
4201 let id = fx.add(
4202 "N",
4203 &format!("n{i}"),
4204 vec![("k", Value::Str("const".into()))],
4205 );
4206 let mut g = fx.g();
4207 eng.on_node_changed(id, None, &mut g);
4208 }
4209 assert!(eng.by_node_consistent(), "consistent after insertions");
4210
4211 let id2 = fx.ids.get("n2").unwrap();
4213 let old = fx.props.get(id2, "k").cloned();
4214 fx.props.set(id2, "k", Value::Str("other".into()));
4215 {
4216 let mut g = fx.g();
4217 eng.on_node_changed(id2, Some(("k", old)), &mut g);
4218 }
4219 assert!(eng.by_node_consistent(), "consistent after eviction");
4220
4221 {
4222 let mut g = fx.g();
4223 eng.rebuild("eq", &mut g).unwrap();
4224 }
4225 assert!(eng.by_node_consistent(), "consistent after rebuild");
4226 }
4227
4228 fn numeric_rule() -> RuleDef {
4229 RuleDef {
4230 name: "nw".into(),
4231 src_label: "C".into(),
4232 dst_label: "C".into(),
4233 predicate: Predicate::NumericWithin {
4234 field: "year".into(),
4235 tolerance: 2.0,
4236 },
4237 edge_type: "NEAR".into(),
4238 weight_prop: Some("score".into()),
4239 max_edges: None,
4240 approximate: false,
4241 via_label: None,
4242 via_edge: None,
4243 via_dir: None,
4244 }
4245 }
4246
4247 fn geo_rule() -> RuleDef {
4248 RuleDef {
4249 name: "geo".into(),
4250 src_label: "City".into(),
4251 dst_label: "City".into(),
4252 predicate: Predicate::GeoRadius {
4253 field: "loc".into(),
4254 km: 400.0,
4255 },
4256 edge_type: "NEAR_GEO".into(),
4257 weight_prop: Some("score".into()),
4258 max_edges: None,
4259 approximate: false,
4260 via_label: None,
4261 via_edge: None,
4262 via_dir: None,
4263 }
4264 }
4265
4266 fn vec_rule() -> RuleDef {
4267 RuleDef {
4268 name: "vec".into(),
4269 src_label: "Doc".into(),
4270 dst_label: "Doc".into(),
4271 predicate: Predicate::VectorSimilar {
4272 field: "emb".into(),
4273 min: 0.9,
4274 },
4275 edge_type: "SIM".into(),
4276 weight_prop: Some("score".into()),
4277 max_edges: None,
4278 approximate: false,
4279 via_label: None,
4280 via_edge: None,
4281 via_dir: None,
4282 }
4283 }
4284
4285 fn pair_edges(topo: &Topology, et: u32, a: u32, b: u32) -> bool {
4286 topo.neighbors(et, Direction::Out, a).contains(&b)
4287 && topo.neighbors(et, Direction::Out, b).contains(&a)
4288 }
4289
4290 #[test]
4291 fn numeric_within_incremental_crosses_bucket_and_clears_old_index() {
4292 let mut fx = Fx::new();
4293 let a = fx.add("C", "a", vec![("year", Value::Float(10.0))]);
4294 let b = fx.add("C", "b", vec![("year", Value::Float(12.0))]);
4295 let et = fx.syms.intern("NEAR");
4296 let mut eng = RuleEngine::new();
4297 {
4298 let mut g = fx.g();
4299 eng.create_rule(numeric_rule(), &mut g).unwrap();
4300 assert!(pair_edges(g.topo, et, a, b));
4302 }
4303
4304 let old = fx.props.get(b, "year").cloned();
4307 fx.props.set(b, "year", Value::Float(16.1));
4308 {
4309 let mut g = fx.g();
4310 eng.on_node_changed(b, Some(("year", old)), &mut g);
4311 assert!(!pair_edges(g.topo, et, a, b));
4312 assert_eq!(g.topo.edge_count(), 0);
4313 }
4314 let def = numeric_rule();
4315 let spec = candidate_spec_for(&def);
4316 let old_map: std::collections::HashMap<_, _> =
4317 [("year".to_string(), Value::Float(12.0))].into();
4318 let old_get = |f: &str| old_map.get(f).cloned();
4319 let src_hits = eng.indexes["nw"].src_side.candidates(&spec, &old_get);
4320 let dst_hits = eng.indexes["nw"].dst_side.candidates(&spec, &old_get);
4321 assert!(!src_hits.contains(&b), "old src bucket must drop b");
4322 assert!(!dst_hits.contains(&b), "old dst bucket must drop b");
4323 assert!(src_hits.contains(&a));
4324
4325 let old = fx.props.get(b, "year").cloned();
4327 fx.props.set(b, "year", Value::Float(11.9));
4328 let mut g = fx.g();
4329 eng.on_node_changed(b, Some(("year", old)), &mut g);
4330 assert!(pair_edges(g.topo, et, a, b));
4331 }
4332
4333 fn loc_val(lat: f64, lon: f64) -> Value {
4334 Value::List(vec![Value::Float(lat), Value::Float(lon)])
4335 }
4336
4337 fn emb_val(vals: &[f64]) -> Value {
4338 Value::List(vals.iter().copied().map(Value::Float).collect())
4339 }
4340
4341 #[test]
4342 fn rebuild_is_noop_for_numeric_geo_and_vector() {
4343 let mut fx = Fx::new();
4344 let ca = fx.add("C", "ca", vec![("year", Value::Int(1998))]);
4345 let cb = fx.add("C", "cb", vec![("year", Value::Float(2000.0))]);
4346 let pa = fx.add("City", "paris", vec![("loc", loc_val(48.8566, 2.3522))]);
4347 let lo = fx.add("City", "london", vec![("loc", loc_val(51.5074, -0.1278))]);
4348 let da = fx.add("Doc", "d1", vec![("emb", emb_val(&[1.0, 0.0]))]);
4349 let db = fx.add("Doc", "d2", vec![("emb", emb_val(&[1.0, 0.0]))]);
4350
4351 let mut eng = RuleEngine::new();
4352 {
4353 let mut g = fx.g();
4354 eng.create_rule(numeric_rule(), &mut g).unwrap();
4355 eng.create_rule(geo_rule(), &mut g).unwrap();
4356 eng.create_rule(vec_rule(), &mut g).unwrap();
4357 }
4358
4359 let (near, ngeo, sim) = (
4360 fx.syms.get("NEAR").unwrap(),
4361 fx.syms.get("NEAR_GEO").unwrap(),
4362 fx.syms.get("SIM").unwrap(),
4363 );
4364 assert!(pair_edges(&fx.topo, near, ca, cb));
4365 assert!(pair_edges(&fx.topo, ngeo, pa, lo));
4366 assert!(pair_edges(&fx.topo, sim, da, db));
4367 let before = fx.topo.edge_count();
4368
4369 {
4370 let mut g = fx.g();
4371 eng.rebuild("nw", &mut g).unwrap();
4372 eng.rebuild("geo", &mut g).unwrap();
4373 eng.rebuild("vec", &mut g).unwrap();
4374 }
4375 assert_eq!(fx.topo.edge_count(), before);
4376 assert!(pair_edges(&fx.topo, near, ca, cb));
4377 assert!(pair_edges(&fx.topo, ngeo, pa, lo));
4378 assert!(pair_edges(&fx.topo, sim, da, db));
4379 }
4380
4381 fn fk_rule() -> RuleDef {
4382 RuleDef {
4383 name: "works_at".into(),
4384 src_label: "T".into(),
4385 dst_label: "C".into(),
4386 predicate: Predicate::KeyMatch {
4387 field: "cid".into(),
4388 },
4389 edge_type: "AT".into(),
4390 weight_prop: None,
4391 max_edges: None,
4392 approximate: false,
4393 via_label: None,
4394 via_edge: None,
4395 via_dir: None,
4396 }
4397 }
4398
4399 #[test]
4400 fn by_node_matches_rebuild_after_mutation_storm() {
4401 let mut fx = Fx::new();
4402 let hub = fx.add("C", "hub", vec![]);
4403 let other = fx.add("C", "other", vec![]);
4404 let mut people = Vec::new();
4405 for i in 0..40 {
4406 let cid = if i < 30 { "hub" } else { "other" };
4407 people.push(fx.add(
4408 "T",
4409 &format!("t{i}"),
4410 vec![("cid", Value::Str(cid.into())), ("tags", tags(&["x", "y"]))],
4411 ));
4412 }
4413 let mut overlap = overlap_rule();
4414 overlap.src_label = "T".into();
4415 overlap.dst_label = "T".into();
4416 let mut eng = RuleEngine::new();
4417 {
4418 let mut g = fx.g();
4419 eng.create_rule(fk_rule(), &mut g).unwrap();
4420 eng.create_rule(overlap, &mut g).unwrap();
4421 }
4422 assert!(eng.by_node_consistent());
4423 assert_eq!(eng.provenance_touching_len(hub), 30);
4424
4425 for (i, &id) in people.iter().enumerate().take(15) {
4427 let old = fx.props.get(id, "cid").cloned();
4428 fx.props.set(id, "cid", Value::Str("other".into()));
4429 let mut g = fx.g();
4430 eng.on_node_changed(id, Some(("cid", old)), &mut g);
4431 assert!(
4432 eng.by_node_consistent(),
4433 "inconsistent after cid update {i}"
4434 );
4435 }
4436 for &id in people.iter().take(8) {
4437 let old = fx.props.get(id, "tags").cloned();
4438 fx.props.set(id, "tags", tags(&["q"]));
4439 let mut g = fx.g();
4440 eng.on_node_changed(id, Some(("tags", old)), &mut g);
4441 }
4442 assert!(eng.by_node_consistent());
4443
4444 {
4446 let mut g = fx.g();
4447 eng.on_node_removed(people[0], &mut g);
4448 }
4449 fx.labels[people[0] as usize] = u32::MAX;
4450 assert!(eng.by_node_consistent());
4451 assert_eq!(eng.provenance_touching_len(people[0]), 0);
4452
4453 {
4454 let mut g = fx.g();
4455 eng.rebuild("works_at", &mut g).unwrap();
4456 eng.rebuild("rel", &mut g).unwrap();
4457 }
4458 assert!(eng.by_node_consistent());
4459
4460 {
4461 let mut g = fx.g();
4462 eng.delete_rule("rel", &mut g).unwrap();
4463 }
4464 assert!(eng.by_node_consistent());
4465 assert_eq!(eng.provenance_touching(people[1]).count(), 1);
4466
4467 let (defs, prov, tripped, fires) = eng.to_persist();
4469 let restored = RuleEngine::from_persist(defs, prov, tripped, fires);
4470 assert!(restored.by_node_consistent());
4471 assert_eq!(
4472 restored.provenance_touching_len(hub),
4473 eng.provenance_touching_len(hub)
4474 );
4475 assert_eq!(
4476 restored.provenance_touching_len(other),
4477 eng.provenance_touching_len(other)
4478 );
4479 }
4480
4481 #[test]
4482 fn provenance_touching_high_degree_hub() {
4483 let mut fx = Fx::new();
4484 let hub = fx.add("C", "hub", vec![]);
4485 let mut first = None;
4486 for i in 0..256 {
4487 let id = fx.add(
4488 "T",
4489 &format!("t{i}"),
4490 vec![("cid", Value::Str("hub".into()))],
4491 );
4492 if first.is_none() {
4493 first = Some(id);
4494 }
4495 }
4496 let first = first.unwrap();
4497 let mut eng = RuleEngine::new();
4498 {
4499 let mut g = fx.g();
4500 eng.create_rule(fk_rule(), &mut g).unwrap();
4501 }
4502 assert!(eng.by_node_consistent());
4503 assert_eq!(eng.provenance_touching_len(hub), 256);
4504 assert_eq!(eng.provenance_touching_len(first), 1);
4505 let hits: Vec<_> = eng.provenance_touching(first).collect();
4506 assert_eq!(hits.len(), 1);
4507 assert_eq!(hits[0].0, "works_at");
4508 assert_eq!(hits[0].2, first);
4509 assert_eq!(hits[0].3, hub);
4510 }
4511
4512 #[test]
4520 fn by_node_consistent_across_inserts_and_rebuild() {
4521 let mut fx = Fx::new();
4522 let mut eng = RuleEngine::new();
4523 let rule = RuleDef {
4524 name: "eq".into(),
4525 src_label: "N".into(),
4526 dst_label: "N".into(),
4527 predicate: Predicate::FieldEqual { field: "k".into() },
4528 edge_type: "EQ".into(),
4529 weight_prop: None,
4530 max_edges: None, approximate: false,
4532 via_label: None,
4533 via_edge: None,
4534 via_dir: None,
4535 };
4536 {
4537 let mut g = fx.g();
4538 eng.create_rule(rule, &mut g).unwrap();
4539 }
4540 let mut ids = Vec::new();
4541 for i in 0..6 {
4542 let id = fx.add(
4543 "N",
4544 &format!("n{i}"),
4545 vec![("k", Value::Str("const".into()))],
4546 );
4547 ids.push(id);
4548 let mut g = fx.g();
4549 eng.on_node_changed(id, None, &mut g);
4550 }
4551 assert_eq!(eng.provenance()["eq"].len(), 30);
4553 assert!(!eng.is_tripped("eq"));
4554 assert!(eng.by_node_consistent(), "consistent after insertions");
4555
4556 let old = fx.props.get(ids[3], "k").cloned();
4558 fx.props.set(ids[3], "k", Value::Str("other".into()));
4559 {
4560 let mut g = fx.g();
4561 eng.on_node_changed(ids[3], Some(("k", old)), &mut g);
4562 }
4563 assert!(eng.by_node_consistent(), "consistent after property change");
4564
4565 {
4566 let mut g = fx.g();
4567 eng.rebuild("eq", &mut g).unwrap();
4568 }
4569 assert!(!eng.is_tripped("eq"));
4570 assert!(eng.by_node_consistent(), "consistent after rebuild");
4571 }
4572
4573 fn mix64(mut x: u64) -> u64 {
4574 x = x.wrapping_add(0x9E3779B97F4A7C15);
4575 x = (x ^ (x >> 30)).wrapping_mul(0xBF58476D1CE4E5B9);
4576 x = (x ^ (x >> 27)).wrapping_mul(0x94D049BB133111EB);
4577 x ^ (x >> 31)
4578 }
4579
4580 fn rand_emb(seed: u64, i: u32, dim: usize) -> Value {
4581 let vals: Vec<f64> = (0..dim)
4582 .map(|d| {
4583 let bits = mix64(seed ^ ((i as u64 + 1).wrapping_mul(0x100000001)) ^ (d as u64));
4584 let mut f = (bits as f64) / (u64::MAX as f64) * 2.0 - 1.0;
4585 if f == 0.0 {
4586 f = 1.0;
4587 }
4588 f
4589 })
4590 .collect();
4591 emb_val(&vals)
4592 }
4593
4594 fn seed_docs(n: u32, seed: u64) -> (Fx, Vec<u32>) {
4595 let dims = [2usize, 3, 4, 8];
4596 let mut fx = Fx::new();
4597 let mut ids = Vec::new();
4598 for i in 0..n {
4599 let dim = dims[(i as usize) % dims.len()];
4600 ids.push(fx.add(
4601 "Doc",
4602 &format!("d{i}"),
4603 vec![("emb", rand_emb(seed, i, dim))],
4604 ));
4605 }
4606 (fx, ids)
4607 }
4608
4609 #[test]
4612 fn vector_dim_reject_matches_unfiltered_and_oracle() {
4613 const N: u32 = 500;
4614 const SEED: u64 = 0xC0FF_EE00_D15C;
4615 let def = vec_rule();
4616
4617 let (mut fx_on, ids) = seed_docs(N, SEED);
4618 let mut eng_on = RuleEngine::new();
4619 {
4620 let mut g = fx_on.g();
4621 eng_on.create_rule(def.clone(), &mut g).unwrap();
4622 }
4623 let on = prov_pairs(&eng_on, "vec");
4624 assert!(!on.is_empty(), "seeded set must produce some edges");
4625
4626 let (mut fx_off, _) = seed_docs(N, SEED);
4627 let mut eng_off = RuleEngine::new();
4628 {
4629 let mut g = fx_off.g();
4630 with_vector_dim_reject(false, || {
4631 eng_off.create_rule(def.clone(), &mut g).unwrap();
4632 });
4633 }
4634 assert_eq!(on, prov_pairs(&eng_off, "vec"), "filter vs no-filter");
4635
4636 let mut brute = BTreeSet::new();
4637 for &s in &ids {
4638 for &d in &ids {
4639 if s == d {
4640 continue;
4641 }
4642 let skey = fx_on.ids.key_of(s).unwrap();
4643 let dkey = fx_on.ids.key_of(d).unwrap();
4644 let sget = |f: &str| fx_on.props.get(s, f).cloned();
4645 let dget = |f: &str| fx_on.props.get(d, f).cloned();
4646 if evaluate(
4647 &def.predicate,
4648 &NodeView {
4649 key: skey,
4650 props: &sget,
4651 },
4652 &NodeView {
4653 key: dkey,
4654 props: &dget,
4655 },
4656 )
4657 .is_some()
4658 {
4659 brute.insert((s, d));
4660 }
4661 }
4662 }
4663 assert_eq!(on, brute, "filter vs brute-force evaluate");
4664 }
4665
4666 #[test]
4669 fn vector_dim_change_updates_cache_and_matches_fresh_build() {
4670 let mut fx = Fx::new();
4671 let a = fx.add("Doc", "a", vec![("emb", emb_val(&[1.0, 0.0]))]);
4672 let b = fx.add("Doc", "b", vec![("emb", emb_val(&[1.0, 0.0]))]);
4673 let c = fx.add("Doc", "c", vec![("emb", emb_val(&[1.0, 0.0, 0.0]))]);
4674 let mut eng = RuleEngine::new();
4675 {
4676 let mut g = fx.g();
4677 eng.create_rule(vec_rule(), &mut g).unwrap();
4678 }
4679 assert_eq!(eng.indexes["vec"].src_side.vec_dim(a), Some(2));
4680 assert_eq!(eng.indexes["vec"].src_side.vec_dim(c), Some(3));
4681 assert_eq!(prov_pairs(&eng, "vec"), BTreeSet::from([(a, b), (b, a)]));
4682
4683 let old = fx.props.get(b, "emb").cloned();
4684 fx.props.set(b, "emb", emb_val(&[1.0, 0.0, 0.0]));
4685 {
4686 let mut g = fx.g();
4687 eng.on_node_changed(b, Some(("emb", old)), &mut g);
4688 }
4689 assert_eq!(eng.indexes["vec"].src_side.vec_dim(b), Some(3));
4690 assert_eq!(eng.indexes["vec"].dst_side.vec_dim(b), Some(3));
4691 let after = prov_pairs(&eng, "vec");
4692 assert_eq!(after, BTreeSet::from([(b, c), (c, b)]));
4693
4694 let mut fresh_fx = Fx::new();
4696 let fa = fresh_fx.add("Doc", "a", vec![("emb", emb_val(&[1.0, 0.0]))]);
4697 let fb = fresh_fx.add("Doc", "b", vec![("emb", emb_val(&[1.0, 0.0, 0.0]))]);
4698 let fc = fresh_fx.add("Doc", "c", vec![("emb", emb_val(&[1.0, 0.0, 0.0]))]);
4699 let mut fresh = RuleEngine::new();
4700 {
4701 let mut g = fresh_fx.g();
4702 fresh.create_rule(vec_rule(), &mut g).unwrap();
4703 }
4704 assert_eq!(
4705 prov_pairs(&fresh, "vec"),
4706 BTreeSet::from([(fb, fc), (fc, fb)])
4707 );
4708 assert_eq!(fresh.indexes["vec"].src_side.vec_dim(fb), Some(3));
4709 assert_eq!(fresh.indexes["vec"].src_side.vec_dim(fa), Some(2));
4710 }
4711
4712 #[test]
4732 fn streaming_topk_order_identity_property_test() {
4733 fn reference_topk(rule: &RuleDef, k: u64, fx: &mut Fx) -> BTreeSet<(u32, u32)> {
4736 let mut idx = RuleIndex::default();
4737 for id in 0..fx.ids.len() as u32 {
4738 let label_sym = match fx.labels.get(id as usize).copied() {
4739 Some(s) if s != u32::MAX => s,
4740 _ => continue,
4741 };
4742 index_node_for_rule(
4743 id,
4744 label_sym,
4745 rule,
4746 &mut idx,
4747 &fx.syms,
4748 ColumnsView::owned(&fx.props),
4749 );
4750 }
4751 let src_sym = fx.syms.get(&rule.src_label);
4752 let mut out = BTreeSet::new();
4753 let ids_snap: Vec<u32> = (0..fx.ids.len() as u32).collect();
4754 for id in ids_snap {
4755 let label_sym = match fx.labels.get(id as usize).copied() {
4756 Some(s) if s != u32::MAX => s,
4757 _ => continue,
4758 };
4759 if src_sym != Some(label_sym) {
4760 continue;
4761 }
4762 let g = GraphMut {
4763 ids: &fx.ids,
4764 syms: &mut fx.syms,
4765 labels: &fx.labels,
4766 props: ColumnsView::owned(&fx.props),
4767 topo: &mut fx.topo,
4768 base_topo: None,
4769 edge_props: &mut fx.eprops,
4770 };
4771 let per_src = compute_desired(rule, &idx, id, true, &g);
4772 let mut candidates: Vec<((u32, u32), f64)> = per_src.into_iter().collect();
4774 candidates.sort_by(|&((_, da), sa), &((_, db), sb)| {
4775 sb.total_cmp(&sa).then_with(|| {
4776 let ka = fx.ids.key_of(da).unwrap_or("");
4777 let kb = fx.ids.key_of(db).unwrap_or("");
4778 ka.cmp(kb)
4779 })
4780 });
4781 candidates.truncate(k as usize);
4782 out.extend(candidates.into_iter().map(|(k, _)| k));
4783 }
4784 out
4785 }
4786
4787 fn streaming_pairs(rule: RuleDef, fx: &mut Fx) -> BTreeSet<(u32, u32)> {
4789 let name = rule.name.clone();
4790 let mut eng = RuleEngine::new();
4791 eng.create_rule(rule, &mut fx.g()).unwrap();
4792 eng.provenance()
4793 .get(&name)
4794 .map(|s| s.iter().map(|&(_, a, b)| (a, b)).collect())
4795 .unwrap_or_default()
4796 }
4797
4798 for seed in [0u64, 1, 42, 0xDEAD_BEEF, 0x1234_5678, 99, 12_648_430, 7] {
4803 for k in [1u64, 2, 3, 5] {
4804 let rule = RuleDef {
4805 name: "eq".into(),
4806 src_label: "N".into(),
4807 dst_label: "N".into(),
4808 predicate: Predicate::FieldEqual { field: "k".into() },
4809 edge_type: "EQ".into(),
4810 weight_prop: None,
4811 max_edges: Some(k),
4812 approximate: false,
4813 via_label: None,
4814 via_edge: None,
4815 via_dir: None,
4816 };
4817
4818 let build = || {
4819 let mut fx = Fx::new();
4820 for i in 0..12u32 {
4821 let h = mix64(seed ^ (i as u64 + 1));
4822 let val = match h % 3 {
4823 0 => "a",
4824 1 => "b",
4825 _ => "c",
4826 };
4827 fx.add(
4828 "N",
4829 &format!("n{i:02}"),
4830 vec![("k", Value::Str(val.into()))],
4831 );
4832 }
4833 fx
4834 };
4835
4836 let expected = reference_topk(&rule, k, &mut build());
4837 let actual = streaming_pairs(rule, &mut build());
4838
4839 assert_eq!(
4840 expected, actual,
4841 "FieldEqual seed={seed} k={k}: streaming top-k must match brute-force top-k"
4842 );
4843 }
4844 }
4845
4846 for seed in [0u64, 1, 42, 7] {
4851 for k in [1u64, 2, 4] {
4852 let rule = RuleDef {
4853 name: "nw".into(),
4854 src_label: "S".into(),
4855 dst_label: "D".into(),
4856 predicate: Predicate::NumericWithin {
4857 field: "v".into(),
4858 tolerance: 10.0,
4859 },
4860 edge_type: "NEAR".into(),
4861 weight_prop: Some("score".into()),
4862 max_edges: Some(k),
4863 approximate: false,
4864 via_label: None,
4865 via_edge: None,
4866 via_dir: None,
4867 };
4868
4869 let build = || {
4870 let mut fx = Fx::new();
4871 for i in 0..6u32 {
4872 let h = mix64(seed ^ (i as u64 + 1));
4873 let v = (h % 20) as f64;
4874 fx.add("S", &format!("s{i}"), vec![("v", Value::Float(v))]);
4875 }
4876 for i in 0..8u32 {
4877 let h = mix64(seed ^ (i as u64 + 101));
4878 let v = (h % 20) as f64;
4879 fx.add("D", &format!("d{i}"), vec![("v", Value::Float(v))]);
4880 }
4881 fx
4882 };
4883
4884 let expected = reference_topk(&rule, k, &mut build());
4885 let actual = streaming_pairs(rule, &mut build());
4886
4887 assert_eq!(
4888 expected, actual,
4889 "NumericWithin seed={seed} k={k}: streaming top-k must match brute-force top-k"
4890 );
4891 }
4892 }
4893
4894 for seed in [0u64, 1, 42, 7] {
4901 for k in [1u64, 2] {
4902 let rule = RuleDef {
4903 name: "fk".into(),
4904 src_label: "T".into(),
4905 dst_label: "C".into(),
4906 predicate: Predicate::KeyMatch {
4907 field: "cid".into(),
4908 },
4909 edge_type: "AT".into(),
4910 weight_prop: None,
4911 max_edges: Some(k),
4912 approximate: false,
4913 via_label: None,
4914 via_edge: None,
4915 via_dir: None,
4916 };
4917
4918 let build = || {
4919 let mut fx = Fx::new();
4920 for i in 0..4u32 {
4922 fx.add("C", &format!("c{i}"), vec![]);
4923 }
4924 for i in 0..8u32 {
4926 let h = mix64(seed ^ (i as u64 + 1));
4927 let cid = format!("c{}", h % 4);
4928 fx.add("T", &format!("t{i}"), vec![("cid", Value::Str(cid))]);
4929 }
4930 fx
4931 };
4932
4933 let expected = reference_topk(&rule, k, &mut build());
4934 let actual = streaming_pairs(rule, &mut build());
4935
4936 assert_eq!(
4937 expected, actual,
4938 "KeyMatch seed={seed} k={k}: streaming top-k must match brute-force top-k"
4939 );
4940 }
4941 }
4942
4943 {
4949 let cluster_a: &[(&str, f64, f64)] = &[
4951 ("va0", 1.0_f64, 0.0_f64),
4952 ("va1", 0.98_f64, 0.199_f64), ("va2", 0.97_f64, 0.243_f64), ];
4955 let cluster_b: &[(&str, f64, f64)] = &[
4956 ("vb0", 0.0_f64, 1.0_f64),
4957 ("vb1", 0.1_f64, 0.995_f64),
4958 ("vb2", 0.05_f64, 0.999_f64),
4959 ];
4960 for k in [1u64, 2] {
4961 let rule = RuleDef {
4962 name: "vsim".into(),
4963 src_label: "V".into(),
4964 dst_label: "V".into(),
4965 predicate: Predicate::VectorSimilar {
4966 field: "emb".into(),
4967 min: 0.9,
4968 },
4969 edge_type: "VSIM".into(),
4970 weight_prop: Some("score".into()),
4971 max_edges: Some(k),
4972 approximate: false,
4973 via_label: None,
4974 via_edge: None,
4975 via_dir: None,
4976 };
4977
4978 let build = || {
4979 let mut fx = Fx::new();
4980 let mut add_v = |key: &str, x: f64, y: f64| {
4981 let norm = (x * x + y * y).sqrt();
4982 let v = Value::List(vec![Value::Float(x / norm), Value::Float(y / norm)]);
4983 fx.add("V", key, vec![("emb", v)]);
4984 };
4985 for &(k, x, y) in cluster_a.iter().chain(cluster_b.iter()) {
4986 add_v(k, x, y);
4987 }
4988 fx
4989 };
4990
4991 let expected = reference_topk(&rule, k, &mut build());
4992 let actual = streaming_pairs(rule, &mut build());
4993
4994 assert_eq!(
4995 expected, actual,
4996 "VectorSimilar/ScanAll k={k}: streaming top-k must match brute-force top-k"
4997 );
4998 }
4999 }
5000 }
5001
5002 #[test]
5028 #[ignore]
5029 fn streaming_peak_transient_bound() {
5030 use std::sync::{
5031 atomic::{AtomicBool, AtomicU64, Ordering},
5032 Arc,
5033 };
5034
5035 fn peak_rss_during<F: FnOnce()>(f: F) -> u64 {
5038 let done = Arc::new(AtomicBool::new(false));
5039 let peak = Arc::new(AtomicU64::new(0));
5040 let done2 = done.clone();
5041 let peak2 = peak.clone();
5042 let pid = std::process::id().to_string();
5043
5044 let handle = std::thread::spawn(move || {
5045 while !done2.load(Ordering::Relaxed) {
5046 let rss = std::process::Command::new("ps")
5047 .args(["-o", "rss=", "-p", &pid])
5048 .output()
5049 .ok()
5050 .and_then(|o| String::from_utf8(o.stdout).ok())
5051 .and_then(|s| s.trim().parse::<u64>().ok())
5052 .unwrap_or(0)
5053 * 1024;
5054 peak2.fetch_max(rss, Ordering::Relaxed);
5055 std::thread::sleep(std::time::Duration::from_millis(1));
5056 }
5057 });
5058
5059 f();
5060
5061 done.store(true, Ordering::Relaxed);
5062 let _ = handle.join();
5063 peak.load(Ordering::Relaxed)
5064 }
5065
5066 let mut fx = Fx::new();
5070 for i in 0..500u32 {
5071 fx.add(
5072 "Talent",
5073 &format!("t{i}"),
5074 vec![("k", Value::Str("same".into()))],
5075 );
5076 }
5077 for i in 0..500u32 {
5078 fx.add(
5079 "Company",
5080 &format!("c{i}"),
5081 vec![("k", Value::Str("same".into()))],
5082 );
5083 }
5084 let rule = RuleDef {
5085 name: "eq_tc".into(),
5086 src_label: "Talent".into(),
5087 dst_label: "Company".into(),
5088 predicate: Predicate::FieldEqual { field: "k".into() },
5089 edge_type: "EQ".into(),
5090 weight_prop: None,
5091 max_edges: Some(2), approximate: false,
5093 via_label: None,
5094 via_edge: None,
5095 via_dir: None,
5096 };
5097
5098 let pid = std::process::id().to_string();
5100 let baseline = std::process::Command::new("ps")
5101 .args(["-o", "rss=", "-p", &pid])
5102 .output()
5103 .ok()
5104 .and_then(|o| String::from_utf8(o.stdout).ok())
5105 .and_then(|s| s.trim().parse::<u64>().ok())
5106 .unwrap_or(0)
5107 * 1024;
5108
5109 let mut eng = RuleEngine::new();
5110 let peak = peak_rss_during(|| {
5111 eng.create_rule(rule, &mut fx.g()).unwrap();
5112 });
5113
5114 let peak_delta = peak.saturating_sub(baseline);
5115
5116 assert!(
5120 peak_delta < 3 * 1024 * 1024,
5121 "peak transient delta {} bytes ({} KiB) exceeded 3 MiB; \
5122 streaming path may be building the full pairs map",
5123 peak_delta,
5124 peak_delta / 1024
5125 );
5126 assert_eq!(eng.provenance()["eq_tc"].len(), 1_000); assert!(!eng.is_tripped("eq_tc")); eprintln!(
5129 "streaming_peak_transient_bound: baseline={baseline} peak={peak} \
5130 delta={peak_delta} bytes ({} KiB)",
5131 peak_delta / 1024
5132 );
5133 }
5134
5135 fn near_threshold_pair(dim: usize, min: f64) -> (Vec<f64>, Vec<f64>) {
5142 let cos_target = min + 1e-6; let sin_small = (1.0 - cos_target * cos_target).sqrt();
5146 let mut a = vec![0.0f64; dim];
5147 a[0] = 1.0;
5148 let mut b = vec![0.0f64; dim];
5149 b[0] = cos_target;
5150 if dim > 1 {
5151 b[1] = sin_small;
5152 }
5153 (a, b)
5154 }
5155
5156 fn emb_val2(xs: &[f64]) -> Value {
5157 Value::List(xs.iter().copied().map(Value::Float).collect())
5158 }
5159
5160 fn make_early_exit_fixture(seed: u64, min: f64) -> (Fx, Vec<u32>, usize, usize) {
5164 let dims = [2usize, 4, 8, 16];
5165 let n = 100u32;
5166 let mut fx = Fx::new();
5167 let mut ids = Vec::new();
5168 for i in 0..n {
5169 let dim = dims[(i as usize) % dims.len()];
5170 let emb = rand_emb(seed, i, dim);
5171 ids.push(fx.add("Doc", &format!("d{i}"), vec![("emb", emb)]));
5172 }
5173 let (va, vb) = near_threshold_pair(8, min);
5175 let nt_a = fx.add("Doc", "nt_a", vec![("emb", emb_val2(&va))]);
5176 let nt_b = fx.add("Doc", "nt_b", vec![("emb", emb_val2(&vb))]);
5177 ids.push(nt_a);
5178 ids.push(nt_b);
5179 (fx, ids, nt_a as usize, nt_b as usize)
5180 }
5181
5182 #[test]
5186 fn vector_early_exit_identity_proof() {
5187 const SEED: u64 = 0xEA_4E_5A;
5188 const MIN: f64 = 0.85;
5189
5190 let def = RuleDef {
5191 name: "vec".into(),
5192 src_label: "Doc".into(),
5193 dst_label: "Doc".into(),
5194 predicate: Predicate::VectorSimilar {
5195 field: "emb".into(),
5196 min: MIN,
5197 },
5198 edge_type: "SIM".into(),
5199 weight_prop: Some("score".into()),
5200 max_edges: None,
5201 approximate: false,
5202 via_label: None,
5203 via_edge: None,
5204 via_dir: None,
5205 };
5206
5207 let (mut fx_on, ids, nt_a, nt_b) = make_early_exit_fixture(SEED, MIN);
5209 let (mut fx_off, _, _, _) = make_early_exit_fixture(SEED, MIN);
5210 let (fx_oracle, _, _, _) = make_early_exit_fixture(SEED, MIN);
5211
5212 let nt_a = nt_a as u32;
5213 let nt_b = nt_b as u32;
5214
5215 let mut eng_on = RuleEngine::new();
5217 {
5218 let mut g = fx_on.g();
5219 eng_on.create_rule(def.clone(), &mut g).unwrap();
5220 }
5221 let edges_on = prov_pairs(&eng_on, "vec");
5222 assert!(!edges_on.is_empty(), "should produce some edges");
5223
5224 assert!(
5226 edges_on.contains(&(nt_a, nt_b)),
5227 "near-threshold pair nt_a→nt_b must match with early-exit ON"
5228 );
5229 assert!(
5230 edges_on.contains(&(nt_b, nt_a)),
5231 "near-threshold pair nt_b→nt_a must match with early-exit ON"
5232 );
5233
5234 let mut eng_off = RuleEngine::new();
5236 {
5237 let mut g = fx_off.g();
5238 with_vector_early_exit(false, || {
5239 eng_off.create_rule(def.clone(), &mut g).unwrap();
5240 });
5241 }
5242 let edges_off = prov_pairs(&eng_off, "vec");
5243 assert_eq!(
5244 edges_on, edges_off,
5245 "early-exit ON vs OFF must produce identical edges"
5246 );
5247
5248 let mut oracle = BTreeSet::new();
5250 for &s in &ids {
5251 for &d in &ids {
5252 if s == d {
5253 continue;
5254 }
5255 let skey = fx_oracle.ids.key_of(s).unwrap();
5256 let dkey = fx_oracle.ids.key_of(d).unwrap();
5257 let sg = |f: &str| fx_oracle.props.get(s, f).cloned();
5258 let dg = |f: &str| fx_oracle.props.get(d, f).cloned();
5259 if evaluate(
5260 &def.predicate,
5261 &NodeView {
5262 key: skey,
5263 props: &sg,
5264 },
5265 &NodeView {
5266 key: dkey,
5267 props: &dg,
5268 },
5269 )
5270 .is_some()
5271 {
5272 oracle.insert((s, d));
5273 }
5274 }
5275 }
5276 assert_eq!(
5277 edges_on, oracle,
5278 "early-exit ON vs brute-force oracle must be identical"
5279 );
5280 }
5281
5282 #[test]
5285 fn vector_early_exit_checkpoint_coherence() {
5286 let mut fx = Fx::new();
5287 let a = fx.add("Doc", "a", vec![("emb", emb_val(&[1.0, 0.0, 0.0, 0.0]))]);
5289 let b = fx.add("Doc", "b", vec![("emb", emb_val(&[1.0, 0.0, 0.0, 0.0]))]);
5290 let c = fx.add(
5292 "Doc",
5293 "c",
5294 vec![("emb", emb_val(&[1.0, 0.0, 0.0, 0.0, 0.0, 0.0]))],
5295 );
5296 let def = RuleDef {
5297 name: "vec".into(),
5298 src_label: "Doc".into(),
5299 dst_label: "Doc".into(),
5300 predicate: Predicate::VectorSimilar {
5301 field: "emb".into(),
5302 min: 0.9,
5303 },
5304 edge_type: "SIM".into(),
5305 weight_prop: None,
5306 max_edges: None,
5307 approximate: false,
5308 via_label: None,
5309 via_edge: None,
5310 via_dir: None,
5311 };
5312
5313 let mut eng = RuleEngine::new();
5314 {
5315 let mut g = fx.g();
5316 eng.create_rule(def.clone(), &mut g).unwrap();
5317 }
5318
5319 assert!(
5321 eng.indexes["vec"].src_side.vec_ckpts(a).is_some(),
5322 "a must have src checkpoints"
5323 );
5324 assert!(
5325 eng.indexes["vec"].dst_side.vec_ckpts(b).is_some(),
5326 "b must have dst checkpoints"
5327 );
5328 assert!(
5329 eng.indexes["vec"].src_side.vec_ckpts(c).is_some(),
5330 "c must have src checkpoints (dim=6)"
5331 );
5332
5333 let ckpts_a = *eng.indexes["vec"].src_side.vec_ckpts(a).unwrap();
5335 let norm_a = eng.indexes["vec"].src_side.vec_meta(a).unwrap().1;
5336 assert!(
5337 (ckpts_a[0] - norm_a).abs() < 1e-12,
5338 "ckpts[0] must equal the full L2 norm"
5339 );
5340
5341 assert_eq!(prov_pairs(&eng, "vec"), BTreeSet::from([(a, b), (b, a)]));
5343
5344 let old_b = fx.props.get(b, "emb").cloned();
5346 fx.props
5347 .set(b, "emb", emb_val(&[1.0, 0.0, 0.0, 0.0, 0.0, 0.0]));
5348 {
5349 let mut g = fx.g();
5350 eng.on_node_changed(b, Some(("emb", old_b)), &mut g);
5351 }
5352 assert_eq!(eng.indexes["vec"].src_side.vec_dim(b), Some(6));
5354 assert_eq!(eng.indexes["vec"].dst_side.vec_dim(b), Some(6));
5355 assert!(eng.indexes["vec"].src_side.vec_ckpts(b).is_some());
5357 assert_eq!(prov_pairs(&eng, "vec"), BTreeSet::from([(b, c), (c, b)]));
5359
5360 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);
5364 assert!(
5365 gate_result.is_none(),
5366 "freshness gate must reject a mismatched-norm live vector"
5367 );
5368
5369 let correct_live = vec![1.0f64, 0.0, 0.0, 0.0, 0.0, 0.0];
5371 let gate_result = eng.indexes["vec"]
5372 .src_side
5373 .fresh_ckpts_for(b, &correct_live);
5374 assert!(
5375 gate_result.is_some(),
5376 "freshness gate must accept the matching live vector"
5377 );
5378 }
5379
5380 #[test]
5389 fn vector_early_exit_razor_dim1536() {
5390 const MIN: f64 = 0.85;
5391 const DIM: usize = 1536;
5392 let target = MIN + 5e-13;
5394 let inv_sqrt = 1.0 / (DIM as f64).sqrt();
5395
5396 let a: Vec<f64> = vec![inv_sqrt; DIM];
5398
5399 let perp_scale = (1.0 - target * target).sqrt() / (2.0f64).sqrt();
5405 let mut b: Vec<f64> = vec![target * inv_sqrt; DIM];
5406 b[0] += perp_scale;
5407 b[1] -= perp_scale;
5408
5409 let def = RuleDef {
5410 name: "razor".into(),
5411 src_label: "Doc".into(),
5412 dst_label: "Doc".into(),
5413 predicate: Predicate::VectorSimilar {
5414 field: "emb".into(),
5415 min: MIN,
5416 },
5417 edge_type: "SIM".into(),
5418 weight_prop: None,
5419 max_edges: None,
5420 approximate: false,
5421 via_label: None,
5422 via_edge: None,
5423 via_dir: None,
5424 };
5425
5426 let build_fx = || {
5428 let mut fx = Fx::new();
5429 let na = fx.add("Doc", "razor_a", vec![("emb", emb_val2(&a))]);
5430 let nb = fx.add("Doc", "razor_b", vec![("emb", emb_val2(&b))]);
5431 (fx, na, nb)
5432 };
5433
5434 let (mut fx_on, na, nb) = build_fx();
5435 let (mut fx_off, _, _) = build_fx();
5436 let (fx_oracle, _, _) = build_fx();
5437
5438 let mut eng_on = RuleEngine::new();
5440 {
5441 let mut g = fx_on.g();
5442 eng_on.create_rule(def.clone(), &mut g).unwrap();
5443 }
5444 let edges_on = prov_pairs(&eng_on, "razor");
5445 assert!(
5446 edges_on.contains(&(na, nb)),
5447 "razor pair razor_a→razor_b must be present with early-exit ON (cos={target:.15}, min={MIN})"
5448 );
5449 assert!(
5450 edges_on.contains(&(nb, na)),
5451 "razor pair razor_b→razor_a must be present with early-exit ON"
5452 );
5453
5454 let mut eng_off = RuleEngine::new();
5456 {
5457 let mut g = fx_off.g();
5458 with_vector_early_exit(false, || {
5459 eng_off.create_rule(def.clone(), &mut g).unwrap();
5460 });
5461 }
5462 let edges_off = prov_pairs(&eng_off, "razor");
5463 assert_eq!(
5464 edges_on, edges_off,
5465 "razor dim=1536: early-exit ON vs OFF must produce identical edges"
5466 );
5467
5468 let ids = [na, nb];
5470 let mut oracle = BTreeSet::new();
5471 for &s in &ids {
5472 for &d in &ids {
5473 if s == d {
5474 continue;
5475 }
5476 let skey = fx_oracle.ids.key_of(s).unwrap();
5477 let dkey = fx_oracle.ids.key_of(d).unwrap();
5478 let sg = |f: &str| fx_oracle.props.get(s, f).cloned();
5479 let dg = |f: &str| fx_oracle.props.get(d, f).cloned();
5480 if evaluate(
5481 &def.predicate,
5482 &NodeView {
5483 key: skey,
5484 props: &sg,
5485 },
5486 &NodeView {
5487 key: dkey,
5488 props: &dg,
5489 },
5490 )
5491 .is_some()
5492 {
5493 oracle.insert((s, d));
5494 }
5495 }
5496 }
5497 assert_eq!(
5498 edges_on, oracle,
5499 "razor dim=1536: early-exit ON vs brute-force oracle must be identical"
5500 );
5501 }
5502
5503 fn field_equal_rule(
5547 src_label: &str,
5548 dst_label: &str,
5549 field: &str,
5550 edge_type: &str,
5551 max_edges: Option<u64>,
5552 ) -> RuleDef {
5553 RuleDef {
5554 name: format!("{src_label}_{dst_label}_{field}"),
5555 src_label: src_label.into(),
5556 dst_label: dst_label.into(),
5557 predicate: Predicate::FieldEqual {
5558 field: field.into(),
5559 },
5560 edge_type: edge_type.into(),
5561 weight_prop: None,
5562 max_edges,
5563 approximate: false,
5564 via_label: None,
5565 via_edge: None,
5566 via_dir: None,
5567 }
5568 }
5569
5570 #[test]
5571 fn backfill_does_not_materialize_the_cross_product() {
5572 use std::sync::atomic::Ordering;
5573 let mut fx = Fx::new();
5578 for i in 0..400u32 {
5579 fx.add(
5580 "Person",
5581 &format!("p{i}"),
5582 vec![("city", Value::Str("austin".into()))],
5583 );
5584 }
5585 for i in 0..400u32 {
5586 fx.add(
5587 "Org",
5588 &format!("o{i}"),
5589 vec![("city", Value::Str("austin".into()))],
5590 );
5591 }
5592
5593 let mut eng = RuleEngine::new();
5594 PEAK_DESIRED_PAIRS.store(0, Ordering::Relaxed);
5595 {
5596 let mut g = fx.g();
5597 eng.create_rule(
5598 field_equal_rule("Person", "Org", "city", "IN_CITY", Some(5)),
5599 &mut g,
5600 )
5601 .unwrap();
5602 }
5603
5604 let edges = fx.topo.edge_count();
5605 assert_eq!(
5606 edges,
5607 400 * 5,
5608 "per-source top-k must yield exactly k per source"
5609 );
5610
5611 let peak = PEAK_DESIRED_PAIRS.load(Ordering::Relaxed);
5612 assert!(
5613 peak <= 400 * 5 * 4, "backfill must not materialize the full cross-product; peak was {peak}"
5615 );
5616 }
5617
5618 #[test]
5634 fn global_budget_backfill_stays_per_source_bounded() {
5635 use std::sync::atomic::Ordering;
5636 let mut fx = Fx::new();
5639 for i in 0..400u32 {
5640 fx.add(
5641 "Person",
5642 &format!("p{i}"),
5643 vec![("city", Value::Str("austin".into()))],
5644 );
5645 }
5646 for i in 0..400u32 {
5647 fx.add(
5648 "Org",
5649 &format!("o{i}"),
5650 vec![("city", Value::Str("austin".into()))],
5651 );
5652 }
5653
5654 let mut eng = RuleEngine::new();
5655 PEAK_DESIRED_PAIRS.store(0, Ordering::Relaxed);
5656 {
5657 let mut g = fx.g();
5658 eng.create_rule(
5659 field_equal_rule("Person", "Org", "city", "IN_CITY", None),
5660 &mut g,
5661 )
5662 .unwrap();
5663 }
5664
5665 let edges = fx.topo.edge_count();
5668 assert_eq!(
5669 edges,
5670 400 * 400,
5671 "none-path must apply all pairs when under budget; got {edges}"
5672 );
5673
5674 let peak = PEAK_DESIRED_PAIRS.load(Ordering::Relaxed);
5678 assert!(
5679 peak <= 400 * 4, "none-path backfill must not accumulate a global desired-map; peak was {peak}"
5681 );
5682 }
5683}