1use crate::def::{evaluate, is_keymatch_rooted, NodeView, Predicate, RuleDef};
2use crate::hnsw::HnswIndex;
3use crate::index::{
4 candidate_spec, candidate_spec_approx_with_k, ivf_drift_rebuild_threshold, CandidateSpec,
5 RuleIndex,
6};
7use core_storage::v8::encode::{decode_ivf_bytes, decode_provenance_bytes};
8use core_storage::v8::seam::ColumnsView;
9use core_storage::{EdgeProps, IdMap, Interner, Topology, Value};
10
11fn decode_ivf_bytes_to_export(bytes: &[u8]) -> BTreeMap<String, RuleIvfExport> {
14 decode_ivf_bytes(bytes)
15 .into_iter()
16 .map(|(name, ps)| {
17 (
18 name,
19 (
20 (ps.src.centroids, ps.src.clusters, ps.src.drift),
21 (ps.dst.centroids, ps.dst.clusters, ps.dst.drift),
22 ),
23 )
24 })
25 .collect()
26}
27use std::collections::{BTreeMap, BTreeSet};
28use std::sync::{Mutex, OnceLock};
29
30#[derive(Debug, Clone)]
37pub struct EngineEdgeDelta {
38 pub rule: String,
39 pub src_key: String,
41 pub dst_key: String,
43 pub edge_type: String,
45 pub etype_sym: u32,
47 pub src_id: u32,
49 pub dst_id: u32,
51 pub fired: bool,
53}
54
55#[cfg(test)]
56pub use crate::index::{with_ivf_drift_rebuild, with_vector_dim_reject, with_vector_early_exit};
57
58#[cfg(test)]
61pub(crate) static PEAK_DESIRED_PAIRS: std::sync::atomic::AtomicUsize =
62 std::sync::atomic::AtomicUsize::new(0);
63
64#[cfg(test)]
65pub(crate) fn record_desired_len(n: usize) {
66 use std::sync::atomic::Ordering;
67 let mut cur = PEAK_DESIRED_PAIRS.load(Ordering::Relaxed);
68 while n > cur {
69 match PEAK_DESIRED_PAIRS.compare_exchange_weak(cur, n, Ordering::Relaxed, Ordering::Relaxed)
70 {
71 Ok(_) => break,
72 Err(actual) => cur = actual,
73 }
74 }
75}
76
77pub struct GraphMut<'a> {
79 pub ids: &'a IdMap,
80 pub syms: &'a mut Interner,
81 pub labels: &'a [u32],
82 pub props: ColumnsView<'a>,
83 pub topo: &'a mut Topology,
84 pub edge_props: &'a mut EdgeProps,
85}
86
87pub const DEFAULT_MAX_EDGES: u64 = 1_000_000;
89
90pub const MAX_CHAIN_DEPTH: usize = 4;
99
100type Triple = (u32, u32, u32);
102type Touch = (u32, u32, u32, u32);
104
105struct ChainScope {
108 cursor: usize,
110 prev_emit: bool,
112 active: bool,
115}
116
117pub type SideIvfExport = (Vec<Vec<f64>>, BTreeMap<u32, usize>, u64);
120pub type RuleIvfExport = (SideIvfExport, SideIvfExport);
122
123type HnswBlobMap = BTreeMap<String, (Vec<u8>, Vec<u8>)>;
125type LazyHnswMap = BTreeMap<String, (Option<HnswIndex>, Option<HnswIndex>)>;
127
128#[derive(Debug, Default)]
136struct LazyProvenance {
137 provenance: BTreeMap<String, BTreeSet<Triple>>,
138 by_node: BTreeMap<u32, BTreeSet<Touch>>,
139 intern_rule: Vec<String>,
140}
141
142#[derive(Debug, Default)]
143pub struct RuleEngine {
144 rules: BTreeMap<String, RuleDef>,
145 indexes: BTreeMap<String, RuleIndex>,
146 provenance: BTreeMap<String, BTreeSet<Triple>>,
147 owned: BTreeSet<Triple>,
148 by_node: BTreeMap<u32, BTreeSet<Touch>>,
151 rule_intern: BTreeMap<String, u32>,
156 intern_rule: Vec<String>,
157 tripped: BTreeMap<String, bool>,
158 fires: BTreeMap<String, u64>,
159 pending_deltas: Vec<EngineEdgeDelta>,
166 emit_deltas: bool,
179 rebuild_needed: BTreeSet<String>,
183 indexes_populated: bool,
189 retained_hnsw_blobs: Mutex<HnswBlobMap>,
198 retained_ivf_bytes: Mutex<Option<Vec<u8>>>,
206 retained_provenance_bytes: Mutex<Option<Vec<u8>>>,
215 lazy_provenance: OnceLock<LazyProvenance>,
223 lazy_hnsw: OnceLock<LazyHnswMap>,
230 chain_depth: usize,
234 chain_fired: BTreeSet<(u32, u32)>,
240 doomed: Option<u32>,
249 chain_truncations: u64,
252}
253
254fn candidate_spec_for(def: &RuleDef) -> CandidateSpec<'_> {
261 if def.approximate {
262 let k = def.max_edges.map(|me| me.max(128)).unwrap_or(128) as usize;
266 candidate_spec_approx_with_k(&def.predicate, k)
267 } else {
268 candidate_spec(&def.predicate)
269 }
270}
271
272fn src_lookup_spec_for(def: &RuleDef) -> CandidateSpec<'_> {
278 if is_keymatch_rooted(&def.predicate) {
279 let field =
280 keymatch_field(&def.predicate).expect("keymatch-rooted predicate has a KeyMatch field");
281 CandidateSpec::Scalar { field }
282 } else {
283 candidate_spec_for(def)
284 }
285}
286
287fn predicate_covers_field(p: &Predicate, field: &str) -> bool {
289 match p {
290 Predicate::VectorSimilar { field: f, .. } => f == field,
291 Predicate::All(parts) | Predicate::Any(parts) => {
292 parts.iter().any(|q| predicate_covers_field(q, field))
293 }
294 _ => false,
295 }
296}
297
298fn keymatch_field(p: &Predicate) -> Option<&str> {
300 match p {
301 Predicate::KeyMatch { field } => Some(field),
302 Predicate::All(parts) => parts.first().and_then(keymatch_field),
303 Predicate::Any(_) => None,
304 _ => None,
305 }
306}
307
308fn compute_desired(
311 def: &RuleDef,
312 index: &RuleIndex,
313 n: u32,
314 on_src_side: bool,
315 g: &GraphMut<'_>,
316) -> BTreeMap<(u32, u32), f64> {
317 let (my_label, other_label) = if on_src_side {
318 (&def.src_label, &def.dst_label)
319 } else {
320 (&def.dst_label, &def.src_label)
321 };
322
323 let Some(my_sym) = g.syms.get(my_label) else {
324 return BTreeMap::new();
325 };
326 if g.labels.get(n as usize).copied() != Some(my_sym) {
327 return BTreeMap::new();
328 }
329 let other_sym = g.syms.get(other_label);
330
331 let n_key = match g.ids.key_of(n) {
332 Some(k) => k,
333 None => return BTreeMap::new(),
334 };
335 let n_get = |f: &str| g.props.get(n, f).map(|vr| vr.into_value());
336
337 let spec = candidate_spec_for(def);
338 let candidates: BTreeSet<u32> = if on_src_side {
339 if is_keymatch_rooted(&def.predicate) {
340 let field = keymatch_field(&def.predicate).expect("ByKey always comes from KeyMatch");
344 match n_get(field) {
345 Some(Value::Str(ref target_key)) => match g.ids.get(target_key) {
346 Some(dst_id) => std::iter::once(dst_id).collect(),
347 None => BTreeSet::new(),
348 },
349 _ => BTreeSet::new(),
350 }
351 } else {
352 index.dst_side.candidates(&spec, &n_get)
353 }
354 } else {
355 let src_spec = src_lookup_spec_for(def);
357 if is_keymatch_rooted(&def.predicate) {
358 let key_getter = |_: &str| Some(Value::Str(n_key.to_string()));
361 index.src_side.candidates(&src_spec, &key_getter)
362 } else {
363 index.src_side.candidates(&src_spec, &n_get)
364 }
365 };
366
367 let n_early_exit_hint: Option<(Vec<f64>, f64, [f64; 8])> = if !def.approximate {
382 if let Predicate::VectorSimilar { field, .. } = &def.predicate {
383 if crate::index::vector_early_exit_enabled() {
384 let n_side = if on_src_side {
385 &index.src_side
386 } else {
387 &index.dst_side
388 };
389 if let Some(vn_v) = n_get(field) {
390 if let Some(vn) = crate::index::as_numeric_list(&vn_v) {
391 if let Some((norm_n, ckpts_n)) = n_side.fresh_ckpts_for(n, &vn) {
392 Some((vn, norm_n, *ckpts_n))
393 } else {
394 None
395 }
396 } else {
397 None
398 }
399 } else {
400 None
401 }
402 } else {
403 None
404 }
405 } else {
406 None
407 }
408 } else {
409 None
410 };
411
412 let mut out = BTreeMap::new();
413 for m in candidates {
414 if m == n {
415 continue; }
417 if g.labels.get(m as usize).copied() != other_sym {
418 continue; }
420 let m_key = match g.ids.key_of(m) {
421 Some(k) => k,
422 None => continue,
423 };
424 let m_get = |f: &str| g.props.get(m, f).map(|vr| vr.into_value());
425 let (s_view, d_view, s_id, d_id) = if on_src_side {
426 (
427 NodeView {
428 key: n_key,
429 props: &n_get,
430 },
431 NodeView {
432 key: m_key,
433 props: &m_get,
434 },
435 n,
436 m,
437 )
438 } else {
439 (
440 NodeView {
441 key: m_key,
442 props: &m_get,
443 },
444 NodeView {
445 key: n_key,
446 props: &n_get,
447 },
448 m,
449 n,
450 )
451 };
452
453 if let (Some((ref vn, norm_n, ckpts_n)), Predicate::VectorSimilar { field, min }) =
455 (&n_early_exit_hint, &def.predicate)
456 {
457 let m_side = if on_src_side {
458 &index.dst_side
459 } else {
460 &index.src_side
461 };
462 if let Some(vm_v) = m_get(field) {
463 if let Some(vm) = crate::index::as_numeric_list(&vm_v) {
464 if let Some((norm_m, ckpts_m)) = m_side.fresh_ckpts_for(m, &vm) {
465 let (va, ckpts_a, na, vb, ckpts_b, nb) = if on_src_side {
466 (
467 vn.as_slice(),
468 ckpts_n,
469 *norm_n,
470 vm.as_slice(),
471 ckpts_m,
472 norm_m,
473 )
474 } else {
475 (
476 vm.as_slice(),
477 ckpts_m,
478 norm_m,
479 vn.as_slice(),
480 ckpts_n,
481 *norm_n,
482 )
483 };
484 match crate::def::cosine_early_exit(va, vb, ckpts_a, ckpts_b, na, nb, *min)
485 {
486 None => continue, Some(score) => {
488 out.insert((s_id, d_id), score);
489 continue; }
491 }
492 }
493 }
494 }
495 }
496
497 if let Some(score) = evaluate(&def.predicate, &s_view, &d_view) {
498 out.insert((s_id, d_id), score);
499 }
500 }
501 #[cfg(test)]
502 record_desired_len(out.len());
503 out
504}
505
506fn compute_desired_via(
525 def: &RuleDef,
526 anchor: ViaAnchor,
527 doomed: Option<u32>,
528 g: &GraphMut<'_>,
529) -> BTreeMap<(u32, u32), f64> {
530 let via_label = def.via_label.as_deref().unwrap();
531 let via_edge_str = def.via_edge.as_deref().unwrap();
532 let via_dir = def.via_dir.unwrap_or(core_storage::Direction::Out);
533
534 let src_sym = match g.syms.get(&def.src_label) {
535 Some(s) => s,
536 None => return BTreeMap::new(),
537 };
538 let via_sym = match g.syms.get(via_label) {
539 Some(s) => s,
540 None => return BTreeMap::new(),
541 };
542 let dst_sym = match g.syms.get(&def.dst_label) {
543 Some(s) => s,
544 None => return BTreeMap::new(),
545 };
546 let via_etype = match g.syms.get(via_edge_str) {
547 Some(e) => e,
548 None => return BTreeMap::new(),
549 };
550
551 let srcs: Vec<u32> = match anchor {
553 ViaAnchor::Src(src_id) => {
554 if Some(src_id) == doomed {
555 return BTreeMap::new();
556 }
557 if g.labels.get(src_id as usize).copied() == Some(src_sym) {
558 vec![src_id]
559 } else {
560 return BTreeMap::new();
561 }
562 }
563 ViaAnchor::Dst(_) => {
564 (0..g.ids.len() as u32)
566 .filter(|&id| {
567 Some(id) != doomed
568 && matches!(
569 g.labels.get(id as usize).copied(),
570 Some(s) if s != u32::MAX && s == src_sym
571 )
572 })
573 .collect()
574 }
575 };
576
577 let anchored_dst: Option<u32> = match anchor {
579 ViaAnchor::Dst(dst_id) => {
580 if Some(dst_id) == doomed {
581 return BTreeMap::new();
582 }
583 if g.labels.get(dst_id as usize).copied() == Some(dst_sym) {
584 Some(dst_id)
585 } else {
586 return BTreeMap::new();
587 }
588 }
589 _ => None,
590 };
591
592 let mut out = BTreeMap::new();
593
594 for src in srcs {
595 let _src_key = match g.ids.key_of(src) {
596 Some(k) => k,
597 None => continue,
598 };
599 let via_neighbors: Vec<u32> = g
601 .topo
602 .neighbors(via_etype, via_dir, src)
603 .iter()
604 .copied()
605 .filter(|&v| Some(v) != doomed && g.labels.get(v as usize).copied() == Some(via_sym))
606 .collect();
607
608 if via_neighbors.is_empty() {
609 continue;
610 }
611
612 let dsts: Vec<u32> = if let Some(dst_id) = anchored_dst {
614 vec![dst_id]
615 } else {
616 (0..g.ids.len() as u32)
617 .filter(|&id| {
618 id != src
619 && Some(id) != doomed
620 && matches!(
621 g.labels.get(id as usize).copied(),
622 Some(s) if s != u32::MAX && s == dst_sym
623 )
624 })
625 .collect()
626 };
627
628 for dst in dsts {
629 if dst == src {
630 continue; }
632 let dst_key = match g.ids.key_of(dst) {
633 Some(k) => k,
634 None => continue,
635 };
636 let dst_get = |f: &str| g.props.get(dst, f).map(|vr| vr.into_value());
637 let dst_view = NodeView {
638 key: dst_key,
639 props: &dst_get,
640 };
641
642 let mut best: Option<f64> = None;
644 for &via_id in &via_neighbors {
645 let via_key = match g.ids.key_of(via_id) {
646 Some(k) => k,
647 None => continue,
648 };
649 let via_get = |f: &str| g.props.get(via_id, f).map(|vr| vr.into_value());
650 let via_view = NodeView {
651 key: via_key,
652 props: &via_get,
653 };
654 if let Some(score) = evaluate(&def.predicate, &via_view, &dst_view) {
655 best = Some(match best {
656 None => score,
657 Some(prev) => prev.max(score),
658 });
659 }
660 }
661
662 if let Some(score) = best {
663 out.insert((src, dst), score);
664 }
665 }
666 }
667
668 out
669}
670
671enum ViaAnchor {
673 Src(u32),
675 Dst(u32),
678}
679
680fn edge_budget(def: &RuleDef) -> u64 {
681 def.max_edges.unwrap_or(DEFAULT_MAX_EDGES)
684}
685
686pub(crate) fn filter_src_top_k(
708 per_src: BTreeMap<(u32, u32), f64>,
709 k: u64,
710 ids: &core_storage::IdMap,
711) -> BTreeMap<(u32, u32), f64> {
712 if per_src.len() as u64 <= k {
713 return per_src;
714 }
715 let mut candidates: Vec<((u32, u32), f64)> = per_src.into_iter().collect();
716 candidates.sort_by(|&((_, da), sa), &((_, db), sb)| {
718 sb.total_cmp(&sa).then_with(|| {
719 let ka = ids.key_of(da).unwrap_or("");
720 let kb = ids.key_of(db).unwrap_or("");
721 ka.cmp(kb)
722 })
723 });
724 candidates.truncate(k as usize);
725 candidates.into_iter().collect()
726}
727
728fn apply_per_src_top_k(
735 def: &RuleDef,
736 src: u32,
737 desired_from_src: BTreeMap<(u32, u32), f64>,
738 prov: &mut ProvSets<'_>,
739 g: &mut GraphMut<'_>,
740) {
741 let et = g.syms.intern(&def.edge_type);
742
743 let current: Vec<Triple> = {
747 let rid = prov.rule_intern.get(&def.name).copied();
748 prov.by_node
749 .get(&src)
750 .into_iter()
751 .flatten()
752 .filter(|(r, t, s, _d)| Some(*r) == rid && *t == et && *s == src)
753 .map(|(_, t, s, d)| (*t, *s, *d))
754 .collect()
755 };
756
757 for (t, s, d) in current {
759 if !desired_from_src.contains_key(&(s, d)) {
760 g.topo.remove_edge(t, s, d);
761 g.edge_props.remove_edge(t, s, d);
762 prov.remove(&def.name, (t, s, d), g.ids, g.syms);
763 }
764 }
765
766 for ((s, d), score) in &desired_from_src {
768 let triple = (et, *s, *d);
769 let already = prov.contains(&triple);
770 if !already {
771 let newly = g.topo.add_edge(et, *s, *d);
772 if newly {
773 prov.insert(&def.name, triple, g.ids, g.syms);
774 }
775 }
776 let is_owned = already || prov.contains(&triple);
777 if is_owned {
778 if let Some(p) = &def.weight_prop {
779 g.edge_props.set(et, *s, *d, p, Value::Float(*score));
780 }
781 }
782 }
783}
784
785fn intern_rule(intern: &mut BTreeMap<String, u32>, names: &mut Vec<String>, rule: &str) -> u32 {
787 if let Some(&id) = intern.get(rule) {
788 return id;
789 }
790 let id = names.len() as u32;
791 intern.insert(rule.to_string(), id);
792 names.push(rule.to_string());
793 id
794}
795
796type ByNodeRebuild = (
797 BTreeMap<u32, BTreeSet<Touch>>,
798 BTreeMap<String, u32>,
799 Vec<String>,
800);
801
802fn rebuild_by_node(provenance: &BTreeMap<String, BTreeSet<Triple>>) -> ByNodeRebuild {
803 let mut by_node = BTreeMap::new();
804 let mut intern = BTreeMap::new();
805 let mut names = Vec::new();
806 for (rule, set) in provenance {
807 let rid = intern_rule(&mut intern, &mut names, rule);
808 for &triple in set {
809 touch_insert(&mut by_node, rid, triple);
810 }
811 }
812 (by_node, intern, names)
813}
814
815fn touch_insert(by_node: &mut BTreeMap<u32, BTreeSet<Touch>>, rid: u32, triple: Triple) {
816 let (t, s, d) = triple;
817 let entry = (rid, t, s, d);
818 by_node.entry(s).or_default().insert(entry);
819 if s != d {
820 by_node.entry(d).or_default().insert(entry);
821 }
822}
823
824fn touch_remove(by_node: &mut BTreeMap<u32, BTreeSet<Touch>>, rid: u32, triple: Triple) {
825 let (t, s, d) = triple;
826 let entry = (rid, t, s, d);
827 if let Some(set) = by_node.get_mut(&s) {
828 set.remove(&entry);
829 if set.is_empty() {
830 by_node.remove(&s);
831 }
832 }
833 if s != d {
834 if let Some(set) = by_node.get_mut(&d) {
835 set.remove(&entry);
836 if set.is_empty() {
837 by_node.remove(&d);
838 }
839 }
840 }
841}
842
843#[cfg(test)]
844fn resolve_by_node(
845 by_node: &BTreeMap<u32, BTreeSet<Touch>>,
846 names: &[String],
847) -> BTreeMap<u32, BTreeSet<(String, Triple)>> {
848 by_node
849 .iter()
850 .map(|(&n, set)| {
851 let resolved = set
852 .iter()
853 .map(|&(rid, t, s, d)| (names[rid as usize].clone(), (t, s, d)))
854 .collect();
855 (n, resolved)
856 })
857 .collect()
858}
859
860struct ProvSets<'a> {
863 set: &'a mut BTreeSet<Triple>,
864 owned: &'a mut BTreeSet<Triple>,
865 by_node: &'a mut BTreeMap<u32, BTreeSet<Touch>>,
866 rule_intern: &'a mut BTreeMap<String, u32>,
867 intern_rule: &'a mut Vec<String>,
868 deltas: &'a mut Vec<EngineEdgeDelta>,
872 emit: bool,
875}
876
877impl ProvSets<'_> {
878 fn insert(&mut self, rule: &str, triple: Triple, ids: &IdMap, syms: &Interner) -> bool {
882 if !self.set.insert(triple) {
883 return false;
884 }
885 self.owned.insert(triple);
886 let rid = intern_rule(self.rule_intern, self.intern_rule, rule);
887 touch_insert(self.by_node, rid, triple);
888 let (etype, src, dst) = triple;
889 if self.emit {
890 if let (Some(sk), Some(dk), Some(et)) =
891 (ids.key_of(src), ids.key_of(dst), syms.resolve(etype))
892 {
893 self.deltas.push(EngineEdgeDelta {
894 rule: rule.to_string(),
895 src_key: sk.to_string(),
896 dst_key: dk.to_string(),
897 edge_type: et.to_string(),
898 etype_sym: etype,
899 src_id: src,
900 dst_id: dst,
901 fired: true,
902 });
903 }
904 }
905 true
906 }
907
908 fn remove(&mut self, rule: &str, triple: Triple, ids: &IdMap, syms: &Interner) -> bool {
909 if !self.set.remove(&triple) {
910 return false;
911 }
912 self.owned.remove(&triple);
913 let rid = intern_rule(self.rule_intern, self.intern_rule, rule);
914 touch_remove(self.by_node, rid, triple);
915 let (etype, src, dst) = triple;
916 if self.emit {
917 if let (Some(sk), Some(dk), Some(et)) =
918 (ids.key_of(src), ids.key_of(dst), syms.resolve(etype))
919 {
920 self.deltas.push(EngineEdgeDelta {
921 rule: rule.to_string(),
922 src_key: sk.to_string(),
923 dst_key: dk.to_string(),
924 edge_type: et.to_string(),
925 etype_sym: etype,
926 src_id: src,
927 dst_id: dst,
928 fired: false,
929 });
930 }
931 }
932 true
933 }
934
935 fn contains(&self, triple: &Triple) -> bool {
936 self.set.contains(triple)
937 }
938
939 fn len(&self) -> usize {
940 self.set.len()
941 }
942}
943
944fn apply_desired(
955 def: &RuleDef,
956 desired: BTreeMap<(u32, u32), f64>,
957 retract_touching: Option<u32>,
958 prov: &mut ProvSets<'_>,
959 tripped: &mut bool,
960 g: &mut GraphMut<'_>,
961) {
962 let budget = edge_budget(def);
963 let et = g.syms.intern(&def.edge_type);
964
965 let current: Vec<Triple> = match retract_touching {
966 None => prov
967 .set
968 .iter()
969 .filter(|(t, _, _)| *t == et)
970 .copied()
971 .collect(),
972 Some(n) => {
973 let rid = prov.rule_intern.get(&def.name).copied();
974 prov.by_node
975 .get(&n)
976 .into_iter()
977 .flatten()
978 .filter(|(r, t, _, _)| Some(*r) == rid && *t == et)
979 .map(|(_, t, s, d)| (*t, *s, *d))
980 .collect()
981 }
982 };
983
984 for (t, s, d) in current {
985 if !desired.contains_key(&(s, d)) {
986 g.topo.remove_edge(t, s, d);
987 g.edge_props.remove_edge(t, s, d);
988 prov.remove(&def.name, (t, s, d), g.ids, g.syms);
989 }
990 }
991
992 for ((s, d), score) in desired {
993 let triple = (et, s, d);
994 let already = prov.contains(&triple);
995 if !already {
996 if *tripped || prov.len() as u64 >= budget {
997 *tripped = true;
998 continue;
999 }
1000 let newly = g.topo.add_edge(et, s, d);
1001 if newly {
1002 prov.insert(&def.name, triple, g.ids, g.syms);
1003 }
1004 }
1005 let is_owned_here = already || prov.contains(&triple);
1009 if is_owned_here {
1010 if let Some(p) = &def.weight_prop {
1011 g.edge_props.set(et, s, d, p, Value::Float(score));
1012 }
1013 }
1014 }
1015}
1016
1017#[cfg(test)]
1025#[allow(dead_code)]
1026fn compute_full_desired(
1027 def: &RuleDef,
1028 index: &RuleIndex,
1029 g: &GraphMut<'_>,
1030) -> BTreeMap<(u32, u32), f64> {
1031 let mut desired = BTreeMap::new();
1032 let src_sym = g.syms.get(&def.src_label);
1033 for id in 0..g.ids.len() as u32 {
1034 let label_sym = match g.labels.get(id as usize).copied() {
1035 Some(s) if s != u32::MAX => s,
1036 _ => continue,
1037 };
1038 if src_sym == Some(label_sym) {
1039 desired.extend(compute_desired(def, index, id, true, g));
1040 #[cfg(test)]
1041 record_desired_len(desired.len());
1042 }
1043 }
1044 desired
1045}
1046
1047fn pair_still_desired(def: &RuleDef, s: u32, d: u32, g: &GraphMut<'_>) -> bool {
1054 let src_sym = match g.syms.get(&def.src_label) {
1055 Some(sym) => sym,
1056 None => return false,
1057 };
1058 let dst_sym = match g.syms.get(&def.dst_label) {
1059 Some(sym) => sym,
1060 None => return false,
1061 };
1062 if g.labels.get(s as usize).copied() != Some(src_sym) {
1063 return false;
1064 }
1065 if g.labels.get(d as usize).copied() != Some(dst_sym) {
1066 return false;
1067 }
1068 let s_key = match g.ids.key_of(s) {
1069 Some(k) => k,
1070 None => return false,
1071 };
1072 let d_key = match g.ids.key_of(d) {
1073 Some(k) => k,
1074 None => return false,
1075 };
1076 let s_get = |f: &str| g.props.get(s, f).map(|vr| vr.into_value());
1077 let d_get = |f: &str| g.props.get(d, f).map(|vr| vr.into_value());
1078 evaluate(
1079 &def.predicate,
1080 &NodeView {
1081 key: s_key,
1082 props: &s_get,
1083 },
1084 &NodeView {
1085 key: d_key,
1086 props: &d_get,
1087 },
1088 )
1089 .is_some()
1090}
1091
1092fn count_desired_up_to(def: &RuleDef, index: &RuleIndex, limit: u64, g: &GraphMut<'_>) -> u64 {
1097 let mut count = 0u64;
1098 let src_sym = g.syms.get(&def.src_label);
1099 for id in 0..g.ids.len() as u32 {
1100 let label_sym = match g.labels.get(id as usize).copied() {
1101 Some(s) if s != u32::MAX => s,
1102 _ => continue,
1103 };
1104 if src_sym != Some(label_sym) {
1105 continue;
1106 }
1107 count += compute_desired(def, index, id, true, g).len() as u64;
1108 if count > limit {
1109 return count;
1110 }
1111 }
1112 count
1113}
1114
1115fn apply_streaming_create(
1137 def: &RuleDef,
1138 index: &RuleIndex,
1139 prov: &mut ProvSets<'_>,
1140 tripped: &mut bool,
1141 g: &mut GraphMut<'_>,
1142) {
1143 let budget = edge_budget(def);
1144 let et = g.syms.intern(&def.edge_type);
1145 let src_sym = g.syms.get(&def.src_label);
1146
1147 'outer: for id in 0..g.ids.len() as u32 {
1148 let label_sym = match g.labels.get(id as usize).copied() {
1149 Some(s) if s != u32::MAX => s,
1150 _ => continue,
1151 };
1152 if src_sym != Some(label_sym) {
1153 continue;
1154 }
1155 let per_src = compute_desired(def, index, id, true, g);
1156 for ((s, d), score) in per_src {
1157 let triple = (et, s, d);
1158 let already = prov.contains(&triple);
1163 if !already {
1164 if *tripped || prov.len() as u64 >= budget {
1165 *tripped = true;
1166 break 'outer;
1167 }
1168 let newly = g.topo.add_edge(et, s, d);
1169 if newly {
1170 prov.insert(&def.name, triple, g.ids, g.syms);
1171 }
1172 }
1173 let is_owned_here = already || prov.contains(&triple);
1174 if is_owned_here {
1175 if let Some(p) = &def.weight_prop {
1176 g.edge_props.set(et, s, d, p, Value::Float(score));
1177 }
1178 }
1179 }
1180 }
1181}
1182
1183fn apply_streaming_create_top_k(
1190 def: &RuleDef,
1191 k: u64,
1192 index: &RuleIndex,
1193 prov: &mut ProvSets<'_>,
1194 g: &mut GraphMut<'_>,
1195) {
1196 let src_sym = g.syms.get(&def.src_label);
1197 for id in 0..g.ids.len() as u32 {
1198 let label_sym = match g.labels.get(id as usize).copied() {
1199 Some(s) if s != u32::MAX => s,
1200 _ => continue,
1201 };
1202 if src_sym != Some(label_sym) {
1203 continue;
1204 }
1205 let per_src = compute_desired(def, index, id, true, g);
1206 let top_k = filter_src_top_k(per_src, k, g.ids);
1207 apply_per_src_top_k(def, id, top_k, prov, g);
1208 }
1209}
1210
1211fn apply_streaming_rebuild_top_k(
1218 def: &RuleDef,
1219 k: u64,
1220 index: &RuleIndex,
1221 doomed: Option<u32>,
1222 prov: &mut ProvSets<'_>,
1223 g: &mut GraphMut<'_>,
1224) {
1225 let et = g.syms.intern(&def.edge_type);
1226
1227 let existing_srcs: BTreeSet<u32> = prov
1230 .set
1231 .iter()
1232 .filter(|(t, _, _)| *t == et)
1233 .map(|(_, s, _)| *s)
1234 .collect();
1235
1236 let src_sym = g.syms.get(&def.src_label);
1237 let mut all_srcs: BTreeSet<u32> = existing_srcs;
1238 for id in 0..g.ids.len() as u32 {
1239 let label_sym = match g.labels.get(id as usize).copied() {
1240 Some(s) if s != u32::MAX => s,
1241 _ => continue,
1242 };
1243 if src_sym == Some(label_sym) {
1244 all_srcs.insert(id);
1245 }
1246 }
1247
1248 for src in all_srcs {
1249 let desired_src = if def.via_edge.is_some() {
1252 compute_desired_via(def, ViaAnchor::Src(src), doomed, g)
1253 } else {
1254 compute_desired(def, index, src, true, g)
1255 };
1256 let top_k = filter_src_top_k(desired_src, k, g.ids);
1257 apply_per_src_top_k(def, src, top_k, prov, g);
1258 }
1259}
1260
1261fn apply_via_rebuild(
1275 def: &RuleDef,
1276 doomed: Option<u32>,
1277 prov: &mut ProvSets<'_>,
1278 tripped: &mut bool,
1279 g: &mut GraphMut<'_>,
1280) {
1281 let budget = edge_budget(def);
1282 let et = g.syms.intern(&def.edge_type);
1283
1284 let mut sources: BTreeSet<u32> = prov
1287 .set
1288 .iter()
1289 .filter(|(t, _, _)| *t == et)
1290 .map(|(_, s, _)| *s)
1291 .collect();
1292 let src_sym = g.syms.get(&def.src_label);
1293 for id in 0..g.ids.len() as u32 {
1294 if let Some(s) = g.labels.get(id as usize).copied() {
1295 if s != u32::MAX && Some(s) == src_sym {
1296 sources.insert(id);
1297 }
1298 }
1299 }
1300 let sources: Vec<u32> = sources.into_iter().collect();
1301
1302 let mut total: u64 = 0;
1305 for &src in &sources {
1306 total += compute_desired_via(def, ViaAnchor::Src(src), doomed, g).len() as u64;
1307 if total > budget {
1308 *tripped = true;
1309 return; }
1311 }
1312 *tripped = false;
1313
1314 for src in sources {
1317 let desired = compute_desired_via(def, ViaAnchor::Src(src), doomed, g);
1318 let current: Vec<Triple> = prov
1319 .set
1320 .iter()
1321 .filter(|&&(t, s, _)| t == et && s == src)
1322 .copied()
1323 .collect();
1324 for triple in current {
1325 let (t, s, d) = triple;
1326 if !desired.contains_key(&(s, d)) {
1327 g.topo.remove_edge(t, s, d);
1328 g.edge_props.remove_edge(t, s, d);
1329 prov.remove(&def.name, triple, g.ids, g.syms);
1330 }
1331 }
1332 for ((s, d), score) in desired {
1333 let triple = (et, s, d);
1334 let already = prov.contains(&triple);
1335 if !already && g.topo.add_edge(et, s, d) {
1336 prov.insert(&def.name, triple, g.ids, g.syms);
1337 }
1338 if already || prov.contains(&triple) {
1341 if let Some(p) = &def.weight_prop {
1342 g.edge_props.set(et, s, d, p, Value::Float(score));
1343 }
1344 }
1345 }
1346 }
1347}
1348
1349fn apply_streaming_rebuild(
1362 def: &RuleDef,
1363 index: &RuleIndex,
1364 prov: &mut ProvSets<'_>,
1365 tripped: &mut bool,
1366 g: &mut GraphMut<'_>,
1367) {
1368 let budget = edge_budget(def);
1369 let et = g.syms.intern(&def.edge_type);
1370
1371 let total = count_desired_up_to(def, index, budget, g);
1373 if total > budget {
1374 *tripped = true;
1375 return; }
1377
1378 *tripped = false;
1380
1381 let current: Vec<Triple> = prov
1384 .set
1385 .iter()
1386 .filter(|(t, _, _)| *t == et)
1387 .copied()
1388 .collect();
1389 for (t, s, d) in current {
1390 if !pair_still_desired(def, s, d, g) {
1391 g.topo.remove_edge(t, s, d);
1392 g.edge_props.remove_edge(t, s, d);
1393 prov.remove(&def.name, (t, s, d), g.ids, g.syms);
1394 }
1395 }
1396
1397 let src_sym = g.syms.get(&def.src_label);
1400 for id in 0..g.ids.len() as u32 {
1401 let label_sym = match g.labels.get(id as usize).copied() {
1402 Some(s) if s != u32::MAX => s,
1403 _ => continue,
1404 };
1405 if src_sym != Some(label_sym) {
1406 continue;
1407 }
1408 let per_src = compute_desired(def, index, id, true, g);
1409 for ((s, d), score) in per_src {
1410 let triple = (et, s, d);
1411 let already = prov.contains(&triple);
1412 if !already {
1413 let newly = g.topo.add_edge(et, s, d);
1414 if newly {
1415 prov.insert(&def.name, triple, g.ids, g.syms);
1416 }
1417 }
1418 let is_owned_here = already || prov.contains(&triple);
1419 if is_owned_here {
1420 if let Some(p) = &def.weight_prop {
1421 g.edge_props.set(et, s, d, p, Value::Float(score));
1422 }
1423 }
1424 }
1425 }
1426}
1427
1428fn bump_fires_for_participants(def: &RuleDef, g: &GraphMut<'_>, fires: &mut u64) {
1431 let src_sym = g.syms.get(&def.src_label);
1432 let dst_sym = g.syms.get(&def.dst_label);
1433 for id in 0..g.ids.len() as u32 {
1434 let label_sym = match g.labels.get(id as usize).copied() {
1435 Some(s) if s != u32::MAX => s,
1436 _ => continue,
1437 };
1438 if src_sym == Some(label_sym) || dst_sym == Some(label_sym) {
1439 *fires += 1;
1440 }
1441 }
1442}
1443
1444fn index_node_for_rule(
1446 id: u32,
1447 label_sym: u32,
1448 def: &RuleDef,
1449 index: &mut RuleIndex,
1450 syms: &Interner,
1451 props: ColumnsView<'_>,
1452) {
1453 let get = |f: &str| props.get(id, f).map(|vr| vr.into_value());
1454 if syms.get(&def.src_label) == Some(label_sym) {
1455 let spec = src_lookup_spec_for(def);
1456 index.src_side.insert(&spec, id, &get);
1457 }
1458 if syms.get(&def.dst_label) == Some(label_sym) {
1459 let spec = candidate_spec_for(def);
1460 index.dst_side.insert(&spec, id, &get);
1461 }
1462}
1463
1464impl RuleEngine {
1469 pub fn new() -> Self {
1470 Self::default()
1471 }
1472
1473 fn chaining_possible(&self) -> bool {
1476 self.rules.values().any(|r| r.via_edge.is_some())
1477 }
1478
1479 fn begin_chain(&mut self) -> ChainScope {
1499 let active = self.chain_depth == 0 && self.chaining_possible();
1500 let prev_emit = self.emit_deltas;
1501 if active {
1502 self.emit_deltas = true;
1503 }
1504 ChainScope {
1505 cursor: self.pending_deltas.len(),
1506 prev_emit,
1507 active,
1508 }
1509 }
1510
1511 fn end_chain(&mut self, scope: ChainScope, g: &mut GraphMut<'_>) {
1513 if scope.active {
1514 self.chain_from(scope.cursor, g);
1515 }
1516 self.emit_deltas = scope.prev_emit;
1517 }
1518
1519 pub fn reset_chain_state(&mut self) {
1528 self.chain_depth = 0;
1529 self.chain_fired.clear();
1530 self.doomed = None;
1531 }
1532
1533 fn chain_from(&mut self, mut cursor: usize, g: &mut GraphMut<'_>) {
1540 debug_assert_eq!(self.chain_depth, 0);
1541 if self.pending_deltas.len() == cursor {
1542 return; }
1544 let via_edges: BTreeSet<String> = self
1548 .rules
1549 .values()
1550 .filter_map(|r| r.via_edge.clone())
1551 .collect();
1552 let rule_count = self.rules.len();
1553 for level in 1..=MAX_CHAIN_DEPTH {
1554 let end = self.pending_deltas.len();
1555 if end == cursor {
1556 return; }
1558 let batch: Vec<(String, u32, u32)> = self.pending_deltas[cursor..end]
1559 .iter()
1560 .filter(|d| via_edges.contains(&d.edge_type))
1561 .map(|d| (d.edge_type.clone(), d.src_id, d.dst_id))
1562 .collect();
1563 cursor = end;
1564 if batch.is_empty() {
1565 return; }
1567 self.chain_fired.clear();
1574 self.chain_depth = level;
1575 for (etype, src, dst) in batch {
1576 self.on_edge_changed_inner(&etype, src, dst, g);
1577 }
1578 self.chain_depth = 0;
1579 debug_assert_eq!(
1583 self.rules.len(),
1584 rule_count,
1585 "the rule set must not change during a chain"
1586 );
1587 }
1588 let truncated = self.pending_deltas[cursor..]
1592 .iter()
1593 .any(|d| via_edges.contains(&d.edge_type));
1594 if truncated {
1595 self.chain_truncations = self.chain_truncations.saturating_add(1);
1596 }
1597 }
1598
1599 pub fn rules(&self) -> impl Iterator<Item = &RuleDef> {
1600 self.rules.values()
1601 }
1602
1603 pub fn chain_truncations(&self) -> u64 {
1610 self.chain_truncations
1611 }
1612
1613 pub fn is_owned(&self, etype: u32, src: u32, dst: u32) -> bool {
1614 self.owned.contains(&(etype, src, dst))
1615 }
1616
1617 fn provenance_is_retained(&self) -> bool {
1622 self.retained_provenance_bytes
1623 .lock()
1624 .expect("lock poisoned")
1625 .is_some()
1626 }
1627
1628 pub fn provenance(&self) -> &BTreeMap<String, BTreeSet<(u32, u32, u32)>> {
1633 if self.provenance_is_retained() {
1634 self.ensure_provenance_loaded();
1635 &self.lazy_provenance.get().unwrap().provenance
1636 } else {
1637 &self.provenance
1638 }
1639 }
1640
1641 pub fn provenance_touching(
1646 &self,
1647 node: u32,
1648 ) -> impl Iterator<Item = (&str, u32, u32, u32)> + '_ {
1649 let use_lazy = self.provenance_is_retained();
1650 let (by_node, intern_rule): (&BTreeMap<u32, BTreeSet<Touch>>, &Vec<String>) = if use_lazy {
1651 self.ensure_provenance_loaded();
1652 let lp = self.lazy_provenance.get().unwrap();
1653 (&lp.by_node, &lp.intern_rule)
1654 } else {
1655 (&self.by_node, &self.intern_rule)
1656 };
1657 by_node
1658 .get(&node)
1659 .into_iter()
1660 .flatten()
1661 .map(move |&(rid, t, s, d)| (intern_rule[rid as usize].as_str(), t, s, d))
1662 }
1663
1664 pub fn provenance_touching_len(&self, node: u32) -> usize {
1666 if self.provenance_is_retained() {
1667 self.ensure_provenance_loaded();
1668 self.lazy_provenance
1669 .get()
1670 .unwrap()
1671 .by_node
1672 .get(&node)
1673 .map_or(0, BTreeSet::len)
1674 } else {
1675 self.by_node.get(&node).map_or(0, BTreeSet::len)
1676 }
1677 }
1678
1679 pub fn is_tripped(&self, name: &str) -> bool {
1682 self.tripped.get(name).copied().unwrap_or(false)
1683 }
1684
1685 pub fn fire_count(&self, name: &str) -> u64 {
1689 self.fires.get(name).copied().unwrap_or(0)
1690 }
1691
1692 pub fn drain_deltas(&mut self) -> Vec<EngineEdgeDelta> {
1706 std::mem::take(&mut self.pending_deltas)
1707 }
1708
1709 pub fn pending_delta_count(&self) -> usize {
1712 self.pending_deltas.len()
1713 }
1714
1715 pub fn pending_deltas_since(&self, cursor: usize) -> &[EngineEdgeDelta] {
1724 &self.pending_deltas[cursor..]
1725 }
1726
1727 #[allow(clippy::type_complexity)]
1731 pub fn to_persist(
1732 &self,
1733 ) -> (
1734 Vec<RuleDef>,
1735 BTreeMap<String, BTreeSet<(u32, u32, u32)>>,
1736 BTreeMap<String, bool>,
1737 BTreeMap<String, u64>,
1738 ) {
1739 (
1740 self.rules.values().cloned().collect(),
1741 self.provenance.clone(),
1742 self.tripped.clone(),
1743 self.fires.clone(),
1744 )
1745 }
1746
1747 pub fn from_persist(
1749 rules: Vec<RuleDef>,
1750 prov: BTreeMap<String, BTreeSet<(u32, u32, u32)>>,
1751 tripped: BTreeMap<String, bool>,
1752 fires: BTreeMap<String, u64>,
1753 ) -> Self {
1754 let mut owned = BTreeSet::new();
1755 for set in prov.values() {
1756 owned.extend(set.iter().copied());
1757 }
1758 let indexes = rules
1759 .iter()
1760 .map(|r| (r.name.clone(), RuleIndex::default()))
1761 .collect();
1762 let rules: BTreeMap<String, RuleDef> =
1763 rules.into_iter().map(|r| (r.name.clone(), r)).collect();
1764 let mut tripped = tripped;
1766 let mut fires = fires;
1767 for name in rules.keys() {
1768 tripped.entry(name.clone()).or_insert(false);
1769 fires.entry(name.clone()).or_insert(0);
1770 }
1771 let (by_node, rule_intern, intern_rule) = rebuild_by_node(&prov);
1772 Self {
1773 rules,
1774 indexes,
1775 provenance: prov,
1776 owned,
1777 by_node,
1778 rule_intern,
1779 intern_rule,
1780 tripped,
1781 fires,
1782 pending_deltas: Vec::new(),
1783 emit_deltas: false,
1784 rebuild_needed: BTreeSet::new(),
1785 indexes_populated: false,
1789 retained_hnsw_blobs: Mutex::new(BTreeMap::new()),
1790 retained_ivf_bytes: Mutex::new(None),
1791 retained_provenance_bytes: Mutex::new(None),
1792 lazy_provenance: OnceLock::new(),
1793 lazy_hnsw: OnceLock::new(),
1794 chain_depth: 0,
1795 chain_fired: BTreeSet::new(),
1796 doomed: None,
1797 chain_truncations: 0,
1798 }
1799 }
1800
1801 pub fn set_emit_deltas(&mut self, emit: bool) {
1807 self.emit_deltas = emit;
1808 }
1809
1810 pub fn emit_deltas(&self) -> bool {
1812 self.emit_deltas
1813 }
1814
1815 pub fn take_rebuild_needed(&mut self) -> Vec<String> {
1818 std::mem::take(&mut self.rebuild_needed)
1819 .into_iter()
1820 .collect()
1821 }
1822
1823 pub fn queue_rebuild_needed(&mut self, name: String) {
1827 self.rebuild_needed.insert(name);
1828 }
1829
1830 fn maybe_queue_ivf_rebuild(&mut self, rule_name: &str, def: &RuleDef) {
1831 if !def.approximate {
1832 return;
1833 }
1834 let Some(idx) = self.indexes.get(rule_name) else {
1835 return;
1836 };
1837 if idx.dst_side.ivf_drift > ivf_drift_rebuild_threshold() {
1838 self.rebuild_needed.insert(rule_name.to_string());
1839 }
1840 }
1841
1842 pub fn export_ivf_state(&self) -> BTreeMap<String, RuleIvfExport> {
1846 let mut out = BTreeMap::new();
1847 for (name, def) in &self.rules {
1848 if def.approximate {
1849 if let Some(idx) = self.indexes.get(name) {
1850 out.insert(
1851 name.clone(),
1852 (
1853 idx.src_side.export_ivf_state(),
1854 idx.dst_side.export_ivf_state(),
1855 ),
1856 );
1857 }
1858 }
1859 }
1860 out
1861 }
1862
1863 pub fn reindex_all(
1865 &mut self,
1866 ids: &IdMap,
1867 syms: &Interner,
1868 labels: &[u32],
1869 props: ColumnsView<'_>,
1870 ) {
1871 for idx in self.indexes.values_mut() {
1872 *idx = RuleIndex::default();
1873 }
1874 let rule_names: Vec<String> = self.rules.keys().cloned().collect();
1877
1878 for name in &rule_names {
1880 if self.rules[name].approximate {
1881 let idx = self.indexes.get_mut(name).unwrap();
1882 idx.src_side.init_hnsw(name);
1883 idx.dst_side.init_hnsw(name);
1884 }
1885 }
1886
1887 for id in 0..ids.len() as u32 {
1888 let label_sym = match labels.get(id as usize).copied() {
1889 Some(s) if s != u32::MAX => s,
1890 _ => continue,
1891 };
1892 for name in &rule_names {
1893 let def = self.rules[name].clone();
1894 let idx = self.indexes.get_mut(name).unwrap();
1895 index_node_for_rule(id, label_sym, &def, idx, syms, props);
1896 }
1897 }
1898 for name in &rule_names {
1901 if self.rules[name].approximate {
1902 let idx = self.indexes.get_mut(name).unwrap();
1903 idx.src_side.fit_ivf_clusters(name);
1904 idx.dst_side.fit_ivf_clusters(name);
1905 }
1906 }
1907 self.indexes_populated = true;
1908 }
1909
1910 pub fn reindex_all_load_ivf(
1920 &mut self,
1921 ids: &IdMap,
1922 syms: &Interner,
1923 labels: &[u32],
1924 props: ColumnsView<'_>,
1925 ivf_state: BTreeMap<String, RuleIvfExport>,
1926 ) {
1927 for idx in self.indexes.values_mut() {
1928 *idx = RuleIndex::default();
1929 }
1930 let rule_names: Vec<String> = self.rules.keys().cloned().collect();
1931
1932 for name in &rule_names {
1935 if self.rules[name].approximate {
1936 let idx = self.indexes.get_mut(name).unwrap();
1937 idx.src_side.init_hnsw(name);
1938 idx.dst_side.init_hnsw(name);
1939 }
1940 }
1941
1942 for id in 0..ids.len() as u32 {
1943 let label_sym = match labels.get(id as usize).copied() {
1944 Some(s) if s != u32::MAX => s,
1945 _ => continue,
1946 };
1947 for name in &rule_names {
1948 let def = self.rules[name].clone();
1949 let idx = self.indexes.get_mut(name).unwrap();
1950 index_node_for_rule(id, label_sym, &def, idx, syms, props);
1951 }
1952 }
1953 for name in &rule_names {
1957 if !self.rules[name].approximate {
1958 continue;
1959 }
1960 let idx = self.indexes.get_mut(name).unwrap();
1961 if let Some(((sc, sa, sd), (dc, da, dd))) = ivf_state.get(name) {
1962 idx.src_side.load_ivf_state(sc.clone(), sa.clone(), *sd);
1963 idx.dst_side.load_ivf_state(dc.clone(), da.clone(), *dd);
1964 } else {
1965 idx.src_side.fit_ivf_clusters(name);
1967 idx.dst_side.fit_ivf_clusters(name);
1968 }
1969 }
1970 self.indexes_populated = true;
1971 }
1972
1973 pub fn store_snapshot_state(
1981 &self,
1982 hnsw_blobs: BTreeMap<String, (Vec<u8>, Vec<u8>)>,
1983 ivf_bytes: Vec<u8>,
1984 ) {
1985 *self
1986 .retained_hnsw_blobs
1987 .lock()
1988 .expect("retained_hnsw_blobs lock poisoned") = hnsw_blobs;
1989 *self
1990 .retained_ivf_bytes
1991 .lock()
1992 .expect("retained_ivf_bytes lock poisoned") = if ivf_bytes.is_empty() {
1993 None
1994 } else {
1995 Some(ivf_bytes)
1996 };
1997 }
1999
2000 pub fn store_provenance_bytes(&self, bytes: Vec<u8>) {
2007 *self
2008 .retained_provenance_bytes
2009 .lock()
2010 .expect("lock poisoned") = if bytes.is_empty() { None } else { Some(bytes) };
2011 }
2012
2013 pub fn ensure_provenance_loaded(&self) {
2020 self.lazy_provenance.get_or_init(|| {
2021 let guard = self
2024 .retained_provenance_bytes
2025 .lock()
2026 .expect("retained_provenance_bytes lock poisoned");
2027 let bytes = match &*guard {
2028 Some(b) if !b.is_empty() => b,
2029 _ => return LazyProvenance::default(),
2030 };
2031 let prov = decode_provenance_bytes(bytes);
2032 let (by_node, _rule_intern, intern_rule) = rebuild_by_node(&prov);
2033 LazyProvenance {
2034 provenance: prov,
2035 by_node,
2036 intern_rule,
2037 }
2038 });
2039 }
2040
2041 pub fn ensure_provenance_loaded_mut(&mut self) {
2047 let bytes = match self
2048 .retained_provenance_bytes
2049 .lock()
2050 .expect("lock poisoned")
2051 .take()
2052 {
2053 Some(b) => b,
2054 None => return,
2055 };
2056 let prov = decode_provenance_bytes(&bytes);
2057 for set in prov.values() {
2058 self.owned.extend(set.iter().copied());
2059 }
2060 let (by_node, rule_intern, intern_rule) = rebuild_by_node(&prov);
2061 self.provenance = prov;
2062 self.by_node = by_node;
2063 self.rule_intern = rule_intern;
2064 self.intern_rule = intern_rule;
2065 }
2066
2067 pub fn consume_retained_state_eager(
2073 &mut self,
2074 ids: &IdMap,
2075 syms: &Interner,
2076 labels: &[u32],
2077 props: ColumnsView<'_>,
2078 ) {
2079 if self.indexes_populated {
2080 return;
2081 }
2082 self.ensure_provenance_loaded_mut();
2085 let hnsw = std::mem::take(
2086 &mut *self
2087 .retained_hnsw_blobs
2088 .lock()
2089 .expect("retained_hnsw_blobs lock poisoned"),
2090 );
2091 let ivf_bytes = self
2092 .retained_ivf_bytes
2093 .lock()
2094 .expect("retained_ivf_bytes lock poisoned")
2095 .take()
2096 .unwrap_or_default();
2097 let ivf = decode_ivf_bytes_to_export(&ivf_bytes);
2098 self.reindex_all_load_ivf(ids, syms, labels, props, ivf);
2099 self.load_hnsw_state(hnsw);
2101 }
2102
2103 pub fn ensure_hnsw_loaded(&self) {
2114 self.lazy_hnsw.get_or_init(|| {
2115 let snapshot: Vec<(String, Vec<u8>, Vec<u8>)> = {
2119 let guard = self
2120 .retained_hnsw_blobs
2121 .lock()
2122 .expect("retained_hnsw_blobs lock poisoned");
2123 if guard.is_empty() {
2124 return BTreeMap::new();
2125 }
2126 guard
2127 .iter()
2128 .map(|(name, (sb, db))| (name.clone(), sb.clone(), db.clone()))
2129 .collect()
2130 }; snapshot
2132 .into_iter()
2133 .map(|(name, sb, db)| {
2134 let src = if !sb.is_empty() {
2135 bincode::deserialize::<HnswIndex>(&sb).ok()
2136 } else {
2137 None
2138 };
2139 let dst = if !db.is_empty() {
2140 bincode::deserialize::<HnswIndex>(&db).ok()
2141 } else {
2142 None
2143 };
2144 (name, (src, dst))
2145 })
2146 .collect()
2147 });
2148 }
2149
2150 pub fn indexes_populated(&self) -> bool {
2153 self.indexes_populated
2154 }
2155
2156 pub fn export_hnsw_state(&self) -> BTreeMap<String, (Vec<u8>, Vec<u8>)> {
2161 let mut out = BTreeMap::new();
2162 for (name, def) in &self.rules {
2163 if def.approximate {
2164 if let Some(idx) = self.indexes.get(name) {
2165 out.insert(
2166 name.clone(),
2167 (
2168 idx.src_side.export_hnsw_blob(),
2169 idx.dst_side.export_hnsw_blob(),
2170 ),
2171 );
2172 }
2173 }
2174 }
2175 out
2176 }
2177
2178 pub fn export_hnsw_state_passthrough(&self) -> BTreeMap<String, (Vec<u8>, Vec<u8>)> {
2182 if !self.indexes_populated {
2183 let guard = self
2184 .retained_hnsw_blobs
2185 .lock()
2186 .expect("retained_hnsw_blobs lock poisoned");
2187 if !guard.is_empty() {
2188 return guard.clone();
2189 }
2190 }
2191 self.export_hnsw_state()
2192 }
2193
2194 pub fn retained_ivf_bytes_clone(&self) -> Option<Vec<u8>> {
2200 self.retained_ivf_bytes
2201 .lock()
2202 .expect("retained_ivf_bytes lock poisoned")
2203 .clone()
2204 }
2205
2206 pub fn load_hnsw_state(&mut self, blobs: BTreeMap<String, (Vec<u8>, Vec<u8>)>) {
2211 for (name, (src_blob, dst_blob)) in blobs {
2212 if let Some(idx) = self.indexes.get_mut(&name) {
2213 if !src_blob.is_empty() {
2214 idx.src_side.load_hnsw_blob(&src_blob);
2215 }
2216 if !dst_blob.is_empty() {
2217 idx.dst_side.load_hnsw_blob(&dst_blob);
2218 }
2219 }
2220 }
2221 }
2222
2223 pub fn hnsw_search_dst(
2228 &self,
2229 field: &str,
2230 dst_label: &str,
2231 q: &[f64],
2232 k: usize,
2233 ) -> Option<Vec<(u32, f64)>> {
2234 for (name, def) in &self.rules {
2235 if !def.approximate || def.dst_label != dst_label {
2236 continue;
2237 }
2238 if !predicate_covers_field(&def.predicate, field) {
2240 continue;
2241 }
2242 if let Some(idx) = self.indexes.get(name) {
2243 if let Some(h) = idx.dst_side.hnsw_ref() {
2244 if !h.is_empty() {
2245 return Some(h.search(q, k));
2246 }
2247 }
2248 }
2249 if let Some(lazy) = self.lazy_hnsw.get() {
2252 if let Some((_, Some(h))) = lazy.get(name) {
2253 if !h.is_empty() {
2254 return Some(h.search(q, k));
2255 }
2256 }
2257 }
2258 }
2259 None
2260 }
2261
2262 pub fn hnsw_has_rule(&self, field: &str) -> bool {
2269 self.rules
2270 .values()
2271 .any(|def| def.approximate && predicate_covers_field(&def.predicate, field))
2272 }
2273
2274 pub fn hnsw_search_any_dst(&self, field: &str, q: &[f64], k: usize) -> Option<Vec<(u32, f64)>> {
2284 let mut merged: std::collections::BTreeMap<u32, f64> = std::collections::BTreeMap::new();
2285 let mut found_index = false;
2286
2287 for (name, def) in &self.rules {
2288 if !def.approximate {
2289 continue;
2290 }
2291 if !predicate_covers_field(&def.predicate, field) {
2292 continue;
2293 }
2294 let hits: Option<Vec<(u32, f64)>> = if let Some(idx) = self.indexes.get(name) {
2295 if let Some(h) = idx.dst_side.hnsw_ref() {
2296 if !h.is_empty() {
2297 found_index = true;
2298 Some(h.search(q, k))
2299 } else {
2300 None
2301 }
2302 } else {
2303 None
2304 }
2305 } else if let Some(lazy) = self.lazy_hnsw.get() {
2306 if let Some((_, Some(h))) = lazy.get(name) {
2307 if !h.is_empty() {
2308 found_index = true;
2309 Some(h.search(q, k))
2310 } else {
2311 None
2312 }
2313 } else {
2314 None
2315 }
2316 } else {
2317 None
2318 };
2319
2320 if let Some(hits) = hits {
2321 for (id, score) in hits {
2322 merged
2323 .entry(id)
2324 .and_modify(|s| {
2325 if score > *s {
2326 *s = score;
2327 }
2328 })
2329 .or_insert(score);
2330 }
2331 }
2332 }
2333
2334 if !found_index {
2335 return None;
2336 }
2337 let mut result: Vec<(u32, f64)> = merged.into_iter().collect();
2338 result.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
2339 result.truncate(k);
2340 Some(result)
2341 }
2342
2343 pub fn create_rule(&mut self, def: RuleDef, g: &mut GraphMut<'_>) -> Result<(), String> {
2346 def.validate()?;
2347 if self.rules.contains_key(&def.name) {
2348 return Err(format!("rule {:?} already exists", def.name));
2349 }
2350 let scope = self.begin_chain();
2354 let name = def.name.clone();
2355 self.rules.insert(name.clone(), def);
2356 self.indexes.insert(name.clone(), RuleIndex::default());
2357 self.provenance.entry(name.clone()).or_default();
2358 self.tripped.insert(name.clone(), false);
2359 self.fires.insert(name.clone(), 0);
2360
2361 let n_total = g.ids.len() as u32;
2363 let def = self.rules[&name].clone();
2364
2365 if def.approximate {
2368 let idx = self.indexes.get_mut(&name).unwrap();
2369 idx.src_side.init_hnsw(&name);
2370 idx.dst_side.init_hnsw(&name);
2371 }
2372
2373 for id in 0..n_total {
2374 let label_sym = match g.labels.get(id as usize).copied() {
2375 Some(s) if s != u32::MAX => s,
2376 _ => continue,
2377 };
2378 let idx = self.indexes.get_mut(&name).unwrap();
2379 index_node_for_rule(id, label_sym, &def, idx, g.syms, g.props);
2380 }
2381
2382 if def.approximate {
2385 let idx = self.indexes.get_mut(&name).unwrap();
2386 idx.src_side.fit_ivf_clusters(&name);
2387 idx.dst_side.fit_ivf_clusters(&name);
2388 }
2389
2390 let mut prov = ProvSets {
2395 set: self.provenance.get_mut(&name).unwrap(),
2396 owned: &mut self.owned,
2397 by_node: &mut self.by_node,
2398 rule_intern: &mut self.rule_intern,
2399 intern_rule: &mut self.intern_rule,
2400 deltas: &mut self.pending_deltas,
2401 emit: self.emit_deltas,
2402 };
2403 if def.via_label.is_some() {
2404 let budget = edge_budget(&def);
2406 let et = g.syms.intern(&def.edge_type);
2407 let src_sym = g.syms.get(&def.src_label);
2408 let tripped = self.tripped.get_mut(&name).unwrap();
2409 'via_outer: for id in 0..g.ids.len() as u32 {
2410 let label_sym = match g.labels.get(id as usize).copied() {
2411 Some(s) if s != u32::MAX => s,
2412 _ => continue,
2413 };
2414 if src_sym != Some(label_sym) {
2415 continue;
2416 }
2417 let per_src = compute_desired_via(&def, ViaAnchor::Src(id), self.doomed, g);
2418 if let Some(k) = def.max_edges {
2419 let top_k = filter_src_top_k(per_src, k, g.ids);
2420 apply_per_src_top_k(&def, id, top_k, &mut prov, g);
2421 } else {
2422 for ((s, d), score) in per_src {
2423 let triple = (et, s, d);
2424 let already = prov.contains(&triple);
2425 if !already {
2426 if *tripped || prov.len() as u64 >= budget {
2427 *tripped = true;
2428 break 'via_outer;
2429 }
2430 let newly = g.topo.add_edge(et, s, d);
2431 if newly {
2432 prov.insert(&name, triple, g.ids, g.syms);
2433 }
2434 }
2435 let is_owned_here = already || prov.contains(&triple);
2436 if is_owned_here {
2437 if let Some(p) = &def.weight_prop {
2438 g.edge_props.set(et, s, d, p, Value::Float(score));
2439 }
2440 }
2441 }
2442 }
2443 }
2444 } else if let Some(k) = def.max_edges {
2445 apply_streaming_create_top_k(&def, k, &self.indexes[&name], &mut prov, g);
2446 } else {
2447 let tripped = self.tripped.get_mut(&name).unwrap();
2448 apply_streaming_create(&def, &self.indexes[&name], &mut prov, tripped, g);
2449 }
2450 let fires = self.fires.get_mut(&name).unwrap();
2453 bump_fires_for_participants(&def, g, fires);
2454
2455 self.indexes_populated = true;
2459
2460 self.end_chain(scope, g);
2461 Ok(())
2462 }
2463
2464 pub fn delete_rule(&mut self, name: &str, g: &mut GraphMut<'_>) -> Result<(), String> {
2466 if !self.rules.contains_key(name) {
2467 return Err(format!("rule {:?} not found", name));
2468 }
2469 let scope = self.begin_chain();
2473 let def = self.rules.remove(name).unwrap();
2474 self.indexes.remove(name);
2475 self.tripped.remove(name);
2476 self.fires.remove(name);
2477 let mut leftover = self.provenance.remove(name).unwrap_or_default();
2478 let _et = g.syms.intern(&def.edge_type);
2480 let triples: Vec<Triple> = leftover.iter().copied().collect();
2481 let mut sets = ProvSets {
2482 set: &mut leftover,
2483 owned: &mut self.owned,
2484 by_node: &mut self.by_node,
2485 rule_intern: &mut self.rule_intern,
2486 intern_rule: &mut self.intern_rule,
2487 deltas: &mut self.pending_deltas,
2488 emit: self.emit_deltas,
2489 };
2490 for triple in triples {
2491 let (t, s, d) = triple;
2492 g.topo.remove_edge(t, s, d);
2493 g.edge_props.remove_edge(t, s, d);
2494 sets.remove(name, triple, g.ids, g.syms);
2495 }
2496 let same_etype_survivors: Vec<String> = self
2502 .rules
2503 .values()
2504 .filter(|r| r.edge_type == def.edge_type)
2505 .map(|r| r.name.clone())
2506 .collect();
2507 for survivor in same_etype_survivors {
2508 let _ = self.rebuild_inner(&survivor, g);
2510 }
2511 self.end_chain(scope, g);
2512 Ok(())
2513 }
2514
2515 pub fn on_node_changed(
2529 &mut self,
2530 n: u32,
2531 changed: Option<(&str, Option<Value>)>,
2532 g: &mut GraphMut<'_>,
2533 ) {
2534 let scope = self.begin_chain();
2535 self.on_node_changed_inner(n, changed, g);
2536 self.end_chain(scope, g);
2537 }
2538
2539 fn on_node_changed_inner(
2540 &mut self,
2541 n: u32,
2542 changed: Option<(&str, Option<Value>)>,
2543 g: &mut GraphMut<'_>,
2544 ) {
2545 self.ensure_provenance_loaded_mut();
2547 if !self.indexes_populated && !self.rules.is_empty() {
2553 let hnsw = std::mem::take(
2554 &mut *self
2555 .retained_hnsw_blobs
2556 .lock()
2557 .expect("retained_hnsw_blobs lock poisoned"),
2558 );
2559 let ivf_bytes = self
2560 .retained_ivf_bytes
2561 .lock()
2562 .expect("retained_ivf_bytes lock poisoned")
2563 .take()
2564 .unwrap_or_default();
2565 let ivf = decode_ivf_bytes_to_export(&ivf_bytes);
2566 self.reindex_all_load_ivf(g.ids, g.syms, g.labels, g.props, ivf);
2567 self.load_hnsw_state(hnsw);
2568 }
2569
2570 let n_label = g.labels.get(n as usize).copied();
2571 let rule_names: Vec<String> = self.rules.keys().cloned().collect();
2572
2573 for rule_name in rule_names {
2574 let def = self.rules[&rule_name].clone();
2575
2576 if def.via_label.is_some() {
2577 self.on_node_changed_via(&rule_name, &def, n, n_label, changed.clone(), g);
2579 } else {
2580 let src_sym = g.syms.get(&def.src_label);
2582 let dst_sym = g.syms.get(&def.dst_label);
2583 let as_src = src_sym.is_some() && n_label == src_sym;
2584 let as_dst = dst_sym.is_some() && n_label == dst_sym;
2585
2586 let fires = match changed {
2587 None => as_src || as_dst,
2588 Some((field, _)) => def.watched_fields().contains(field) && (as_src || as_dst),
2589 };
2590 if !fires {
2591 continue;
2592 }
2593 *self.fires.entry(rule_name.clone()).or_default() += 1;
2594
2595 if let Some((field, ref old_val)) = changed {
2597 let old_val_cloned = old_val.clone();
2598 let old_getter = |f: &str| {
2599 if f == field {
2600 old_val_cloned.clone()
2601 } else {
2602 g.props.get(n, f).map(|vr| vr.into_value())
2603 }
2604 };
2605 let idx = self.indexes.get_mut(&rule_name).unwrap();
2606 if as_src {
2607 let spec = src_lookup_spec_for(&def);
2608 idx.src_side.remove(&spec, n, &old_getter);
2609 }
2610 if as_dst {
2611 let spec = candidate_spec_for(&def);
2612 idx.dst_side.remove(&spec, n, &old_getter);
2613 }
2614 }
2615
2616 {
2617 let cur_getter = |f: &str| g.props.get(n, f).map(|vr| vr.into_value());
2618 let idx = self.indexes.get_mut(&rule_name).unwrap();
2619 if as_src {
2620 let spec = src_lookup_spec_for(&def);
2621 idx.src_side.insert(&spec, n, &cur_getter);
2622 }
2623 if as_dst {
2624 let spec = candidate_spec_for(&def);
2625 idx.dst_side.insert(&spec, n, &cur_getter);
2626 }
2627 }
2628
2629 self.maybe_queue_ivf_rebuild(&rule_name, &def);
2630
2631 if let Some(k) = def.max_edges {
2633 let et = g.syms.intern(&def.edge_type);
2634 let affected_srcs_for_n_dst: BTreeSet<u32> = if as_dst {
2635 let rid = self.rule_intern.get(&def.name).copied();
2636 self.by_node
2637 .get(&n)
2638 .into_iter()
2639 .flatten()
2640 .filter(|(r, t, _s, d)| Some(*r) == rid && *t == et && *d == n)
2641 .map(|(_, _, s, _)| *s)
2642 .collect()
2643 } else {
2644 BTreeSet::new()
2645 };
2646
2647 let mut prov = ProvSets {
2648 set: self.provenance.entry(rule_name.clone()).or_default(),
2649 owned: &mut self.owned,
2650 by_node: &mut self.by_node,
2651 rule_intern: &mut self.rule_intern,
2652 intern_rule: &mut self.intern_rule,
2653 deltas: &mut self.pending_deltas,
2654 emit: self.emit_deltas,
2655 };
2656
2657 if as_src {
2658 let desired_n_src =
2659 compute_desired(&def, &self.indexes[&rule_name], n, true, g);
2660 let top_k = filter_src_top_k(desired_n_src, k, g.ids);
2661 apply_per_src_top_k(&def, n, top_k, &mut prov, g);
2662 }
2663
2664 if as_dst {
2665 let new_desired =
2666 compute_desired(&def, &self.indexes[&rule_name], n, false, g);
2667 let new_srcs: BTreeSet<u32> = new_desired.keys().map(|(s, _)| *s).collect();
2668 let affected_srcs: BTreeSet<u32> =
2669 affected_srcs_for_n_dst.union(&new_srcs).copied().collect();
2670 for src in affected_srcs {
2671 if src == n {
2672 continue;
2673 }
2674 let desired_src =
2675 compute_desired(&def, &self.indexes[&rule_name], src, true, g);
2676 let top_k = filter_src_top_k(desired_src, k, g.ids);
2677 apply_per_src_top_k(&def, src, top_k, &mut prov, g);
2678 }
2679 }
2680 } else {
2681 let mut desired = BTreeMap::new();
2682 if as_src {
2683 desired.extend(compute_desired(
2684 &def,
2685 &self.indexes[&rule_name],
2686 n,
2687 true,
2688 g,
2689 ));
2690 }
2691 if as_dst {
2692 desired.extend(compute_desired(
2693 &def,
2694 &self.indexes[&rule_name],
2695 n,
2696 false,
2697 g,
2698 ));
2699 }
2700 let tripped = self.tripped.entry(rule_name.clone()).or_default();
2701 apply_desired(
2702 &def,
2703 desired,
2704 Some(n),
2705 &mut ProvSets {
2706 set: self.provenance.entry(rule_name).or_default(),
2707 owned: &mut self.owned,
2708 by_node: &mut self.by_node,
2709 rule_intern: &mut self.rule_intern,
2710 intern_rule: &mut self.intern_rule,
2711 deltas: &mut self.pending_deltas,
2712 emit: self.emit_deltas,
2713 },
2714 tripped,
2715 g,
2716 );
2717 }
2718 }
2719 }
2720 }
2721
2722 fn on_node_changed_via(
2737 &mut self,
2738 rule_name: &str,
2739 def: &RuleDef,
2740 n: u32,
2741 n_label: Option<u32>,
2742 changed: Option<(&str, Option<Value>)>,
2743 g: &mut GraphMut<'_>,
2744 ) {
2745 let doomed = self.doomed;
2746 let src_sym = g.syms.get(&def.src_label);
2747 let dst_sym = g.syms.get(&def.dst_label);
2748 let via_sym = def.via_label.as_deref().and_then(|l| g.syms.get(l));
2749
2750 let as_src = src_sym.is_some() && n_label == src_sym;
2751 let as_dst = dst_sym.is_some() && n_label == dst_sym;
2752 let as_via = via_sym.is_some() && n_label == via_sym;
2753
2754 let fires = match changed {
2758 None => as_src || as_via || as_dst,
2759 Some((field, _)) => {
2760 let wf = def.watched_fields();
2761 (wf.contains(field)) && (as_src || as_via || as_dst)
2762 }
2763 };
2764 if !fires {
2765 return;
2766 }
2767 *self.fires.entry(rule_name.to_string()).or_default() += 1;
2768
2769 let mut affected_srcs: BTreeSet<u32> = BTreeSet::new();
2771 if as_src {
2772 affected_srcs.insert(n);
2773 }
2774 if as_via {
2775 let via_edge_str = def.via_edge.as_deref().unwrap();
2777 let via_dir = def.via_dir.unwrap_or(core_storage::Direction::Out);
2778 let rev_dir = match via_dir {
2779 core_storage::Direction::Out => core_storage::Direction::In,
2780 core_storage::Direction::In => core_storage::Direction::Out,
2781 };
2782 if let (Some(via_etype), Some(s_sym)) = (g.syms.get(via_edge_str), src_sym) {
2783 for &src in g.topo.neighbors(via_etype, rev_dir, n).as_ref() {
2784 if g.labels.get(src as usize).copied() == Some(s_sym) {
2785 affected_srcs.insert(src);
2786 }
2787 }
2788 }
2789 }
2790 if as_dst {
2791 let desired_touching_n = compute_desired_via(def, ViaAnchor::Dst(n), doomed, g);
2793 for (src, _dst) in desired_touching_n.keys() {
2794 affected_srcs.insert(*src);
2795 }
2796 let et = g.syms.intern(&def.edge_type);
2798 let rid = self.rule_intern.get(rule_name).copied();
2799 let old_srcs: Vec<u32> = self
2800 .by_node
2801 .get(&n)
2802 .into_iter()
2803 .flatten()
2804 .filter(|(r, t, _s, d)| Some(*r) == rid && *t == et && *d == n)
2805 .map(|(_, _, s, _)| *s)
2806 .collect();
2807 affected_srcs.extend(old_srcs);
2808 }
2809
2810 let affected_srcs: Vec<u32> = affected_srcs.into_iter().collect();
2812
2813 if let Some(k) = def.max_edges {
2814 let mut prov = ProvSets {
2815 set: self.provenance.entry(rule_name.to_string()).or_default(),
2816 owned: &mut self.owned,
2817 by_node: &mut self.by_node,
2818 rule_intern: &mut self.rule_intern,
2819 intern_rule: &mut self.intern_rule,
2820 deltas: &mut self.pending_deltas,
2821 emit: self.emit_deltas,
2822 };
2823 for src in affected_srcs {
2824 let desired_src = compute_desired_via(def, ViaAnchor::Src(src), doomed, g);
2825 let top_k = filter_src_top_k(desired_src, k, g.ids);
2826 apply_per_src_top_k(def, src, top_k, &mut prov, g);
2827 }
2828 } else {
2829 let tripped = self.tripped.entry(rule_name.to_string()).or_default();
2830 let budget = edge_budget(def);
2831 for src in affected_srcs {
2834 let desired_src = compute_desired_via(def, ViaAnchor::Src(src), doomed, g);
2835 if !*tripped {
2836 let mut prov = ProvSets {
2837 set: self.provenance.entry(rule_name.to_string()).or_default(),
2838 owned: &mut self.owned,
2839 by_node: &mut self.by_node,
2840 rule_intern: &mut self.rule_intern,
2841 intern_rule: &mut self.intern_rule,
2842 deltas: &mut self.pending_deltas,
2843 emit: self.emit_deltas,
2844 };
2845 apply_desired(def, desired_src, Some(src), &mut prov, tripped, g);
2846 }
2847 let _ = budget;
2851 }
2852 }
2853 }
2854
2855 pub fn on_edge_changed(
2869 &mut self,
2870 etype_str: &str,
2871 src_id: u32,
2872 dst_id: u32,
2873 g: &mut GraphMut<'_>,
2874 ) {
2875 let scope = self.begin_chain();
2876 self.on_edge_changed_inner(etype_str, src_id, dst_id, g);
2877 self.end_chain(scope, g);
2878 }
2879
2880 fn on_edge_changed_inner(
2881 &mut self,
2882 etype_str: &str,
2883 src_id: u32,
2884 dst_id: u32,
2885 g: &mut GraphMut<'_>,
2886 ) {
2887 self.ensure_provenance_loaded_mut();
2889 if !self.indexes_populated && !self.rules.is_empty() {
2892 let hnsw = std::mem::take(
2893 &mut *self
2894 .retained_hnsw_blobs
2895 .lock()
2896 .expect("retained_hnsw_blobs lock poisoned"),
2897 );
2898 let ivf_bytes = self
2899 .retained_ivf_bytes
2900 .lock()
2901 .expect("retained_ivf_bytes lock poisoned")
2902 .take()
2903 .unwrap_or_default();
2904 let ivf = decode_ivf_bytes_to_export(&ivf_bytes);
2905 self.reindex_all_load_ivf(g.ids, g.syms, g.labels, g.props, ivf);
2906 self.load_hnsw_state(hnsw);
2907 }
2908
2909 let rule_names: Vec<String> = self.rules.keys().cloned().collect();
2910 for (rule_idx, rule_name) in rule_names.into_iter().enumerate() {
2911 let def = self.rules[&rule_name].clone();
2912 let Some(ref via_edge) = def.via_edge else {
2913 continue; };
2915 if via_edge != etype_str {
2916 continue; }
2918
2919 let src_sym = match g.syms.get(&def.src_label) {
2921 Some(s) => s,
2922 None => continue,
2923 };
2924 let via_sym = match def.via_label.as_deref().and_then(|l| g.syms.get(l)) {
2925 Some(s) => s,
2926 None => continue,
2927 };
2928 let via_dir = def.via_dir.unwrap_or(core_storage::Direction::Out);
2932 let (rule_src, rule_via) = match via_dir {
2933 core_storage::Direction::Out => (src_id, dst_id),
2934 core_storage::Direction::In => (dst_id, src_id),
2935 };
2936
2937 if g.labels.get(rule_src as usize).copied() != Some(src_sym) {
2938 continue;
2939 }
2940 if g.labels.get(rule_via as usize).copied() != Some(via_sym) {
2941 continue;
2942 }
2943
2944 if self.chain_depth > 0 && !self.chain_fired.insert((rule_idx as u32, rule_src)) {
2955 continue;
2956 }
2957
2958 *self.fires.entry(rule_name.clone()).or_default() += 1;
2960 let desired_src = compute_desired_via(&def, ViaAnchor::Src(rule_src), self.doomed, g);
2961
2962 if let Some(k) = def.max_edges {
2963 let mut prov = ProvSets {
2964 set: self.provenance.entry(rule_name).or_default(),
2965 owned: &mut self.owned,
2966 by_node: &mut self.by_node,
2967 rule_intern: &mut self.rule_intern,
2968 intern_rule: &mut self.intern_rule,
2969 deltas: &mut self.pending_deltas,
2970 emit: self.emit_deltas,
2971 };
2972 let top_k = filter_src_top_k(desired_src, k, g.ids);
2973 apply_per_src_top_k(&def, rule_src, top_k, &mut prov, g);
2974 } else {
2975 let tripped = self.tripped.entry(rule_name.clone()).or_default();
2976 let mut prov = ProvSets {
2977 set: self.provenance.entry(rule_name).or_default(),
2978 owned: &mut self.owned,
2979 by_node: &mut self.by_node,
2980 rule_intern: &mut self.rule_intern,
2981 intern_rule: &mut self.intern_rule,
2982 deltas: &mut self.pending_deltas,
2983 emit: self.emit_deltas,
2984 };
2985 apply_desired(&def, desired_src, Some(rule_src), &mut prov, tripped, g);
2986 }
2987 }
2988 }
2989
2990 pub fn on_node_removed(&mut self, n: u32, g: &mut GraphMut<'_>) {
3001 let prev_doomed = self.doomed;
3008 self.doomed = Some(n);
3009 let scope = self.begin_chain();
3010 self.on_node_removed_inner(n, g);
3011 self.end_chain(scope, g);
3012 self.doomed = prev_doomed;
3013 }
3014
3015 fn on_node_removed_inner(&mut self, n: u32, g: &mut GraphMut<'_>) {
3016 self.ensure_provenance_loaded_mut();
3018 if !self.indexes_populated && !self.rules.is_empty() {
3023 let hnsw = std::mem::take(
3024 &mut *self
3025 .retained_hnsw_blobs
3026 .lock()
3027 .expect("retained_hnsw_blobs lock poisoned"),
3028 );
3029 let ivf_bytes = self
3030 .retained_ivf_bytes
3031 .lock()
3032 .expect("retained_ivf_bytes lock poisoned")
3033 .take()
3034 .unwrap_or_default();
3035 let ivf = decode_ivf_bytes_to_export(&ivf_bytes);
3036 self.reindex_all_load_ivf(g.ids, g.syms, g.labels, g.props, ivf);
3037 self.load_hnsw_state(hnsw);
3038 }
3039
3040 let n_label = g.labels.get(n as usize).copied();
3041 let rule_names: Vec<String> = self.rules.keys().cloned().collect();
3042
3043 for rule_name in rule_names {
3044 let def = self.rules[&rule_name].clone();
3045 let src_sym = g.syms.get(&def.src_label);
3046 let dst_sym = g.syms.get(&def.dst_label);
3047 let as_src = src_sym.is_some() && n_label == src_sym;
3048 let as_dst = dst_sym.is_some() && n_label == dst_sym;
3049
3050 {
3051 let cur_getter = |f: &str| g.props.get(n, f).map(|vr| vr.into_value());
3052 let idx = self.indexes.get_mut(&rule_name).unwrap();
3053 if as_src {
3054 let spec = src_lookup_spec_for(&def);
3055 idx.src_side.remove(&spec, n, &cur_getter);
3056 }
3057 if as_dst {
3058 let spec = candidate_spec_for(&def);
3059 idx.dst_side.remove(&spec, n, &cur_getter);
3060 }
3061 }
3062
3063 self.maybe_queue_ivf_rebuild(&rule_name, &def);
3064 }
3065
3066 let touching: Vec<(String, Triple)> = self
3067 .by_node
3068 .get(&n)
3069 .into_iter()
3070 .flatten()
3071 .map(|&(rid, t, s, d)| (self.intern_rule[rid as usize].clone(), (t, s, d)))
3072 .collect();
3073
3074 let topk_backfill: Vec<(String, u32)> = touching
3078 .iter()
3079 .filter_map(|(rule_name, triple)| {
3080 let &(_, s, d) = triple;
3081 let def = self.rules.get(rule_name)?;
3082 def.max_edges?; if d == n && s != n {
3084 Some((rule_name.clone(), s))
3085 } else {
3086 None
3087 }
3088 })
3089 .collect();
3090
3091 for (rule_name, triple) in touching {
3092 let (t, s, d) = triple;
3093 g.topo.remove_edge(t, s, d);
3094 g.edge_props.remove_edge(t, s, d);
3095 if let Some(set) = self.provenance.get_mut(&rule_name) {
3096 ProvSets {
3097 set,
3098 owned: &mut self.owned,
3099 by_node: &mut self.by_node,
3100 rule_intern: &mut self.rule_intern,
3101 intern_rule: &mut self.intern_rule,
3102 deltas: &mut self.pending_deltas,
3103 emit: self.emit_deltas,
3104 }
3105 .remove(&rule_name, triple, g.ids, g.syms);
3106 }
3107 }
3108
3109 for (rule_name, src) in topk_backfill {
3114 let def = self.rules[&rule_name].clone();
3115 let k = def.max_edges.unwrap(); let desired_src = if def.via_edge.is_some() {
3120 compute_desired_via(&def, ViaAnchor::Src(src), self.doomed, g)
3121 } else {
3122 compute_desired(&def, &self.indexes[&rule_name], src, true, g)
3123 };
3124 let top_k = filter_src_top_k(desired_src, k, g.ids);
3125 let mut prov = ProvSets {
3126 set: self.provenance.entry(rule_name.clone()).or_default(),
3127 owned: &mut self.owned,
3128 by_node: &mut self.by_node,
3129 rule_intern: &mut self.rule_intern,
3130 intern_rule: &mut self.intern_rule,
3131 deltas: &mut self.pending_deltas,
3132 emit: self.emit_deltas,
3133 };
3134 apply_per_src_top_k(&def, src, top_k, &mut prov, g);
3135 }
3136 }
3137
3138 pub fn rebuild(&mut self, name: &str, g: &mut GraphMut<'_>) -> Result<(), String> {
3151 let scope = self.begin_chain();
3152 let out = self.rebuild_inner(name, g);
3153 self.end_chain(scope, g);
3154 out
3155 }
3156
3157 fn rebuild_inner(&mut self, name: &str, g: &mut GraphMut<'_>) -> Result<(), String> {
3161 if !self.rules.contains_key(name) {
3162 return Err(format!("rule {:?} not found", name));
3163 }
3164 self.rebuild_needed.remove(name);
3165 let def = self.rules[name].clone();
3166
3167 *self.indexes.get_mut(name).unwrap() = RuleIndex::default();
3169
3170 if def.approximate {
3172 let idx = self.indexes.get_mut(name).unwrap();
3173 idx.src_side.init_hnsw(name);
3174 idx.dst_side.init_hnsw(name);
3175 }
3176
3177 let n_total = g.ids.len() as u32;
3178 for id in 0..n_total {
3179 let label_sym = match g.labels.get(id as usize).copied() {
3180 Some(s) if s != u32::MAX => s,
3181 _ => continue,
3182 };
3183 let idx = self.indexes.get_mut(name).unwrap();
3184 index_node_for_rule(id, label_sym, &def, idx, g.syms, g.props);
3185 }
3186
3187 if def.approximate {
3190 let idx = self.indexes.get_mut(name).unwrap();
3191 idx.src_side.fit_ivf_clusters(name);
3192 idx.dst_side.fit_ivf_clusters(name);
3193 }
3194
3195 let doomed = self.doomed;
3202 let mut prov = ProvSets {
3203 set: self.provenance.get_mut(name).unwrap(),
3204 owned: &mut self.owned,
3205 by_node: &mut self.by_node,
3206 rule_intern: &mut self.rule_intern,
3207 intern_rule: &mut self.intern_rule,
3208 deltas: &mut self.pending_deltas,
3209 emit: self.emit_deltas,
3210 };
3211 if let Some(k) = def.max_edges {
3212 apply_streaming_rebuild_top_k(&def, k, &self.indexes[name], doomed, &mut prov, g);
3213 } else {
3214 let tripped = self.tripped.get_mut(name).unwrap();
3215 if def.via_edge.is_some() {
3216 apply_via_rebuild(&def, doomed, &mut prov, tripped, g);
3217 } else {
3218 apply_streaming_rebuild(&def, &self.indexes[name], &mut prov, tripped, g);
3219 }
3220 }
3221 let fires = self.fires.entry(name.to_string()).or_default();
3222 bump_fires_for_participants(&def, g, fires);
3223
3224 Ok(())
3225 }
3226
3227 #[cfg(test)]
3228 fn by_node_consistent(&self) -> bool {
3229 let (rebuilt, intern, names) = rebuild_by_node(&self.provenance);
3230 resolve_by_node(&self.by_node, &self.intern_rule) == resolve_by_node(&rebuilt, &names)
3231 && intern.len() == names.len()
3232 }
3233}
3234
3235#[cfg(test)]
3240mod tests {
3241 use super::*;
3242 use crate::def::{evaluate, NodeView, Predicate, RuleDef};
3243 use core_storage::{ColumnStore, Direction, EdgeProps, IdMap, Interner, Topology, Value};
3244
3245 struct Fx {
3246 ids: IdMap,
3247 syms: Interner,
3248 labels: Vec<u32>,
3249 props: ColumnStore,
3250 topo: Topology,
3251 eprops: EdgeProps,
3252 }
3253 impl Fx {
3254 fn new() -> Self {
3255 Fx {
3256 ids: IdMap::new(),
3257 syms: Interner::new(),
3258 labels: vec![],
3259 props: ColumnStore::new(),
3260 topo: Topology::new(),
3261 eprops: EdgeProps::new(),
3262 }
3263 }
3264 fn add(&mut self, label: &str, key: &str, props: Vec<(&str, Value)>) -> u32 {
3265 let id = self.ids.get_or_insert(key);
3266 let sym = self.syms.intern(label);
3267 self.labels.resize(id as usize + 1, u32::MAX);
3268 self.labels[id as usize] = sym;
3269 for (f, v) in props {
3270 self.props.set(id, f, v);
3271 }
3272 id
3273 }
3274 fn g(&mut self) -> GraphMut<'_> {
3275 GraphMut {
3276 ids: &self.ids,
3277 syms: &mut self.syms,
3278 labels: &self.labels,
3279 props: ColumnsView::owned(&self.props),
3280 topo: &mut self.topo,
3281 edge_props: &mut self.eprops,
3282 }
3283 }
3284 }
3285
3286 fn tags(items: &[&str]) -> Value {
3287 Value::List(items.iter().map(|s| Value::Str((*s).into())).collect())
3288 }
3289
3290 fn overlap_rule() -> RuleDef {
3291 RuleDef {
3292 name: "rel".into(),
3293 src_label: "A".into(),
3294 dst_label: "A".into(),
3295 predicate: Predicate::Overlap {
3296 field: "tags".into(),
3297 min: 0.4,
3298 },
3299 edge_type: "REL".into(),
3300 weight_prop: Some("score".into()),
3301 max_edges: None,
3302 approximate: false,
3303 via_label: None,
3304 via_edge: None,
3305 via_dir: None,
3306 }
3307 }
3308
3309 fn emb(xs: &[f64]) -> Value {
3310 Value::List(xs.iter().copied().map(Value::Float).collect())
3311 }
3312
3313 fn approx_vec_rule() -> RuleDef {
3314 RuleDef {
3315 name: "sim".into(),
3316 src_label: "V".into(),
3317 dst_label: "V".into(),
3318 predicate: Predicate::VectorSimilar {
3319 field: "emb".into(),
3320 min: 0.5,
3321 },
3322 edge_type: "SIM".into(),
3323 weight_prop: None,
3324 max_edges: None,
3325 approximate: true,
3326 via_label: None,
3327 via_edge: None,
3328 via_dir: None,
3329 }
3330 }
3331
3332 #[test]
3333 fn approximate_rule_rebuilds_after_drift_threshold() {
3334 with_ivf_drift_rebuild(1, || {
3335 let mut fx = Fx::new();
3336 let mut ids = Vec::new();
3337 for i in 0..6 {
3338 let x = i as f64 * 0.2;
3339 ids.push(fx.add("V", &format!("v{i}"), vec![("emb", emb(&[x, 1.0 - x]))]));
3340 }
3341 let mut eng = RuleEngine::new();
3342 {
3343 let mut g = fx.g();
3344 eng.create_rule(approx_vec_rule(), &mut g).unwrap();
3345 }
3346 assert!(eng.take_rebuild_needed().is_empty());
3347 {
3348 let mut g = fx.g();
3349 eng.on_node_removed(ids[0], &mut g);
3350 }
3351 assert!(
3352 eng.take_rebuild_needed().is_empty(),
3353 "drift=1 is not > threshold 1"
3354 );
3355 {
3356 let mut g = fx.g();
3357 eng.on_node_removed(ids[1], &mut g);
3358 }
3359 assert_eq!(eng.take_rebuild_needed(), vec!["sim".to_string()]);
3360 {
3361 let mut g = fx.g();
3362 eng.rebuild("sim", &mut g).unwrap();
3363 }
3364 assert!(
3365 eng.take_rebuild_needed().is_empty(),
3366 "rebuild must reset drift and not re-queue itself"
3367 );
3368 let drift = eng
3369 .export_ivf_state()
3370 .get("sim")
3371 .map(|(_, dst)| dst.2)
3372 .unwrap();
3373 assert_eq!(drift, 0, "rebuild resets dst-side IVF drift");
3374 });
3375 }
3376
3377 #[test]
3378 fn backfill_creates_edges_with_scores_and_delete_removes_exactly_them() {
3379 let mut fx = Fx::new();
3380 let a = fx.add("A", "a", vec![("tags", tags(&["x", "y"]))]);
3381 let b = fx.add("A", "b", vec![("tags", tags(&["x", "y"]))]);
3382 let _c = fx.add("A", "c", vec![("tags", tags(&["q"]))]);
3383 let et = fx.syms.intern("REL");
3385 fx.topo.add_edge(et, a, b);
3386 let mut eng = RuleEngine::new();
3387 let mut g = fx.g();
3388 eng.create_rule(overlap_rule(), &mut g).unwrap();
3389 assert!(g.topo.neighbors(et, Direction::Out, b).contains(&a));
3391 assert_eq!(
3392 g.edge_props.get(et, b, a, "score"),
3393 Some(&Value::Float(1.0))
3394 );
3395 assert!(!eng.is_owned(et, a, b));
3396 assert!(eng.is_owned(et, b, a));
3397 eng.delete_rule("rel", &mut g).unwrap();
3398 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);
3401 }
3402
3403 #[test]
3404 fn incremental_update_adds_and_removes_edges() {
3405 let mut fx = Fx::new();
3406 let a = fx.add("A", "a", vec![("tags", tags(&["x", "y"]))]);
3407 let b = fx.add("A", "b", vec![("tags", tags(&["y", "z"]))]);
3408 let et = fx.syms.intern("REL");
3409 let mut eng = RuleEngine::new();
3410 {
3411 let mut g = fx.g();
3412 eng.create_rule(overlap_rule(), &mut g).unwrap(); assert_eq!(g.topo.edge_count(), 0);
3414 }
3415 let old = fx.props.get(b, "tags").cloned();
3417 fx.props.set(b, "tags", tags(&["x", "y"]));
3418 {
3419 let mut g = fx.g();
3420 eng.on_node_changed(b, Some(("tags", old)), &mut g);
3421 assert!(g.topo.neighbors(et, Direction::Out, a).contains(&b));
3422 assert!(g.topo.neighbors(et, Direction::Out, b).contains(&a));
3423 }
3424 let old = fx.props.get(b, "tags").cloned();
3426 fx.props.set(b, "tags", tags(&["qqq"]));
3427 let mut g = fx.g();
3428 eng.on_node_changed(b, Some(("tags", old)), &mut g);
3429 assert_eq!(g.topo.edge_count(), 0);
3430 assert_eq!(g.edge_props.get(et, a, b, "score"), None);
3431 }
3432
3433 #[test]
3434 fn key_match_new_node_links_and_rebuild_is_noop() {
3435 let mut fx = Fx::new();
3436 fx.add("C", "c1", vec![]);
3437 let mut eng = RuleEngine::new();
3438 {
3439 let mut g = fx.g();
3440 eng.create_rule(
3441 RuleDef {
3442 name: "fk".into(),
3443 src_label: "T".into(),
3444 dst_label: "C".into(),
3445 predicate: Predicate::KeyMatch {
3446 field: "cid".into(),
3447 },
3448 edge_type: "AT".into(),
3449 weight_prop: None,
3450 max_edges: None,
3451 approximate: false,
3452 via_label: None,
3453 via_edge: None,
3454 via_dir: None,
3455 },
3456 &mut g,
3457 )
3458 .unwrap();
3459 }
3460 let t = fx.add("T", "t1", vec![("cid", Value::Str("c1".into()))]);
3461 let (at, c1, count_before) = {
3462 let mut g = fx.g();
3463 eng.on_node_changed(t, None, &mut g);
3464 let at = g.syms.get("AT").unwrap();
3465 let c1 = g.ids.get("c1").unwrap();
3466 assert!(g.topo.neighbors(at, Direction::Out, t).contains(&c1));
3467 (at, c1, g.topo.edge_count())
3468 };
3469 let mut g = fx.g();
3470 eng.rebuild("fk", &mut g).unwrap();
3471 assert_eq!(g.topo.edge_count(), count_before); assert!(g.topo.neighbors(at, Direction::Out, t).contains(&c1));
3473 }
3474
3475 #[test]
3476 fn score_refresh_on_persisting_owned_edge() {
3477 let mut fx = Fx::new();
3480 let a = fx.add("A", "a", vec![("tags", tags(&["x", "y", "z"]))]);
3481 let b = fx.add("A", "b", vec![("tags", tags(&["x", "y", "q"]))]);
3482 let et = fx.syms.intern("SIM");
3483 let mut eng = RuleEngine::new();
3484 {
3485 let mut g = fx.g();
3486 eng.create_rule(
3487 RuleDef {
3488 name: "sim".into(),
3489 src_label: "A".into(),
3490 dst_label: "A".into(),
3491 predicate: Predicate::Overlap {
3492 field: "tags".into(),
3493 min: 0.2,
3494 },
3495 edge_type: "SIM".into(),
3496 weight_prop: Some("score".into()),
3497 max_edges: None,
3498 approximate: false,
3499 via_label: None,
3500 via_edge: None,
3501 via_dir: None,
3502 },
3503 &mut g,
3504 )
3505 .unwrap();
3506 assert!(g.topo.neighbors(et, Direction::Out, a).contains(&b));
3508 assert!(g.topo.neighbors(et, Direction::Out, b).contains(&a));
3509 assert!(eng.is_owned(et, a, b) || eng.is_owned(et, b, a));
3510 let check = |v: Option<&Value>| {
3511 if let Some(Value::Float(f)) = v {
3512 assert!(
3513 (f - 0.5).abs() < 1e-9,
3514 "initial score should be 0.5, got {f}"
3515 );
3516 }
3517 };
3518 check(g.edge_props.get(et, a, b, "score"));
3519 check(g.edge_props.get(et, b, a, "score"));
3520 }
3521 let old = fx.props.get(b, "tags").cloned();
3523 fx.props.set(b, "tags", tags(&["x", "y", "z"]));
3524 {
3525 let mut g = fx.g();
3526 eng.on_node_changed(b, Some(("tags", old)), &mut g);
3527 assert!(g.topo.neighbors(et, Direction::Out, a).contains(&b));
3529 assert!(g.topo.neighbors(et, Direction::Out, b).contains(&a));
3530 assert_eq!(
3532 g.edge_props.get(et, a, b, "score"),
3533 Some(&Value::Float(1.0)),
3534 "score on a→b must refresh to 1.0"
3535 );
3536 assert_eq!(
3537 g.edge_props.get(et, b, a, "score"),
3538 Some(&Value::Float(1.0)),
3539 "score on b→a must refresh to 1.0"
3540 );
3541 }
3542 }
3543
3544 #[test]
3545 fn dst_side_keymatch_links_when_c_node_inserted_after_t() {
3546 let mut fx = Fx::new();
3548 let t = fx.add("T", "t1", vec![("cid", Value::Str("c9".into()))]);
3550 let mut eng = RuleEngine::new();
3551 {
3552 let mut g = fx.g();
3553 eng.create_rule(
3554 RuleDef {
3555 name: "fk".into(),
3556 src_label: "T".into(),
3557 dst_label: "C".into(),
3558 predicate: Predicate::KeyMatch {
3559 field: "cid".into(),
3560 },
3561 edge_type: "AT".into(),
3562 weight_prop: None,
3563 max_edges: None,
3564 approximate: false,
3565 via_label: None,
3566 via_edge: None,
3567 via_dir: None,
3568 },
3569 &mut g,
3570 )
3571 .unwrap();
3572 let at = g.syms.intern("AT");
3574 assert_eq!(g.topo.edge_count(), 0, "no C node yet → no edge");
3575 let _ = at;
3577 }
3578 let c9 = fx.add("C", "c9", vec![]);
3580 {
3581 let mut g = fx.g();
3582 eng.on_node_changed(c9, None, &mut g);
3583 let at = g.syms.get("AT").unwrap();
3584 assert!(
3586 g.topo.neighbors(at, Direction::Out, t).contains(&c9),
3587 "T→C edge must appear when C node is inserted"
3588 );
3589 assert!(eng.is_owned(at, t, c9));
3590 }
3591 }
3592
3593 #[test]
3594 fn on_node_removed_retracts_both_sides_and_deindexes() {
3595 let mut fx = Fx::new();
3596 let a = fx.add("A", "a", vec![("tags", tags(&["x", "y"]))]);
3597 let b = fx.add("A", "b", vec![("tags", tags(&["x", "y"]))]);
3598 let et = fx.syms.intern("REL");
3599 let mut eng = RuleEngine::new();
3600 {
3601 let mut g = fx.g();
3602 eng.create_rule(overlap_rule(), &mut g).unwrap();
3603 assert!(g.topo.neighbors(et, Direction::Out, a).contains(&b));
3604 assert!(g.topo.neighbors(et, Direction::Out, b).contains(&a));
3605 }
3606 {
3607 let mut g = fx.g();
3608 eng.on_node_removed(a, &mut g);
3609 assert!(!g.topo.neighbors(et, Direction::Out, a).contains(&b));
3610 assert!(!g.topo.neighbors(et, Direction::Out, b).contains(&a));
3611 assert_eq!(g.edge_props.get(et, a, b, "score"), None);
3612 assert_eq!(g.edge_props.get(et, b, a, "score"), None);
3613 assert!(!eng.is_owned(et, a, b));
3614 assert!(!eng.is_owned(et, b, a));
3615 }
3616 let c = fx.add("A", "c", vec![("tags", tags(&["x", "y"]))]);
3618 {
3619 let mut g = fx.g();
3620 eng.on_node_changed(c, None, &mut g);
3621 assert!(g.topo.neighbors(et, Direction::Out, b).contains(&c));
3622 assert!(g.topo.neighbors(et, Direction::Out, c).contains(&b));
3623 assert!(!g.topo.neighbors(et, Direction::Out, c).contains(&a));
3624 assert!(!g.topo.neighbors(et, Direction::Out, a).contains(&c));
3625 }
3626 {
3628 let mut g = fx.g();
3629 eng.on_node_removed(a, &mut g);
3630 assert!(g.topo.neighbors(et, Direction::Out, b).contains(&c));
3631 }
3632 }
3633
3634 #[test]
3635 fn duplicate_name_and_unknown_delete_error() {
3636 let mut fx = Fx::new();
3637 let mut eng = RuleEngine::new();
3638 let mut g = fx.g();
3639 eng.create_rule(overlap_rule(), &mut g).unwrap();
3640 assert!(eng.create_rule(overlap_rule(), &mut g).is_err());
3641 assert!(eng.delete_rule("nope", &mut g).is_err());
3642 }
3643
3644 #[test]
3650 fn coowned_edge_type_survives_first_delete_gone_after_second() {
3651 let mut fx = Fx::new();
3652 let a = fx.add("A", "a", vec![("tags", tags(&["x", "y"]))]);
3653 let b = fx.add("A", "b", vec![("tags", tags(&["x", "y"]))]);
3654 let mut eng = RuleEngine::new();
3655 {
3656 let mut g = fx.g();
3657 eng.create_rule(
3659 RuleDef {
3660 name: "r1".into(),
3661 src_label: "A".into(),
3662 dst_label: "A".into(),
3663 predicate: Predicate::Overlap {
3664 field: "tags".into(),
3665 min: 0.1,
3666 },
3667 edge_type: "REL2".into(),
3668 weight_prop: None,
3669 max_edges: None,
3670 approximate: false,
3671 via_label: None,
3672 via_edge: None,
3673 via_dir: None,
3674 },
3675 &mut g,
3676 )
3677 .unwrap();
3678 eng.create_rule(
3680 RuleDef {
3681 name: "r2".into(),
3682 src_label: "A".into(),
3683 dst_label: "A".into(),
3684 predicate: Predicate::Overlap {
3685 field: "tags".into(),
3686 min: 0.2,
3687 },
3688 edge_type: "REL2".into(),
3689 weight_prop: None,
3690 max_edges: None,
3691 approximate: false,
3692 via_label: None,
3693 via_edge: None,
3694 via_dir: None,
3695 },
3696 &mut g,
3697 )
3698 .unwrap();
3699
3700 let et = g.syms.intern("REL2");
3701 assert!(
3703 g.topo.neighbors(et, Direction::Out, a).contains(&b),
3704 "a→b must exist after both rules created"
3705 );
3706 assert!(
3707 g.topo.neighbors(et, Direction::Out, b).contains(&a),
3708 "b→a must exist after both rules created"
3709 );
3710
3711 eng.delete_rule("r1", &mut g).unwrap();
3713 assert!(
3714 g.topo.neighbors(et, Direction::Out, a).contains(&b),
3715 "a→b must survive R1 deletion (R2 rebuilds and claims it)"
3716 );
3717 assert!(
3718 g.topo.neighbors(et, Direction::Out, b).contains(&a),
3719 "b→a must survive R1 deletion (R2 rebuilds and claims it)"
3720 );
3721 assert!(
3723 eng.is_owned(et, a, b),
3724 "a→b must be owned by R2 after rebuild"
3725 );
3726 assert!(
3727 eng.is_owned(et, b, a),
3728 "b→a must be owned by R2 after rebuild"
3729 );
3730
3731 eng.delete_rule("r2", &mut g).unwrap();
3733 assert!(
3734 !g.topo.neighbors(et, Direction::Out, a).contains(&b),
3735 "a→b must be gone after both rules deleted"
3736 );
3737 assert!(
3738 !g.topo.neighbors(et, Direction::Out, b).contains(&a),
3739 "b→a must be gone after both rules deleted"
3740 );
3741 }
3742 }
3743
3744 fn topk_eq_rule(k: u64) -> RuleDef {
3746 RuleDef {
3747 name: "eq".into(),
3748 src_label: "N".into(),
3749 dst_label: "N".into(),
3750 predicate: Predicate::FieldEqual { field: "k".into() },
3751 edge_type: "EQ".into(),
3752 weight_prop: None,
3753 max_edges: Some(k),
3754 approximate: false,
3755 via_label: None,
3756 via_edge: None,
3757 via_dir: None,
3758 }
3759 }
3760
3761 fn prov_pairs(eng: &RuleEngine, name: &str) -> BTreeSet<(u32, u32)> {
3762 eng.provenance()
3763 .get(name)
3764 .map(|s| s.iter().map(|&(_, a, b)| (a, b)).collect())
3765 .unwrap_or_default()
3766 }
3767
3768 #[test]
3772 fn topk_k1_keeps_best_scored_dst() {
3773 let mut fx = Fx::new();
3774 let mut eng = RuleEngine::new();
3775 {
3776 let mut g = fx.g();
3777 eng.create_rule(topk_eq_rule(1), &mut g).unwrap();
3778 }
3779 let mut ids = Vec::new();
3781 for i in 0..4usize {
3782 let id = fx.add(
3783 "N",
3784 &format!("n{i}"),
3785 vec![("k", Value::Str("const".into()))],
3786 );
3787 ids.push(id);
3788 let mut g = fx.g();
3789 eng.on_node_changed(id, None, &mut g);
3790 }
3791 let et = fx.syms.get("EQ").unwrap();
3792 let expected_dsts = [ids[1], ids[0], ids[0], ids[0]];
3798 for (i, (&src, &expected_dst)) in ids.iter().zip(expected_dsts.iter()).enumerate() {
3799 let out: Vec<u32> = fx.topo.neighbors(et, Direction::Out, src).to_vec();
3800 assert_eq!(
3801 out,
3802 vec![expected_dst],
3803 "src n{i} should point only to the best dst"
3804 );
3805 }
3806 assert_eq!(eng.provenance()["eq"].len(), 4);
3807 assert!(!eng.is_tripped("eq"), "top-k rules never trip");
3808 }
3809
3810 #[test]
3813 fn topk_insert_evict() {
3814 let mut fx = Fx::new();
3818 let rule = RuleDef {
3819 name: "nw".into(),
3820 src_label: "S".into(),
3821 dst_label: "D".into(),
3822 predicate: Predicate::NumericWithin {
3823 field: "v".into(),
3824 tolerance: 10.0,
3825 },
3826 edge_type: "NEAR".into(),
3827 weight_prop: Some("score".into()),
3828 max_edges: Some(1),
3829 approximate: false,
3830 via_label: None,
3831 via_edge: None,
3832 via_dir: None,
3833 };
3834 let mut eng = RuleEngine::new();
3835 {
3836 let mut g = fx.g();
3837 eng.create_rule(rule, &mut g).unwrap();
3838 }
3839
3840 let s0 = fx.add("S", "s0", vec![("v", Value::Float(0.0))]);
3842 let d_far = fx.add("D", "d_far", vec![("v", Value::Float(9.0))]);
3844 {
3845 let mut g = fx.g();
3846 eng.on_node_changed(s0, None, &mut g);
3847 eng.on_node_changed(d_far, None, &mut g);
3848 }
3849 let et = fx.syms.get("NEAR").unwrap();
3850 assert!(fx.topo.neighbors(et, Direction::Out, s0).contains(&d_far));
3852 assert_eq!(eng.provenance()["nw"].len(), 1);
3853
3854 let d_close = fx.add("D", "d_close", vec![("v", Value::Float(1.0))]);
3856 {
3857 let mut g = fx.g();
3858 eng.on_node_changed(d_close, None, &mut g);
3859 }
3860 let out: Vec<u32> = fx.topo.neighbors(et, Direction::Out, s0).to_vec();
3862 assert_eq!(out, vec![d_close], "d_close should evict d_far");
3863 assert!(!fx.topo.neighbors(et, Direction::Out, s0).contains(&d_far));
3864 assert_eq!(eng.provenance()["nw"].len(), 1);
3865 assert!(eng.by_node_consistent());
3866 }
3867
3868 #[test]
3870 fn topk_retract_backfill() {
3871 let mut fx = Fx::new();
3872 let rule = RuleDef {
3873 name: "nw".into(),
3874 src_label: "S".into(),
3875 dst_label: "D".into(),
3876 predicate: Predicate::NumericWithin {
3877 field: "v".into(),
3878 tolerance: 10.0,
3879 },
3880 edge_type: "NEAR".into(),
3881 weight_prop: Some("score".into()),
3882 max_edges: Some(1),
3883 approximate: false,
3884 via_label: None,
3885 via_edge: None,
3886 via_dir: None,
3887 };
3888 let mut eng = RuleEngine::new();
3889
3890 let s0 = fx.add("S", "s0", vec![("v", Value::Float(0.0))]);
3891 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))]); {
3894 let mut g = fx.g();
3895 eng.create_rule(rule, &mut g).unwrap();
3896 }
3897 let et = fx.syms.get("NEAR").unwrap();
3898 assert!(fx.topo.neighbors(et, Direction::Out, s0).contains(&d_close));
3900 assert!(!fx.topo.neighbors(et, Direction::Out, s0).contains(&d_far));
3901 assert_eq!(eng.provenance()["nw"].len(), 1);
3902
3903 let old = fx.props.get(d_close, "v").cloned();
3905 fx.props.set(d_close, "v", Value::Float(50.0));
3906 {
3907 let mut g = fx.g();
3908 eng.on_node_changed(d_close, Some(("v", old)), &mut g);
3909 }
3910 assert!(!fx.topo.neighbors(et, Direction::Out, s0).contains(&d_close));
3912 assert!(
3913 fx.topo.neighbors(et, Direction::Out, s0).contains(&d_far),
3914 "d_far should backfill after d_close retracted"
3915 );
3916 assert_eq!(eng.provenance()["nw"].len(), 1);
3917 assert!(eng.by_node_consistent());
3918 }
3919
3920 #[test]
3922 fn topk_tie_broken_by_dst_key() {
3923 let mut fx = Fx::new();
3925 let mut eng = RuleEngine::new();
3926 {
3927 let mut g = fx.g();
3928 eng.create_rule(topk_eq_rule(2), &mut g).unwrap();
3929 }
3930 for name in ["a", "b", "c", "d", "e"] {
3933 let id = fx.add("N", name, vec![("k", Value::Str("x".into()))]);
3934 let mut g = fx.g();
3935 eng.on_node_changed(id, None, &mut g);
3936 }
3937 let et = fx.syms.get("EQ").unwrap();
3938 let get_id = |key: &str| fx.ids.get(key).unwrap();
3939 let a = get_id("a");
3941 let b = get_id("b");
3942 let c = get_id("c");
3943 let out_a: BTreeSet<u32> = fx
3944 .topo
3945 .neighbors(et, Direction::Out, a)
3946 .iter()
3947 .copied()
3948 .collect();
3949 assert!(out_a.contains(&b), "a→b (b is best key after a)");
3950 assert!(out_a.contains(&c), "a→c (c is 2nd best key)");
3951 assert_eq!(out_a.len(), 2);
3952 let e = get_id("e");
3954 let out_e: BTreeSet<u32> = fx
3955 .topo
3956 .neighbors(et, Direction::Out, e)
3957 .iter()
3958 .copied()
3959 .collect();
3960 assert!(out_e.contains(&a), "e→a");
3961 assert!(out_e.contains(&b), "e→b");
3962 assert_eq!(out_e.len(), 2);
3963 assert!(eng.by_node_consistent());
3964 }
3965
3966 #[test]
3968 fn topk_k_larger_than_candidate_count() {
3969 let mut fx = Fx::new();
3970 let mut eng = RuleEngine::new();
3971 {
3972 let mut g = fx.g();
3973 eng.create_rule(topk_eq_rule(100), &mut g).unwrap();
3975 }
3976 for i in 0..4usize {
3977 let id = fx.add("N", &format!("n{i}"), vec![("k", Value::Str("c".into()))]);
3978 let mut g = fx.g();
3979 eng.on_node_changed(id, None, &mut g);
3980 }
3981 assert_eq!(eng.provenance()["eq"].len(), 12);
3983 assert!(!eng.is_tripped("eq"));
3984 }
3985
3986 #[test]
3989 fn topk_rebuild_exact() {
3990 let mut fx = Fx::new();
3991 let mut eng = RuleEngine::new();
3992 {
3993 let mut g = fx.g();
3994 eng.create_rule(topk_eq_rule(1), &mut g).unwrap();
3995 }
3996 let _a = fx.add("N", "a", vec![("k", Value::Str("x".into()))]);
3998 let _b = fx.add("N", "b", vec![("k", Value::Str("x".into()))]);
3999 let _c = fx.add("N", "c", vec![("k", Value::Str("x".into()))]);
4000 {
4001 let mut g = fx.g();
4002 eng.on_node_changed(_a, None, &mut g);
4003 eng.on_node_changed(_b, None, &mut g);
4004 eng.on_node_changed(_c, None, &mut g);
4005 }
4006 assert_eq!(eng.provenance()["eq"].len(), 3);
4007
4008 {
4010 let mut g = fx.g();
4011 eng.rebuild("eq", &mut g).unwrap();
4012 }
4013 assert_eq!(eng.provenance()["eq"].len(), 3);
4014 assert!(!eng.is_tripped("eq"));
4015 assert!(eng.by_node_consistent());
4016 }
4017
4018 #[test]
4020 fn topk_by_node_consistent() {
4021 let mut fx = Fx::new();
4022 let mut eng = RuleEngine::new();
4023 {
4024 let mut g = fx.g();
4025 eng.create_rule(topk_eq_rule(2), &mut g).unwrap();
4026 }
4027 for i in 0..5usize {
4028 let id = fx.add(
4029 "N",
4030 &format!("n{i}"),
4031 vec![("k", Value::Str("const".into()))],
4032 );
4033 let mut g = fx.g();
4034 eng.on_node_changed(id, None, &mut g);
4035 }
4036 assert!(eng.by_node_consistent(), "consistent after insertions");
4037
4038 let id2 = fx.ids.get("n2").unwrap();
4040 let old = fx.props.get(id2, "k").cloned();
4041 fx.props.set(id2, "k", Value::Str("other".into()));
4042 {
4043 let mut g = fx.g();
4044 eng.on_node_changed(id2, Some(("k", old)), &mut g);
4045 }
4046 assert!(eng.by_node_consistent(), "consistent after eviction");
4047
4048 {
4049 let mut g = fx.g();
4050 eng.rebuild("eq", &mut g).unwrap();
4051 }
4052 assert!(eng.by_node_consistent(), "consistent after rebuild");
4053 }
4054
4055 fn numeric_rule() -> RuleDef {
4056 RuleDef {
4057 name: "nw".into(),
4058 src_label: "C".into(),
4059 dst_label: "C".into(),
4060 predicate: Predicate::NumericWithin {
4061 field: "year".into(),
4062 tolerance: 2.0,
4063 },
4064 edge_type: "NEAR".into(),
4065 weight_prop: Some("score".into()),
4066 max_edges: None,
4067 approximate: false,
4068 via_label: None,
4069 via_edge: None,
4070 via_dir: None,
4071 }
4072 }
4073
4074 fn geo_rule() -> RuleDef {
4075 RuleDef {
4076 name: "geo".into(),
4077 src_label: "City".into(),
4078 dst_label: "City".into(),
4079 predicate: Predicate::GeoRadius {
4080 field: "loc".into(),
4081 km: 400.0,
4082 },
4083 edge_type: "NEAR_GEO".into(),
4084 weight_prop: Some("score".into()),
4085 max_edges: None,
4086 approximate: false,
4087 via_label: None,
4088 via_edge: None,
4089 via_dir: None,
4090 }
4091 }
4092
4093 fn vec_rule() -> RuleDef {
4094 RuleDef {
4095 name: "vec".into(),
4096 src_label: "Doc".into(),
4097 dst_label: "Doc".into(),
4098 predicate: Predicate::VectorSimilar {
4099 field: "emb".into(),
4100 min: 0.9,
4101 },
4102 edge_type: "SIM".into(),
4103 weight_prop: Some("score".into()),
4104 max_edges: None,
4105 approximate: false,
4106 via_label: None,
4107 via_edge: None,
4108 via_dir: None,
4109 }
4110 }
4111
4112 fn pair_edges(topo: &Topology, et: u32, a: u32, b: u32) -> bool {
4113 topo.neighbors(et, Direction::Out, a).contains(&b)
4114 && topo.neighbors(et, Direction::Out, b).contains(&a)
4115 }
4116
4117 #[test]
4118 fn numeric_within_incremental_crosses_bucket_and_clears_old_index() {
4119 let mut fx = Fx::new();
4120 let a = fx.add("C", "a", vec![("year", Value::Float(10.0))]);
4121 let b = fx.add("C", "b", vec![("year", Value::Float(12.0))]);
4122 let et = fx.syms.intern("NEAR");
4123 let mut eng = RuleEngine::new();
4124 {
4125 let mut g = fx.g();
4126 eng.create_rule(numeric_rule(), &mut g).unwrap();
4127 assert!(pair_edges(g.topo, et, a, b));
4129 }
4130
4131 let old = fx.props.get(b, "year").cloned();
4134 fx.props.set(b, "year", Value::Float(16.1));
4135 {
4136 let mut g = fx.g();
4137 eng.on_node_changed(b, Some(("year", old)), &mut g);
4138 assert!(!pair_edges(g.topo, et, a, b));
4139 assert_eq!(g.topo.edge_count(), 0);
4140 }
4141 let def = numeric_rule();
4142 let spec = candidate_spec_for(&def);
4143 let old_map: std::collections::HashMap<_, _> =
4144 [("year".to_string(), Value::Float(12.0))].into();
4145 let old_get = |f: &str| old_map.get(f).cloned();
4146 let src_hits = eng.indexes["nw"].src_side.candidates(&spec, &old_get);
4147 let dst_hits = eng.indexes["nw"].dst_side.candidates(&spec, &old_get);
4148 assert!(!src_hits.contains(&b), "old src bucket must drop b");
4149 assert!(!dst_hits.contains(&b), "old dst bucket must drop b");
4150 assert!(src_hits.contains(&a));
4151
4152 let old = fx.props.get(b, "year").cloned();
4154 fx.props.set(b, "year", Value::Float(11.9));
4155 let mut g = fx.g();
4156 eng.on_node_changed(b, Some(("year", old)), &mut g);
4157 assert!(pair_edges(g.topo, et, a, b));
4158 }
4159
4160 fn loc_val(lat: f64, lon: f64) -> Value {
4161 Value::List(vec![Value::Float(lat), Value::Float(lon)])
4162 }
4163
4164 fn emb_val(vals: &[f64]) -> Value {
4165 Value::List(vals.iter().copied().map(Value::Float).collect())
4166 }
4167
4168 #[test]
4169 fn rebuild_is_noop_for_numeric_geo_and_vector() {
4170 let mut fx = Fx::new();
4171 let ca = fx.add("C", "ca", vec![("year", Value::Int(1998))]);
4172 let cb = fx.add("C", "cb", vec![("year", Value::Float(2000.0))]);
4173 let pa = fx.add("City", "paris", vec![("loc", loc_val(48.8566, 2.3522))]);
4174 let lo = fx.add("City", "london", vec![("loc", loc_val(51.5074, -0.1278))]);
4175 let da = fx.add("Doc", "d1", vec![("emb", emb_val(&[1.0, 0.0]))]);
4176 let db = fx.add("Doc", "d2", vec![("emb", emb_val(&[1.0, 0.0]))]);
4177
4178 let mut eng = RuleEngine::new();
4179 {
4180 let mut g = fx.g();
4181 eng.create_rule(numeric_rule(), &mut g).unwrap();
4182 eng.create_rule(geo_rule(), &mut g).unwrap();
4183 eng.create_rule(vec_rule(), &mut g).unwrap();
4184 }
4185
4186 let (near, ngeo, sim) = (
4187 fx.syms.get("NEAR").unwrap(),
4188 fx.syms.get("NEAR_GEO").unwrap(),
4189 fx.syms.get("SIM").unwrap(),
4190 );
4191 assert!(pair_edges(&fx.topo, near, ca, cb));
4192 assert!(pair_edges(&fx.topo, ngeo, pa, lo));
4193 assert!(pair_edges(&fx.topo, sim, da, db));
4194 let before = fx.topo.edge_count();
4195
4196 {
4197 let mut g = fx.g();
4198 eng.rebuild("nw", &mut g).unwrap();
4199 eng.rebuild("geo", &mut g).unwrap();
4200 eng.rebuild("vec", &mut g).unwrap();
4201 }
4202 assert_eq!(fx.topo.edge_count(), before);
4203 assert!(pair_edges(&fx.topo, near, ca, cb));
4204 assert!(pair_edges(&fx.topo, ngeo, pa, lo));
4205 assert!(pair_edges(&fx.topo, sim, da, db));
4206 }
4207
4208 fn fk_rule() -> RuleDef {
4209 RuleDef {
4210 name: "works_at".into(),
4211 src_label: "T".into(),
4212 dst_label: "C".into(),
4213 predicate: Predicate::KeyMatch {
4214 field: "cid".into(),
4215 },
4216 edge_type: "AT".into(),
4217 weight_prop: None,
4218 max_edges: None,
4219 approximate: false,
4220 via_label: None,
4221 via_edge: None,
4222 via_dir: None,
4223 }
4224 }
4225
4226 #[test]
4227 fn by_node_matches_rebuild_after_mutation_storm() {
4228 let mut fx = Fx::new();
4229 let hub = fx.add("C", "hub", vec![]);
4230 let other = fx.add("C", "other", vec![]);
4231 let mut people = Vec::new();
4232 for i in 0..40 {
4233 let cid = if i < 30 { "hub" } else { "other" };
4234 people.push(fx.add(
4235 "T",
4236 &format!("t{i}"),
4237 vec![("cid", Value::Str(cid.into())), ("tags", tags(&["x", "y"]))],
4238 ));
4239 }
4240 let mut overlap = overlap_rule();
4241 overlap.src_label = "T".into();
4242 overlap.dst_label = "T".into();
4243 let mut eng = RuleEngine::new();
4244 {
4245 let mut g = fx.g();
4246 eng.create_rule(fk_rule(), &mut g).unwrap();
4247 eng.create_rule(overlap, &mut g).unwrap();
4248 }
4249 assert!(eng.by_node_consistent());
4250 assert_eq!(eng.provenance_touching_len(hub), 30);
4251
4252 for (i, &id) in people.iter().enumerate().take(15) {
4254 let old = fx.props.get(id, "cid").cloned();
4255 fx.props.set(id, "cid", Value::Str("other".into()));
4256 let mut g = fx.g();
4257 eng.on_node_changed(id, Some(("cid", old)), &mut g);
4258 assert!(
4259 eng.by_node_consistent(),
4260 "inconsistent after cid update {i}"
4261 );
4262 }
4263 for &id in people.iter().take(8) {
4264 let old = fx.props.get(id, "tags").cloned();
4265 fx.props.set(id, "tags", tags(&["q"]));
4266 let mut g = fx.g();
4267 eng.on_node_changed(id, Some(("tags", old)), &mut g);
4268 }
4269 assert!(eng.by_node_consistent());
4270
4271 {
4273 let mut g = fx.g();
4274 eng.on_node_removed(people[0], &mut g);
4275 }
4276 fx.labels[people[0] as usize] = u32::MAX;
4277 assert!(eng.by_node_consistent());
4278 assert_eq!(eng.provenance_touching_len(people[0]), 0);
4279
4280 {
4281 let mut g = fx.g();
4282 eng.rebuild("works_at", &mut g).unwrap();
4283 eng.rebuild("rel", &mut g).unwrap();
4284 }
4285 assert!(eng.by_node_consistent());
4286
4287 {
4288 let mut g = fx.g();
4289 eng.delete_rule("rel", &mut g).unwrap();
4290 }
4291 assert!(eng.by_node_consistent());
4292 assert_eq!(eng.provenance_touching(people[1]).count(), 1);
4293
4294 let (defs, prov, tripped, fires) = eng.to_persist();
4296 let restored = RuleEngine::from_persist(defs, prov, tripped, fires);
4297 assert!(restored.by_node_consistent());
4298 assert_eq!(
4299 restored.provenance_touching_len(hub),
4300 eng.provenance_touching_len(hub)
4301 );
4302 assert_eq!(
4303 restored.provenance_touching_len(other),
4304 eng.provenance_touching_len(other)
4305 );
4306 }
4307
4308 #[test]
4309 fn provenance_touching_high_degree_hub() {
4310 let mut fx = Fx::new();
4311 let hub = fx.add("C", "hub", vec![]);
4312 let mut first = None;
4313 for i in 0..256 {
4314 let id = fx.add(
4315 "T",
4316 &format!("t{i}"),
4317 vec![("cid", Value::Str("hub".into()))],
4318 );
4319 if first.is_none() {
4320 first = Some(id);
4321 }
4322 }
4323 let first = first.unwrap();
4324 let mut eng = RuleEngine::new();
4325 {
4326 let mut g = fx.g();
4327 eng.create_rule(fk_rule(), &mut g).unwrap();
4328 }
4329 assert!(eng.by_node_consistent());
4330 assert_eq!(eng.provenance_touching_len(hub), 256);
4331 assert_eq!(eng.provenance_touching_len(first), 1);
4332 let hits: Vec<_> = eng.provenance_touching(first).collect();
4333 assert_eq!(hits.len(), 1);
4334 assert_eq!(hits[0].0, "works_at");
4335 assert_eq!(hits[0].2, first);
4336 assert_eq!(hits[0].3, hub);
4337 }
4338
4339 #[test]
4347 fn by_node_consistent_across_inserts_and_rebuild() {
4348 let mut fx = Fx::new();
4349 let mut eng = RuleEngine::new();
4350 let rule = RuleDef {
4351 name: "eq".into(),
4352 src_label: "N".into(),
4353 dst_label: "N".into(),
4354 predicate: Predicate::FieldEqual { field: "k".into() },
4355 edge_type: "EQ".into(),
4356 weight_prop: None,
4357 max_edges: None, approximate: false,
4359 via_label: None,
4360 via_edge: None,
4361 via_dir: None,
4362 };
4363 {
4364 let mut g = fx.g();
4365 eng.create_rule(rule, &mut g).unwrap();
4366 }
4367 let mut ids = Vec::new();
4368 for i in 0..6 {
4369 let id = fx.add(
4370 "N",
4371 &format!("n{i}"),
4372 vec![("k", Value::Str("const".into()))],
4373 );
4374 ids.push(id);
4375 let mut g = fx.g();
4376 eng.on_node_changed(id, None, &mut g);
4377 }
4378 assert_eq!(eng.provenance()["eq"].len(), 30);
4380 assert!(!eng.is_tripped("eq"));
4381 assert!(eng.by_node_consistent(), "consistent after insertions");
4382
4383 let old = fx.props.get(ids[3], "k").cloned();
4385 fx.props.set(ids[3], "k", Value::Str("other".into()));
4386 {
4387 let mut g = fx.g();
4388 eng.on_node_changed(ids[3], Some(("k", old)), &mut g);
4389 }
4390 assert!(eng.by_node_consistent(), "consistent after property change");
4391
4392 {
4393 let mut g = fx.g();
4394 eng.rebuild("eq", &mut g).unwrap();
4395 }
4396 assert!(!eng.is_tripped("eq"));
4397 assert!(eng.by_node_consistent(), "consistent after rebuild");
4398 }
4399
4400 fn mix64(mut x: u64) -> u64 {
4401 x = x.wrapping_add(0x9E3779B97F4A7C15);
4402 x = (x ^ (x >> 30)).wrapping_mul(0xBF58476D1CE4E5B9);
4403 x = (x ^ (x >> 27)).wrapping_mul(0x94D049BB133111EB);
4404 x ^ (x >> 31)
4405 }
4406
4407 fn rand_emb(seed: u64, i: u32, dim: usize) -> Value {
4408 let vals: Vec<f64> = (0..dim)
4409 .map(|d| {
4410 let bits = mix64(seed ^ ((i as u64 + 1).wrapping_mul(0x100000001)) ^ (d as u64));
4411 let mut f = (bits as f64) / (u64::MAX as f64) * 2.0 - 1.0;
4412 if f == 0.0 {
4413 f = 1.0;
4414 }
4415 f
4416 })
4417 .collect();
4418 emb_val(&vals)
4419 }
4420
4421 fn seed_docs(n: u32, seed: u64) -> (Fx, Vec<u32>) {
4422 let dims = [2usize, 3, 4, 8];
4423 let mut fx = Fx::new();
4424 let mut ids = Vec::new();
4425 for i in 0..n {
4426 let dim = dims[(i as usize) % dims.len()];
4427 ids.push(fx.add(
4428 "Doc",
4429 &format!("d{i}"),
4430 vec![("emb", rand_emb(seed, i, dim))],
4431 ));
4432 }
4433 (fx, ids)
4434 }
4435
4436 #[test]
4439 fn vector_dim_reject_matches_unfiltered_and_oracle() {
4440 const N: u32 = 500;
4441 const SEED: u64 = 0xC0FF_EE00_D15C;
4442 let def = vec_rule();
4443
4444 let (mut fx_on, ids) = seed_docs(N, SEED);
4445 let mut eng_on = RuleEngine::new();
4446 {
4447 let mut g = fx_on.g();
4448 eng_on.create_rule(def.clone(), &mut g).unwrap();
4449 }
4450 let on = prov_pairs(&eng_on, "vec");
4451 assert!(!on.is_empty(), "seeded set must produce some edges");
4452
4453 let (mut fx_off, _) = seed_docs(N, SEED);
4454 let mut eng_off = RuleEngine::new();
4455 {
4456 let mut g = fx_off.g();
4457 with_vector_dim_reject(false, || {
4458 eng_off.create_rule(def.clone(), &mut g).unwrap();
4459 });
4460 }
4461 assert_eq!(on, prov_pairs(&eng_off, "vec"), "filter vs no-filter");
4462
4463 let mut brute = BTreeSet::new();
4464 for &s in &ids {
4465 for &d in &ids {
4466 if s == d {
4467 continue;
4468 }
4469 let skey = fx_on.ids.key_of(s).unwrap();
4470 let dkey = fx_on.ids.key_of(d).unwrap();
4471 let sget = |f: &str| fx_on.props.get(s, f).cloned();
4472 let dget = |f: &str| fx_on.props.get(d, f).cloned();
4473 if evaluate(
4474 &def.predicate,
4475 &NodeView {
4476 key: skey,
4477 props: &sget,
4478 },
4479 &NodeView {
4480 key: dkey,
4481 props: &dget,
4482 },
4483 )
4484 .is_some()
4485 {
4486 brute.insert((s, d));
4487 }
4488 }
4489 }
4490 assert_eq!(on, brute, "filter vs brute-force evaluate");
4491 }
4492
4493 #[test]
4496 fn vector_dim_change_updates_cache_and_matches_fresh_build() {
4497 let mut fx = Fx::new();
4498 let a = fx.add("Doc", "a", vec![("emb", emb_val(&[1.0, 0.0]))]);
4499 let b = fx.add("Doc", "b", vec![("emb", emb_val(&[1.0, 0.0]))]);
4500 let c = fx.add("Doc", "c", vec![("emb", emb_val(&[1.0, 0.0, 0.0]))]);
4501 let mut eng = RuleEngine::new();
4502 {
4503 let mut g = fx.g();
4504 eng.create_rule(vec_rule(), &mut g).unwrap();
4505 }
4506 assert_eq!(eng.indexes["vec"].src_side.vec_dim(a), Some(2));
4507 assert_eq!(eng.indexes["vec"].src_side.vec_dim(c), Some(3));
4508 assert_eq!(prov_pairs(&eng, "vec"), BTreeSet::from([(a, b), (b, a)]));
4509
4510 let old = fx.props.get(b, "emb").cloned();
4511 fx.props.set(b, "emb", emb_val(&[1.0, 0.0, 0.0]));
4512 {
4513 let mut g = fx.g();
4514 eng.on_node_changed(b, Some(("emb", old)), &mut g);
4515 }
4516 assert_eq!(eng.indexes["vec"].src_side.vec_dim(b), Some(3));
4517 assert_eq!(eng.indexes["vec"].dst_side.vec_dim(b), Some(3));
4518 let after = prov_pairs(&eng, "vec");
4519 assert_eq!(after, BTreeSet::from([(b, c), (c, b)]));
4520
4521 let mut fresh_fx = Fx::new();
4523 let fa = fresh_fx.add("Doc", "a", vec![("emb", emb_val(&[1.0, 0.0]))]);
4524 let fb = fresh_fx.add("Doc", "b", vec![("emb", emb_val(&[1.0, 0.0, 0.0]))]);
4525 let fc = fresh_fx.add("Doc", "c", vec![("emb", emb_val(&[1.0, 0.0, 0.0]))]);
4526 let mut fresh = RuleEngine::new();
4527 {
4528 let mut g = fresh_fx.g();
4529 fresh.create_rule(vec_rule(), &mut g).unwrap();
4530 }
4531 assert_eq!(
4532 prov_pairs(&fresh, "vec"),
4533 BTreeSet::from([(fb, fc), (fc, fb)])
4534 );
4535 assert_eq!(fresh.indexes["vec"].src_side.vec_dim(fb), Some(3));
4536 assert_eq!(fresh.indexes["vec"].src_side.vec_dim(fa), Some(2));
4537 }
4538
4539 #[test]
4559 fn streaming_topk_order_identity_property_test() {
4560 fn reference_topk(rule: &RuleDef, k: u64, fx: &mut Fx) -> BTreeSet<(u32, u32)> {
4563 let mut idx = RuleIndex::default();
4564 for id in 0..fx.ids.len() as u32 {
4565 let label_sym = match fx.labels.get(id as usize).copied() {
4566 Some(s) if s != u32::MAX => s,
4567 _ => continue,
4568 };
4569 index_node_for_rule(
4570 id,
4571 label_sym,
4572 rule,
4573 &mut idx,
4574 &fx.syms,
4575 ColumnsView::owned(&fx.props),
4576 );
4577 }
4578 let src_sym = fx.syms.get(&rule.src_label);
4579 let mut out = BTreeSet::new();
4580 let ids_snap: Vec<u32> = (0..fx.ids.len() as u32).collect();
4581 for id in ids_snap {
4582 let label_sym = match fx.labels.get(id as usize).copied() {
4583 Some(s) if s != u32::MAX => s,
4584 _ => continue,
4585 };
4586 if src_sym != Some(label_sym) {
4587 continue;
4588 }
4589 let g = GraphMut {
4590 ids: &fx.ids,
4591 syms: &mut fx.syms,
4592 labels: &fx.labels,
4593 props: ColumnsView::owned(&fx.props),
4594 topo: &mut fx.topo,
4595 edge_props: &mut fx.eprops,
4596 };
4597 let per_src = compute_desired(rule, &idx, id, true, &g);
4598 let mut candidates: Vec<((u32, u32), f64)> = per_src.into_iter().collect();
4600 candidates.sort_by(|&((_, da), sa), &((_, db), sb)| {
4601 sb.total_cmp(&sa).then_with(|| {
4602 let ka = fx.ids.key_of(da).unwrap_or("");
4603 let kb = fx.ids.key_of(db).unwrap_or("");
4604 ka.cmp(kb)
4605 })
4606 });
4607 candidates.truncate(k as usize);
4608 out.extend(candidates.into_iter().map(|(k, _)| k));
4609 }
4610 out
4611 }
4612
4613 fn streaming_pairs(rule: RuleDef, fx: &mut Fx) -> BTreeSet<(u32, u32)> {
4615 let name = rule.name.clone();
4616 let mut eng = RuleEngine::new();
4617 eng.create_rule(rule, &mut fx.g()).unwrap();
4618 eng.provenance()
4619 .get(&name)
4620 .map(|s| s.iter().map(|&(_, a, b)| (a, b)).collect())
4621 .unwrap_or_default()
4622 }
4623
4624 for seed in [0u64, 1, 42, 0xDEAD_BEEF, 0x1234_5678, 99, 12_648_430, 7] {
4629 for k in [1u64, 2, 3, 5] {
4630 let rule = RuleDef {
4631 name: "eq".into(),
4632 src_label: "N".into(),
4633 dst_label: "N".into(),
4634 predicate: Predicate::FieldEqual { field: "k".into() },
4635 edge_type: "EQ".into(),
4636 weight_prop: None,
4637 max_edges: Some(k),
4638 approximate: false,
4639 via_label: None,
4640 via_edge: None,
4641 via_dir: None,
4642 };
4643
4644 let build = || {
4645 let mut fx = Fx::new();
4646 for i in 0..12u32 {
4647 let h = mix64(seed ^ (i as u64 + 1));
4648 let val = match h % 3 {
4649 0 => "a",
4650 1 => "b",
4651 _ => "c",
4652 };
4653 fx.add(
4654 "N",
4655 &format!("n{i:02}"),
4656 vec![("k", Value::Str(val.into()))],
4657 );
4658 }
4659 fx
4660 };
4661
4662 let expected = reference_topk(&rule, k, &mut build());
4663 let actual = streaming_pairs(rule, &mut build());
4664
4665 assert_eq!(
4666 expected, actual,
4667 "FieldEqual seed={seed} k={k}: streaming top-k must match brute-force top-k"
4668 );
4669 }
4670 }
4671
4672 for seed in [0u64, 1, 42, 7] {
4677 for k in [1u64, 2, 4] {
4678 let rule = RuleDef {
4679 name: "nw".into(),
4680 src_label: "S".into(),
4681 dst_label: "D".into(),
4682 predicate: Predicate::NumericWithin {
4683 field: "v".into(),
4684 tolerance: 10.0,
4685 },
4686 edge_type: "NEAR".into(),
4687 weight_prop: Some("score".into()),
4688 max_edges: Some(k),
4689 approximate: false,
4690 via_label: None,
4691 via_edge: None,
4692 via_dir: None,
4693 };
4694
4695 let build = || {
4696 let mut fx = Fx::new();
4697 for i in 0..6u32 {
4698 let h = mix64(seed ^ (i as u64 + 1));
4699 let v = (h % 20) as f64;
4700 fx.add("S", &format!("s{i}"), vec![("v", Value::Float(v))]);
4701 }
4702 for i in 0..8u32 {
4703 let h = mix64(seed ^ (i as u64 + 101));
4704 let v = (h % 20) as f64;
4705 fx.add("D", &format!("d{i}"), vec![("v", Value::Float(v))]);
4706 }
4707 fx
4708 };
4709
4710 let expected = reference_topk(&rule, k, &mut build());
4711 let actual = streaming_pairs(rule, &mut build());
4712
4713 assert_eq!(
4714 expected, actual,
4715 "NumericWithin seed={seed} k={k}: streaming top-k must match brute-force top-k"
4716 );
4717 }
4718 }
4719
4720 for seed in [0u64, 1, 42, 7] {
4727 for k in [1u64, 2] {
4728 let rule = RuleDef {
4729 name: "fk".into(),
4730 src_label: "T".into(),
4731 dst_label: "C".into(),
4732 predicate: Predicate::KeyMatch {
4733 field: "cid".into(),
4734 },
4735 edge_type: "AT".into(),
4736 weight_prop: None,
4737 max_edges: Some(k),
4738 approximate: false,
4739 via_label: None,
4740 via_edge: None,
4741 via_dir: None,
4742 };
4743
4744 let build = || {
4745 let mut fx = Fx::new();
4746 for i in 0..4u32 {
4748 fx.add("C", &format!("c{i}"), vec![]);
4749 }
4750 for i in 0..8u32 {
4752 let h = mix64(seed ^ (i as u64 + 1));
4753 let cid = format!("c{}", h % 4);
4754 fx.add("T", &format!("t{i}"), vec![("cid", Value::Str(cid))]);
4755 }
4756 fx
4757 };
4758
4759 let expected = reference_topk(&rule, k, &mut build());
4760 let actual = streaming_pairs(rule, &mut build());
4761
4762 assert_eq!(
4763 expected, actual,
4764 "KeyMatch seed={seed} k={k}: streaming top-k must match brute-force top-k"
4765 );
4766 }
4767 }
4768
4769 {
4775 let cluster_a: &[(&str, f64, f64)] = &[
4777 ("va0", 1.0_f64, 0.0_f64),
4778 ("va1", 0.98_f64, 0.199_f64), ("va2", 0.97_f64, 0.243_f64), ];
4781 let cluster_b: &[(&str, f64, f64)] = &[
4782 ("vb0", 0.0_f64, 1.0_f64),
4783 ("vb1", 0.1_f64, 0.995_f64),
4784 ("vb2", 0.05_f64, 0.999_f64),
4785 ];
4786 for k in [1u64, 2] {
4787 let rule = RuleDef {
4788 name: "vsim".into(),
4789 src_label: "V".into(),
4790 dst_label: "V".into(),
4791 predicate: Predicate::VectorSimilar {
4792 field: "emb".into(),
4793 min: 0.9,
4794 },
4795 edge_type: "VSIM".into(),
4796 weight_prop: Some("score".into()),
4797 max_edges: Some(k),
4798 approximate: false,
4799 via_label: None,
4800 via_edge: None,
4801 via_dir: None,
4802 };
4803
4804 let build = || {
4805 let mut fx = Fx::new();
4806 let mut add_v = |key: &str, x: f64, y: f64| {
4807 let norm = (x * x + y * y).sqrt();
4808 let v = Value::List(vec![Value::Float(x / norm), Value::Float(y / norm)]);
4809 fx.add("V", key, vec![("emb", v)]);
4810 };
4811 for &(k, x, y) in cluster_a.iter().chain(cluster_b.iter()) {
4812 add_v(k, x, y);
4813 }
4814 fx
4815 };
4816
4817 let expected = reference_topk(&rule, k, &mut build());
4818 let actual = streaming_pairs(rule, &mut build());
4819
4820 assert_eq!(
4821 expected, actual,
4822 "VectorSimilar/ScanAll k={k}: streaming top-k must match brute-force top-k"
4823 );
4824 }
4825 }
4826 }
4827
4828 #[test]
4854 #[ignore]
4855 fn streaming_peak_transient_bound() {
4856 use std::sync::{
4857 atomic::{AtomicBool, AtomicU64, Ordering},
4858 Arc,
4859 };
4860
4861 fn peak_rss_during<F: FnOnce()>(f: F) -> u64 {
4864 let done = Arc::new(AtomicBool::new(false));
4865 let peak = Arc::new(AtomicU64::new(0));
4866 let done2 = done.clone();
4867 let peak2 = peak.clone();
4868 let pid = std::process::id().to_string();
4869
4870 let handle = std::thread::spawn(move || {
4871 while !done2.load(Ordering::Relaxed) {
4872 let rss = std::process::Command::new("ps")
4873 .args(["-o", "rss=", "-p", &pid])
4874 .output()
4875 .ok()
4876 .and_then(|o| String::from_utf8(o.stdout).ok())
4877 .and_then(|s| s.trim().parse::<u64>().ok())
4878 .unwrap_or(0)
4879 * 1024;
4880 peak2.fetch_max(rss, Ordering::Relaxed);
4881 std::thread::sleep(std::time::Duration::from_millis(1));
4882 }
4883 });
4884
4885 f();
4886
4887 done.store(true, Ordering::Relaxed);
4888 let _ = handle.join();
4889 peak.load(Ordering::Relaxed)
4890 }
4891
4892 let mut fx = Fx::new();
4896 for i in 0..500u32 {
4897 fx.add(
4898 "Talent",
4899 &format!("t{i}"),
4900 vec![("k", Value::Str("same".into()))],
4901 );
4902 }
4903 for i in 0..500u32 {
4904 fx.add(
4905 "Company",
4906 &format!("c{i}"),
4907 vec![("k", Value::Str("same".into()))],
4908 );
4909 }
4910 let rule = RuleDef {
4911 name: "eq_tc".into(),
4912 src_label: "Talent".into(),
4913 dst_label: "Company".into(),
4914 predicate: Predicate::FieldEqual { field: "k".into() },
4915 edge_type: "EQ".into(),
4916 weight_prop: None,
4917 max_edges: Some(2), approximate: false,
4919 via_label: None,
4920 via_edge: None,
4921 via_dir: None,
4922 };
4923
4924 let pid = std::process::id().to_string();
4926 let baseline = std::process::Command::new("ps")
4927 .args(["-o", "rss=", "-p", &pid])
4928 .output()
4929 .ok()
4930 .and_then(|o| String::from_utf8(o.stdout).ok())
4931 .and_then(|s| s.trim().parse::<u64>().ok())
4932 .unwrap_or(0)
4933 * 1024;
4934
4935 let mut eng = RuleEngine::new();
4936 let peak = peak_rss_during(|| {
4937 eng.create_rule(rule, &mut fx.g()).unwrap();
4938 });
4939
4940 let peak_delta = peak.saturating_sub(baseline);
4941
4942 assert!(
4946 peak_delta < 3 * 1024 * 1024,
4947 "peak transient delta {} bytes ({} KiB) exceeded 3 MiB; \
4948 streaming path may be building the full pairs map",
4949 peak_delta,
4950 peak_delta / 1024
4951 );
4952 assert_eq!(eng.provenance()["eq_tc"].len(), 1_000); assert!(!eng.is_tripped("eq_tc")); eprintln!(
4955 "streaming_peak_transient_bound: baseline={baseline} peak={peak} \
4956 delta={peak_delta} bytes ({} KiB)",
4957 peak_delta / 1024
4958 );
4959 }
4960
4961 fn near_threshold_pair(dim: usize, min: f64) -> (Vec<f64>, Vec<f64>) {
4968 let cos_target = min + 1e-6; let sin_small = (1.0 - cos_target * cos_target).sqrt();
4972 let mut a = vec![0.0f64; dim];
4973 a[0] = 1.0;
4974 let mut b = vec![0.0f64; dim];
4975 b[0] = cos_target;
4976 if dim > 1 {
4977 b[1] = sin_small;
4978 }
4979 (a, b)
4980 }
4981
4982 fn emb_val2(xs: &[f64]) -> Value {
4983 Value::List(xs.iter().copied().map(Value::Float).collect())
4984 }
4985
4986 fn make_early_exit_fixture(seed: u64, min: f64) -> (Fx, Vec<u32>, usize, usize) {
4990 let dims = [2usize, 4, 8, 16];
4991 let n = 100u32;
4992 let mut fx = Fx::new();
4993 let mut ids = Vec::new();
4994 for i in 0..n {
4995 let dim = dims[(i as usize) % dims.len()];
4996 let emb = rand_emb(seed, i, dim);
4997 ids.push(fx.add("Doc", &format!("d{i}"), vec![("emb", emb)]));
4998 }
4999 let (va, vb) = near_threshold_pair(8, min);
5001 let nt_a = fx.add("Doc", "nt_a", vec![("emb", emb_val2(&va))]);
5002 let nt_b = fx.add("Doc", "nt_b", vec![("emb", emb_val2(&vb))]);
5003 ids.push(nt_a);
5004 ids.push(nt_b);
5005 (fx, ids, nt_a as usize, nt_b as usize)
5006 }
5007
5008 #[test]
5012 fn vector_early_exit_identity_proof() {
5013 const SEED: u64 = 0xEA_4E_5A;
5014 const MIN: f64 = 0.85;
5015
5016 let def = RuleDef {
5017 name: "vec".into(),
5018 src_label: "Doc".into(),
5019 dst_label: "Doc".into(),
5020 predicate: Predicate::VectorSimilar {
5021 field: "emb".into(),
5022 min: MIN,
5023 },
5024 edge_type: "SIM".into(),
5025 weight_prop: Some("score".into()),
5026 max_edges: None,
5027 approximate: false,
5028 via_label: None,
5029 via_edge: None,
5030 via_dir: None,
5031 };
5032
5033 let (mut fx_on, ids, nt_a, nt_b) = make_early_exit_fixture(SEED, MIN);
5035 let (mut fx_off, _, _, _) = make_early_exit_fixture(SEED, MIN);
5036 let (fx_oracle, _, _, _) = make_early_exit_fixture(SEED, MIN);
5037
5038 let nt_a = nt_a as u32;
5039 let nt_b = nt_b as u32;
5040
5041 let mut eng_on = RuleEngine::new();
5043 {
5044 let mut g = fx_on.g();
5045 eng_on.create_rule(def.clone(), &mut g).unwrap();
5046 }
5047 let edges_on = prov_pairs(&eng_on, "vec");
5048 assert!(!edges_on.is_empty(), "should produce some edges");
5049
5050 assert!(
5052 edges_on.contains(&(nt_a, nt_b)),
5053 "near-threshold pair nt_a→nt_b must match with early-exit ON"
5054 );
5055 assert!(
5056 edges_on.contains(&(nt_b, nt_a)),
5057 "near-threshold pair nt_b→nt_a must match with early-exit ON"
5058 );
5059
5060 let mut eng_off = RuleEngine::new();
5062 {
5063 let mut g = fx_off.g();
5064 with_vector_early_exit(false, || {
5065 eng_off.create_rule(def.clone(), &mut g).unwrap();
5066 });
5067 }
5068 let edges_off = prov_pairs(&eng_off, "vec");
5069 assert_eq!(
5070 edges_on, edges_off,
5071 "early-exit ON vs OFF must produce identical edges"
5072 );
5073
5074 let mut oracle = BTreeSet::new();
5076 for &s in &ids {
5077 for &d in &ids {
5078 if s == d {
5079 continue;
5080 }
5081 let skey = fx_oracle.ids.key_of(s).unwrap();
5082 let dkey = fx_oracle.ids.key_of(d).unwrap();
5083 let sg = |f: &str| fx_oracle.props.get(s, f).cloned();
5084 let dg = |f: &str| fx_oracle.props.get(d, f).cloned();
5085 if evaluate(
5086 &def.predicate,
5087 &NodeView {
5088 key: skey,
5089 props: &sg,
5090 },
5091 &NodeView {
5092 key: dkey,
5093 props: &dg,
5094 },
5095 )
5096 .is_some()
5097 {
5098 oracle.insert((s, d));
5099 }
5100 }
5101 }
5102 assert_eq!(
5103 edges_on, oracle,
5104 "early-exit ON vs brute-force oracle must be identical"
5105 );
5106 }
5107
5108 #[test]
5111 fn vector_early_exit_checkpoint_coherence() {
5112 let mut fx = Fx::new();
5113 let a = fx.add("Doc", "a", vec![("emb", emb_val(&[1.0, 0.0, 0.0, 0.0]))]);
5115 let b = fx.add("Doc", "b", vec![("emb", emb_val(&[1.0, 0.0, 0.0, 0.0]))]);
5116 let c = fx.add(
5118 "Doc",
5119 "c",
5120 vec![("emb", emb_val(&[1.0, 0.0, 0.0, 0.0, 0.0, 0.0]))],
5121 );
5122 let def = RuleDef {
5123 name: "vec".into(),
5124 src_label: "Doc".into(),
5125 dst_label: "Doc".into(),
5126 predicate: Predicate::VectorSimilar {
5127 field: "emb".into(),
5128 min: 0.9,
5129 },
5130 edge_type: "SIM".into(),
5131 weight_prop: None,
5132 max_edges: None,
5133 approximate: false,
5134 via_label: None,
5135 via_edge: None,
5136 via_dir: None,
5137 };
5138
5139 let mut eng = RuleEngine::new();
5140 {
5141 let mut g = fx.g();
5142 eng.create_rule(def.clone(), &mut g).unwrap();
5143 }
5144
5145 assert!(
5147 eng.indexes["vec"].src_side.vec_ckpts(a).is_some(),
5148 "a must have src checkpoints"
5149 );
5150 assert!(
5151 eng.indexes["vec"].dst_side.vec_ckpts(b).is_some(),
5152 "b must have dst checkpoints"
5153 );
5154 assert!(
5155 eng.indexes["vec"].src_side.vec_ckpts(c).is_some(),
5156 "c must have src checkpoints (dim=6)"
5157 );
5158
5159 let ckpts_a = *eng.indexes["vec"].src_side.vec_ckpts(a).unwrap();
5161 let norm_a = eng.indexes["vec"].src_side.vec_meta(a).unwrap().1;
5162 assert!(
5163 (ckpts_a[0] - norm_a).abs() < 1e-12,
5164 "ckpts[0] must equal the full L2 norm"
5165 );
5166
5167 assert_eq!(prov_pairs(&eng, "vec"), BTreeSet::from([(a, b), (b, a)]));
5169
5170 let old_b = fx.props.get(b, "emb").cloned();
5172 fx.props
5173 .set(b, "emb", emb_val(&[1.0, 0.0, 0.0, 0.0, 0.0, 0.0]));
5174 {
5175 let mut g = fx.g();
5176 eng.on_node_changed(b, Some(("emb", old_b)), &mut g);
5177 }
5178 assert_eq!(eng.indexes["vec"].src_side.vec_dim(b), Some(6));
5180 assert_eq!(eng.indexes["vec"].dst_side.vec_dim(b), Some(6));
5181 assert!(eng.indexes["vec"].src_side.vec_ckpts(b).is_some());
5183 assert_eq!(prov_pairs(&eng, "vec"), BTreeSet::from([(b, c), (c, b)]));
5185
5186 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);
5190 assert!(
5191 gate_result.is_none(),
5192 "freshness gate must reject a mismatched-norm live vector"
5193 );
5194
5195 let correct_live = vec![1.0f64, 0.0, 0.0, 0.0, 0.0, 0.0];
5197 let gate_result = eng.indexes["vec"]
5198 .src_side
5199 .fresh_ckpts_for(b, &correct_live);
5200 assert!(
5201 gate_result.is_some(),
5202 "freshness gate must accept the matching live vector"
5203 );
5204 }
5205
5206 #[test]
5215 fn vector_early_exit_razor_dim1536() {
5216 const MIN: f64 = 0.85;
5217 const DIM: usize = 1536;
5218 let target = MIN + 5e-13;
5220 let inv_sqrt = 1.0 / (DIM as f64).sqrt();
5221
5222 let a: Vec<f64> = vec![inv_sqrt; DIM];
5224
5225 let perp_scale = (1.0 - target * target).sqrt() / (2.0f64).sqrt();
5231 let mut b: Vec<f64> = vec![target * inv_sqrt; DIM];
5232 b[0] += perp_scale;
5233 b[1] -= perp_scale;
5234
5235 let def = RuleDef {
5236 name: "razor".into(),
5237 src_label: "Doc".into(),
5238 dst_label: "Doc".into(),
5239 predicate: Predicate::VectorSimilar {
5240 field: "emb".into(),
5241 min: MIN,
5242 },
5243 edge_type: "SIM".into(),
5244 weight_prop: None,
5245 max_edges: None,
5246 approximate: false,
5247 via_label: None,
5248 via_edge: None,
5249 via_dir: None,
5250 };
5251
5252 let build_fx = || {
5254 let mut fx = Fx::new();
5255 let na = fx.add("Doc", "razor_a", vec![("emb", emb_val2(&a))]);
5256 let nb = fx.add("Doc", "razor_b", vec![("emb", emb_val2(&b))]);
5257 (fx, na, nb)
5258 };
5259
5260 let (mut fx_on, na, nb) = build_fx();
5261 let (mut fx_off, _, _) = build_fx();
5262 let (fx_oracle, _, _) = build_fx();
5263
5264 let mut eng_on = RuleEngine::new();
5266 {
5267 let mut g = fx_on.g();
5268 eng_on.create_rule(def.clone(), &mut g).unwrap();
5269 }
5270 let edges_on = prov_pairs(&eng_on, "razor");
5271 assert!(
5272 edges_on.contains(&(na, nb)),
5273 "razor pair razor_a→razor_b must be present with early-exit ON (cos={target:.15}, min={MIN})"
5274 );
5275 assert!(
5276 edges_on.contains(&(nb, na)),
5277 "razor pair razor_b→razor_a must be present with early-exit ON"
5278 );
5279
5280 let mut eng_off = RuleEngine::new();
5282 {
5283 let mut g = fx_off.g();
5284 with_vector_early_exit(false, || {
5285 eng_off.create_rule(def.clone(), &mut g).unwrap();
5286 });
5287 }
5288 let edges_off = prov_pairs(&eng_off, "razor");
5289 assert_eq!(
5290 edges_on, edges_off,
5291 "razor dim=1536: early-exit ON vs OFF must produce identical edges"
5292 );
5293
5294 let ids = [na, nb];
5296 let mut oracle = BTreeSet::new();
5297 for &s in &ids {
5298 for &d in &ids {
5299 if s == d {
5300 continue;
5301 }
5302 let skey = fx_oracle.ids.key_of(s).unwrap();
5303 let dkey = fx_oracle.ids.key_of(d).unwrap();
5304 let sg = |f: &str| fx_oracle.props.get(s, f).cloned();
5305 let dg = |f: &str| fx_oracle.props.get(d, f).cloned();
5306 if evaluate(
5307 &def.predicate,
5308 &NodeView {
5309 key: skey,
5310 props: &sg,
5311 },
5312 &NodeView {
5313 key: dkey,
5314 props: &dg,
5315 },
5316 )
5317 .is_some()
5318 {
5319 oracle.insert((s, d));
5320 }
5321 }
5322 }
5323 assert_eq!(
5324 edges_on, oracle,
5325 "razor dim=1536: early-exit ON vs brute-force oracle must be identical"
5326 );
5327 }
5328
5329 fn field_equal_rule(
5373 src_label: &str,
5374 dst_label: &str,
5375 field: &str,
5376 edge_type: &str,
5377 max_edges: Option<u64>,
5378 ) -> RuleDef {
5379 RuleDef {
5380 name: format!("{src_label}_{dst_label}_{field}"),
5381 src_label: src_label.into(),
5382 dst_label: dst_label.into(),
5383 predicate: Predicate::FieldEqual {
5384 field: field.into(),
5385 },
5386 edge_type: edge_type.into(),
5387 weight_prop: None,
5388 max_edges,
5389 approximate: false,
5390 via_label: None,
5391 via_edge: None,
5392 via_dir: None,
5393 }
5394 }
5395
5396 #[test]
5397 fn backfill_does_not_materialize_the_cross_product() {
5398 use std::sync::atomic::Ordering;
5399 let mut fx = Fx::new();
5404 for i in 0..400u32 {
5405 fx.add(
5406 "Person",
5407 &format!("p{i}"),
5408 vec![("city", Value::Str("austin".into()))],
5409 );
5410 }
5411 for i in 0..400u32 {
5412 fx.add(
5413 "Org",
5414 &format!("o{i}"),
5415 vec![("city", Value::Str("austin".into()))],
5416 );
5417 }
5418
5419 let mut eng = RuleEngine::new();
5420 PEAK_DESIRED_PAIRS.store(0, Ordering::Relaxed);
5421 {
5422 let mut g = fx.g();
5423 eng.create_rule(
5424 field_equal_rule("Person", "Org", "city", "IN_CITY", Some(5)),
5425 &mut g,
5426 )
5427 .unwrap();
5428 }
5429
5430 let edges = fx.topo.edge_count();
5431 assert_eq!(
5432 edges,
5433 400 * 5,
5434 "per-source top-k must yield exactly k per source"
5435 );
5436
5437 let peak = PEAK_DESIRED_PAIRS.load(Ordering::Relaxed);
5438 assert!(
5439 peak <= 400 * 5 * 4, "backfill must not materialize the full cross-product; peak was {peak}"
5441 );
5442 }
5443
5444 #[test]
5460 fn global_budget_backfill_stays_per_source_bounded() {
5461 use std::sync::atomic::Ordering;
5462 let mut fx = Fx::new();
5465 for i in 0..400u32 {
5466 fx.add(
5467 "Person",
5468 &format!("p{i}"),
5469 vec![("city", Value::Str("austin".into()))],
5470 );
5471 }
5472 for i in 0..400u32 {
5473 fx.add(
5474 "Org",
5475 &format!("o{i}"),
5476 vec![("city", Value::Str("austin".into()))],
5477 );
5478 }
5479
5480 let mut eng = RuleEngine::new();
5481 PEAK_DESIRED_PAIRS.store(0, Ordering::Relaxed);
5482 {
5483 let mut g = fx.g();
5484 eng.create_rule(
5485 field_equal_rule("Person", "Org", "city", "IN_CITY", None),
5486 &mut g,
5487 )
5488 .unwrap();
5489 }
5490
5491 let edges = fx.topo.edge_count();
5494 assert_eq!(
5495 edges,
5496 400 * 400,
5497 "none-path must apply all pairs when under budget; got {edges}"
5498 );
5499
5500 let peak = PEAK_DESIRED_PAIRS.load(Ordering::Relaxed);
5504 assert!(
5505 peak <= 400 * 4, "none-path backfill must not accumulate a global desired-map; peak was {peak}"
5507 );
5508 }
5509}