1use std::collections::{BTreeMap, HashMap, HashSet};
2
3use crate::clock::LamportClock;
4use crate::entry::{Entry, GraphOp, Hash, Value};
5use crate::ontology::{Ontology, ValidationMode};
6
7#[derive(Debug, Clone, PartialEq)]
9pub struct Node {
10 pub node_id: String,
11 pub node_type: String,
12 pub subtype: Option<String>,
13 pub label: String,
14 pub properties: BTreeMap<String, Value>,
15 pub property_clocks: HashMap<String, LamportClock>,
19 pub last_clock: LamportClock,
22 pub last_add_clock: LamportClock,
26 pub tombstoned: bool,
28}
29
30#[derive(Debug, Clone, PartialEq)]
32pub struct Edge {
33 pub edge_id: String,
34 pub edge_type: String,
35 pub source_id: String,
36 pub target_id: String,
37 pub properties: BTreeMap<String, Value>,
38 pub property_clocks: HashMap<String, LamportClock>,
40 pub last_clock: LamportClock,
41 pub last_add_clock: LamportClock,
43 pub tombstoned: bool,
44}
45
46#[derive(Debug, Clone, PartialEq)]
53pub struct QuarantineRecord {
54 pub op_kind: String,
56 pub reason: String,
58 pub ontology_hash: String,
61}
62
63pub struct MaterializedGraph {
74 pub nodes: HashMap<String, Node>,
76 pub edges: HashMap<String, Edge>,
78 pub outgoing: HashMap<String, HashSet<String>>,
80 pub incoming: HashMap<String, HashSet<String>>,
82 pub by_type: HashMap<String, HashSet<String>>,
84 pub ontology: Ontology,
86 base_ontology: Ontology,
95 pub quarantined: HashMap<Hash, QuarantineRecord>,
102 pending_edges: HashMap<Hash, Entry>,
108}
109
110impl MaterializedGraph {
111 pub fn new(ontology: Ontology) -> Self {
113 Self {
114 nodes: HashMap::new(),
115 edges: HashMap::new(),
116 outgoing: HashMap::new(),
117 incoming: HashMap::new(),
118 by_type: HashMap::new(),
119 base_ontology: ontology.clone(),
120 ontology,
121 quarantined: HashMap::new(),
122 pending_edges: HashMap::new(),
123 }
124 }
125
126 pub fn apply(&mut self, entry: &Entry) {
133 self.apply_entry(entry, ValidationMode::Full)
134 }
135
136 fn apply_entry(&mut self, entry: &Entry, mode: ValidationMode) {
137 self.apply_inner(entry, mode, true)
138 }
139
140 fn apply_inner(
158 &mut self,
159 entry: &Entry,
160 mode: ValidationMode,
161 adopt_checkpoint_ontology: bool,
162 ) {
163 macro_rules! quarantine {
164 ($kind:expr, $reason:expr) => {{
165 let record = QuarantineRecord {
166 op_kind: $kind.to_string(),
167 reason: $reason.to_string(),
168 ontology_hash: hex::encode(self.ontology.content_hash()),
169 };
170 self.quarantined.insert(entry.hash, record);
171 }};
172 }
173 match &entry.payload {
174 GraphOp::Checkpoint { ops, op_clocks, .. } => {
175 for (i, op) in ops.iter().enumerate() {
178 if let GraphOp::DefineOntology { ontology } = op {
185 if adopt_checkpoint_ontology {
186 self.ontology = ontology.clone();
187 }
188 continue;
189 }
190 let clock = if i < op_clocks.len() {
191 LamportClock::with_values(&entry.author, op_clocks[i].0, op_clocks[i].1)
192 } else {
193 entry.clock.clone() };
195 let synthetic = Entry::new(op.clone(), vec![], vec![], clock, &entry.author);
196 self.apply_entry(&synthetic, ValidationMode::SkipRequired);
197 }
198 self.quarantined.remove(&entry.hash);
200 }
201 GraphOp::DefineOntology { .. } => {
202 }
204 GraphOp::ExtendOntology { extension } => {
205 if let Err(e) = self.ontology.merge_extension(extension) {
206 quarantine!("extend_ontology", e);
207 } else {
208 self.quarantined.remove(&entry.hash);
209 }
210 }
211 GraphOp::AddNode {
212 node_id,
213 node_type,
214 subtype,
215 label,
216 properties,
217 } => {
218 if let Err(e) = self.ontology.validate_node_mode(
220 node_type,
221 subtype.as_deref(),
222 properties,
223 mode,
224 ) {
225 quarantine!("add_node", e);
226 return;
227 }
228 self.quarantined.remove(&entry.hash);
232 self.apply_add_node(
233 node_id,
234 node_type,
235 subtype.as_deref(),
236 label,
237 properties,
238 &entry.clock,
239 );
240 self.retry_pending_edges();
242 }
243 GraphOp::AddEdge {
244 edge_id,
245 edge_type,
246 source_id,
247 target_id,
248 properties,
249 } => {
250 if !self.ontology.edge_types.contains_key(edge_type.as_str()) {
252 quarantine!(
253 "add_edge",
254 crate::ontology::ValidationError::UnknownEdgeType(edge_type.clone())
255 );
256 return;
257 }
258 match (
264 self.nodes.get(source_id.as_str()),
265 self.nodes.get(target_id.as_str()),
266 ) {
267 (Some(src), Some(tgt)) => {
268 if let Err(e) = self.ontology.validate_edge_mode(
269 edge_type,
270 &src.node_type,
271 &tgt.node_type,
272 properties,
273 mode,
274 ) {
275 quarantine!("add_edge", e);
276 return;
277 }
278 }
279 _ => {
280 self.pending_edges.insert(entry.hash, entry.clone());
281 quarantine!(
282 "add_edge",
283 format!(
284 "endpoint not yet materialized (source '{source_id}', \
285 target '{target_id}'); held pending until both arrive"
286 )
287 );
288 return;
289 }
290 }
291 self.quarantined.remove(&entry.hash);
292 self.apply_add_edge(
293 edge_id,
294 edge_type,
295 source_id,
296 target_id,
297 properties,
298 &entry.clock,
299 );
300 }
301 GraphOp::UpdateProperty {
302 entity_id,
303 key,
304 value,
305 } => {
306 let verdict = if let Some(node) = self.nodes.get(entity_id.as_str()) {
314 self.ontology.validate_property_update(
315 &node.node_type,
316 node.subtype.as_deref(),
317 key,
318 value,
319 )
320 } else if let Some(edge) = self.edges.get(entity_id.as_str()) {
321 self.ontology
322 .validate_edge_property_update(&edge.edge_type, key, value)
323 } else {
324 Ok(())
325 };
326 if let Err(e) = verdict {
327 quarantine!("update_property", e);
328 return;
329 }
330 self.quarantined.remove(&entry.hash);
331 self.apply_update_property(entity_id, key, value, &entry.clock);
332 }
333 GraphOp::RemoveNode { node_id } => {
334 self.quarantined.remove(&entry.hash);
335 self.apply_remove_node(node_id, &entry.clock);
336 }
337 GraphOp::RemoveEdge { edge_id } => {
338 self.quarantined.remove(&entry.hash);
339 self.apply_remove_edge(edge_id, &entry.clock);
340 }
341 GraphOp::DefineLens { .. } => {
342 }
344 }
345 }
346
347 pub fn apply_all(&mut self, entries: &[&Entry]) {
349 for entry in entries {
350 self.apply(entry);
351 }
352 }
353
354 pub fn rebuild(&mut self, entries: &[&Entry]) {
356 self.nodes.clear();
357 self.edges.clear();
358 self.outgoing.clear();
359 self.incoming.clear();
360 self.by_type.clear();
361 self.quarantined.clear();
362 self.pending_edges.clear();
363
364 self.ontology = self.base_ontology.clone();
376 for entry in entries {
377 match &entry.payload {
378 GraphOp::DefineOntology { .. } => {}
379 GraphOp::ExtendOntology { extension } => {
380 if let Err(e) = self.ontology.merge_extension(extension) {
381 self.quarantined.insert(
382 entry.hash,
383 QuarantineRecord {
384 op_kind: "extend_ontology".to_string(),
385 reason: e.to_string(),
386 ontology_hash: hex::encode(self.ontology.content_hash()),
387 },
388 );
389 }
390 }
391 GraphOp::Checkpoint { ops, .. } => {
392 for op in ops {
393 if let GraphOp::DefineOntology { ontology } = op {
394 self.ontology = ontology.clone();
395 }
396 }
397 }
398 _ => {}
399 }
400 }
401
402 for entry in entries {
405 if matches!(entry.payload, GraphOp::ExtendOntology { .. }) {
406 continue;
407 }
408 self.apply_inner(entry, ValidationMode::Full, false);
409 }
410 }
411
412 fn retry_pending_edges(&mut self) {
417 if self.pending_edges.is_empty() {
418 return;
419 }
420 let ready: Vec<Entry> = self
421 .pending_edges
422 .values()
423 .filter(|entry| match &entry.payload {
424 GraphOp::AddEdge {
425 source_id,
426 target_id,
427 ..
428 } => {
429 self.nodes.contains_key(source_id.as_str())
430 && self.nodes.contains_key(target_id.as_str())
431 }
432 _ => false,
433 })
434 .cloned()
435 .collect();
436 for entry in ready {
437 self.pending_edges.remove(&entry.hash);
438 self.apply_entry(&entry, ValidationMode::Full);
439 }
440 }
441
442 pub fn get_node(&self, node_id: &str) -> Option<&Node> {
446 self.nodes.get(node_id).filter(|n| !n.tombstoned)
447 }
448
449 pub fn get_edge(&self, edge_id: &str) -> Option<&Edge> {
451 self.edges.get(edge_id).filter(|e| !e.tombstoned)
452 }
453
454 pub fn nodes_by_type(&self, node_type: &str) -> Vec<&Node> {
457 let mut types = vec![node_type.to_string()];
458 types.extend(
459 self.ontology
460 .descendants(node_type)
461 .into_iter()
462 .map(|s| s.to_string()),
463 );
464 types
465 .iter()
466 .flat_map(|t| self.by_type.get(t.as_str()))
467 .flatten()
468 .filter_map(|id| self.get_node(id))
469 .collect()
470 }
471
472 pub fn nodes_by_subtype(&self, subtype: &str) -> Vec<&Node> {
474 self.nodes
475 .values()
476 .filter(|n| !n.tombstoned && n.subtype.as_deref() == Some(subtype))
477 .collect()
478 }
479
480 pub fn nodes_by_property(&self, key: &str, value: &Value) -> Vec<&Node> {
482 self.nodes
483 .values()
484 .filter(|n| !n.tombstoned && n.properties.get(key) == Some(value))
485 .collect()
486 }
487
488 pub fn outgoing_edges(&self, node_id: &str) -> Vec<&Edge> {
490 match self.outgoing.get(node_id) {
491 Some(edge_ids) => edge_ids
492 .iter()
493 .filter_map(|eid| self.get_edge(eid))
494 .filter(|e| self.is_node_live(&e.target_id))
495 .collect(),
496 None => vec![],
497 }
498 }
499
500 pub fn incoming_edges(&self, node_id: &str) -> Vec<&Edge> {
502 match self.incoming.get(node_id) {
503 Some(edge_ids) => edge_ids
504 .iter()
505 .filter_map(|eid| self.get_edge(eid))
506 .filter(|e| self.is_node_live(&e.source_id))
507 .collect(),
508 None => vec![],
509 }
510 }
511
512 pub fn estimated_memory_bytes(&self) -> usize {
517 let mut total = 0;
518 for node in self.nodes.values() {
520 total += node.node_id.len() + node.node_type.len() + node.label.len();
521 total += node.subtype.as_ref().map_or(0, |s| s.len());
522 for (k, v) in &node.properties {
524 total += k.len() + std::mem::size_of_val(v) + 48; }
526 total += 128; }
528 for edge in self.edges.values() {
530 total += edge.edge_id.len() + edge.edge_type.len();
531 total += edge.source_id.len() + edge.target_id.len();
532 for (k, v) in &edge.properties {
533 total += k.len() + std::mem::size_of_val(v) + 48;
534 }
535 total += 128;
536 }
537 for (k, set) in &self.outgoing {
539 total += k.len() + set.len() * 32;
540 }
541 for (k, set) in &self.incoming {
542 total += k.len() + set.len() * 32;
543 }
544 for (k, set) in &self.by_type {
546 total += k.len() + set.len() * 32;
547 }
548 total += self.quarantined.len() * 48;
550 total
551 }
552
553 pub fn all_nodes(&self) -> Vec<&Node> {
555 self.nodes.values().filter(|n| !n.tombstoned).collect()
556 }
557
558 pub fn all_edges(&self) -> Vec<&Edge> {
560 self.edges
561 .values()
562 .filter(|e| {
563 !e.tombstoned && self.is_node_live(&e.source_id) && self.is_node_live(&e.target_id)
564 })
565 .collect()
566 }
567
568 pub fn neighbors(&self, node_id: &str) -> Vec<&str> {
570 self.outgoing_edges(node_id)
571 .iter()
572 .map(|e| e.target_id.as_str())
573 .collect()
574 }
575
576 pub fn reverse_neighbors(&self, node_id: &str) -> Vec<&str> {
578 self.incoming_edges(node_id)
579 .iter()
580 .map(|e| e.source_id.as_str())
581 .collect()
582 }
583
584 fn apply_add_node(
587 &mut self,
588 node_id: &str,
589 node_type: &str,
590 subtype: Option<&str>,
591 label: &str,
592 properties: &BTreeMap<String, Value>,
593 clock: &LamportClock,
594 ) {
595 if let Some(existing) = self.nodes.get_mut(node_id) {
596 existing.tombstoned = false;
598 if clock_wins(clock, &existing.last_add_clock) {
600 existing.last_add_clock = clock.clone();
601 }
602 if clock_wins(clock, &existing.last_clock) {
604 existing.label = label.to_string();
605 existing.subtype = subtype.map(|s| s.to_string());
606 existing.last_clock = clock.clone();
607 }
608 merge_properties_lww(
609 &mut existing.properties,
610 &mut existing.property_clocks,
611 properties,
612 clock,
613 );
614 } else {
615 let property_clocks: HashMap<String, LamportClock> = properties
616 .keys()
617 .map(|k| (k.clone(), clock.clone()))
618 .collect();
619 let node = Node {
620 node_id: node_id.to_string(),
621 node_type: node_type.to_string(),
622 subtype: subtype.map(|s| s.to_string()),
623 label: label.to_string(),
624 properties: properties.clone(),
625 property_clocks,
626 last_clock: clock.clone(),
627 last_add_clock: clock.clone(),
628 tombstoned: false,
629 };
630 self.by_type
631 .entry(node_type.to_string())
632 .or_default()
633 .insert(node_id.to_string());
634 self.nodes.insert(node_id.to_string(), node);
635 }
636 }
637
638 fn apply_add_edge(
639 &mut self,
640 edge_id: &str,
641 edge_type: &str,
642 source_id: &str,
643 target_id: &str,
644 properties: &BTreeMap<String, Value>,
645 clock: &LamportClock,
646 ) {
647 if let Some(existing) = self.edges.get_mut(edge_id) {
648 existing.tombstoned = false;
650 if clock_wins(clock, &existing.last_add_clock) {
651 existing.last_add_clock = clock.clone();
652 }
653 if clock_wins(clock, &existing.last_clock) {
654 existing.last_clock = clock.clone();
655 }
656 merge_properties_lww(
657 &mut existing.properties,
658 &mut existing.property_clocks,
659 properties,
660 clock,
661 );
662 } else {
663 let property_clocks: HashMap<String, LamportClock> = properties
664 .keys()
665 .map(|k| (k.clone(), clock.clone()))
666 .collect();
667 let edge = Edge {
668 edge_id: edge_id.to_string(),
669 edge_type: edge_type.to_string(),
670 source_id: source_id.to_string(),
671 target_id: target_id.to_string(),
672 properties: properties.clone(),
673 property_clocks,
674 last_clock: clock.clone(),
675 last_add_clock: clock.clone(),
676 tombstoned: false,
677 };
678 self.outgoing
679 .entry(source_id.to_string())
680 .or_default()
681 .insert(edge_id.to_string());
682 self.incoming
683 .entry(target_id.to_string())
684 .or_default()
685 .insert(edge_id.to_string());
686 self.edges.insert(edge_id.to_string(), edge);
687 }
688 }
689
690 fn apply_update_property(
691 &mut self,
692 entity_id: &str,
693 key: &str,
694 value: &Value,
695 clock: &LamportClock,
696 ) {
697 if let Some(node) = self.nodes.get_mut(entity_id) {
700 let dominated = node
701 .property_clocks
702 .get(key)
703 .map(|c| clock_wins(clock, c))
704 .unwrap_or(true);
705 if dominated {
706 node.properties.insert(key.to_string(), value.clone());
707 node.property_clocks.insert(key.to_string(), clock.clone());
708 }
709 if clock_wins(clock, &node.last_clock) {
711 node.last_clock = clock.clone();
712 }
713 } else if let Some(edge) = self.edges.get_mut(entity_id) {
714 let dominated = edge
715 .property_clocks
716 .get(key)
717 .map(|c| clock_wins(clock, c))
718 .unwrap_or(true);
719 if dominated {
720 edge.properties.insert(key.to_string(), value.clone());
721 edge.property_clocks.insert(key.to_string(), clock.clone());
722 }
723 if clock_wins(clock, &edge.last_clock) {
724 edge.last_clock = clock.clone();
725 }
726 }
727 }
729
730 fn apply_remove_node(&mut self, node_id: &str, clock: &LamportClock) {
731 if let Some(node) = self.nodes.get_mut(node_id) {
732 if clock_wins(clock, &node.last_add_clock) {
736 node.tombstoned = true;
737 node.last_clock = clock.clone();
738 }
739 }
740 }
743
744 fn apply_remove_edge(&mut self, edge_id: &str, clock: &LamportClock) {
745 if let Some(edge) = self.edges.get_mut(edge_id) {
746 if clock_wins(clock, &edge.last_add_clock) {
748 edge.tombstoned = true;
749 edge.last_clock = clock.clone();
750 }
751 }
752 }
753
754 fn is_node_live(&self, node_id: &str) -> bool {
755 self.nodes
756 .get(node_id)
757 .map(|n| !n.tombstoned)
758 .unwrap_or(false)
759 }
760}
761
762fn merge_properties_lww(
765 existing_props: &mut BTreeMap<String, Value>,
766 existing_clocks: &mut HashMap<String, LamportClock>,
767 new_props: &BTreeMap<String, Value>,
768 clock: &LamportClock,
769) {
770 for (k, v) in new_props {
771 let dominated = existing_clocks
772 .get(k)
773 .map(|c| clock_wins(clock, c))
774 .unwrap_or(true);
775 if dominated {
776 existing_props.insert(k.clone(), v.clone());
777 existing_clocks.insert(k.clone(), clock.clone());
778 }
779 }
780}
781
782fn clock_wins(new_clock: &LamportClock, existing_clock: &LamportClock) -> bool {
785 new_clock.cmp_order(existing_clock) == std::cmp::Ordering::Greater
786}
787
788#[cfg(test)]
789mod tests {
790 use super::*;
791 use crate::entry::Entry;
792 use crate::ontology::{EdgeTypeDef, NodeTypeDef};
793
794 fn test_ontology() -> Ontology {
795 Ontology {
796 node_types: BTreeMap::from([
797 (
798 "entity".into(),
799 NodeTypeDef {
800 description: None,
801 properties: BTreeMap::new(),
802 subtypes: None,
803 parent_type: None,
804 },
805 ),
806 (
807 "signal".into(),
808 NodeTypeDef {
809 description: None,
810 properties: BTreeMap::new(),
811 subtypes: None,
812 parent_type: None,
813 },
814 ),
815 ]),
816 edge_types: BTreeMap::from([
817 (
818 "RUNS_ON".into(),
819 EdgeTypeDef {
820 description: None,
821 source_types: vec!["entity".into()],
822 target_types: vec!["entity".into()],
823 properties: BTreeMap::new(),
824 },
825 ),
826 (
827 "OBSERVES".into(),
828 EdgeTypeDef {
829 description: None,
830 source_types: vec!["signal".into()],
831 target_types: vec!["entity".into()],
832 properties: BTreeMap::new(),
833 },
834 ),
835 ]),
836 }
837 }
838
839 fn make_entry(op: GraphOp, clock_time: u64, author: &str) -> Entry {
840 Entry::new(
841 op,
842 vec![],
843 vec![],
844 LamportClock::with_values(author, clock_time, 0),
845 author,
846 )
847 }
848
849 #[test]
852 fn add_node_appears_in_query() {
853 let mut g = MaterializedGraph::new(test_ontology());
854 let entry = make_entry(
855 GraphOp::AddNode {
856 node_id: "server-1".into(),
857 node_type: "entity".into(),
858 label: "Server 1".into(),
859 properties: BTreeMap::from([("ip".into(), Value::String("10.0.0.1".into()))]),
860 subtype: None,
861 },
862 1,
863 "inst-a",
864 );
865 g.apply(&entry);
866
867 let node = g.get_node("server-1").unwrap();
868 assert_eq!(node.node_type, "entity");
869 assert_eq!(node.label, "Server 1");
870 assert_eq!(
871 node.properties.get("ip"),
872 Some(&Value::String("10.0.0.1".into()))
873 );
874 }
875
876 #[test]
877 fn add_edge_creates_adjacency() {
878 let mut g = MaterializedGraph::new(test_ontology());
879 g.apply(&make_entry(
880 GraphOp::AddNode {
881 node_id: "svc".into(),
882 node_type: "entity".into(),
883 label: "svc".into(),
884 properties: BTreeMap::new(),
885 subtype: None,
886 },
887 1,
888 "inst-a",
889 ));
890 g.apply(&make_entry(
891 GraphOp::AddNode {
892 node_id: "srv".into(),
893 node_type: "entity".into(),
894 label: "srv".into(),
895 properties: BTreeMap::new(),
896 subtype: None,
897 },
898 2,
899 "inst-a",
900 ));
901 g.apply(&make_entry(
902 GraphOp::AddEdge {
903 edge_id: "e1".into(),
904 edge_type: "RUNS_ON".into(),
905 source_id: "svc".into(),
906 target_id: "srv".into(),
907 properties: BTreeMap::new(),
908 },
909 3,
910 "inst-a",
911 ));
912
913 let out = g.outgoing_edges("svc");
915 assert_eq!(out.len(), 1);
916 assert_eq!(out[0].target_id, "srv");
917
918 let inc = g.incoming_edges("srv");
919 assert_eq!(inc.len(), 1);
920 assert_eq!(inc[0].source_id, "svc");
921
922 assert_eq!(g.neighbors("svc"), vec!["srv"]);
923 }
924
925 #[test]
926 fn update_property_reflected() {
927 let mut g = MaterializedGraph::new(test_ontology());
928 g.apply(&make_entry(
929 GraphOp::AddNode {
930 node_id: "s1".into(),
931 node_type: "entity".into(),
932 label: "s1".into(),
933 properties: BTreeMap::new(),
934 subtype: None,
935 },
936 1,
937 "inst-a",
938 ));
939 g.apply(&make_entry(
940 GraphOp::UpdateProperty {
941 entity_id: "s1".into(),
942 key: "cpu".into(),
943 value: Value::Float(85.5),
944 },
945 2,
946 "inst-a",
947 ));
948
949 let node = g.get_node("s1").unwrap();
950 assert_eq!(node.properties.get("cpu"), Some(&Value::Float(85.5)));
951 }
952
953 #[test]
954 fn remove_node_cascades_edges() {
955 let mut g = MaterializedGraph::new(test_ontology());
956 g.apply(&make_entry(
957 GraphOp::AddNode {
958 node_id: "a".into(),
959 node_type: "entity".into(),
960 label: "a".into(),
961 properties: BTreeMap::new(),
962 subtype: None,
963 },
964 1,
965 "inst-a",
966 ));
967 g.apply(&make_entry(
968 GraphOp::AddNode {
969 node_id: "b".into(),
970 node_type: "entity".into(),
971 label: "b".into(),
972 properties: BTreeMap::new(),
973 subtype: None,
974 },
975 2,
976 "inst-a",
977 ));
978 g.apply(&make_entry(
979 GraphOp::AddEdge {
980 edge_id: "e1".into(),
981 edge_type: "RUNS_ON".into(),
982 source_id: "a".into(),
983 target_id: "b".into(),
984 properties: BTreeMap::new(),
985 },
986 3,
987 "inst-a",
988 ));
989 assert_eq!(g.all_edges().len(), 1);
990
991 g.apply(&make_entry(
993 GraphOp::RemoveNode {
994 node_id: "b".into(),
995 },
996 4,
997 "inst-a",
998 ));
999 assert!(g.get_node("b").is_none());
1000 assert_eq!(g.all_edges().len(), 0);
1002 assert_eq!(g.outgoing_edges("a").len(), 0);
1004 }
1005
1006 #[test]
1007 fn remove_edge_preserves_nodes() {
1008 let mut g = MaterializedGraph::new(test_ontology());
1009 g.apply(&make_entry(
1010 GraphOp::AddNode {
1011 node_id: "a".into(),
1012 node_type: "entity".into(),
1013 label: "a".into(),
1014 properties: BTreeMap::new(),
1015 subtype: None,
1016 },
1017 1,
1018 "inst-a",
1019 ));
1020 g.apply(&make_entry(
1021 GraphOp::AddNode {
1022 node_id: "b".into(),
1023 node_type: "entity".into(),
1024 label: "b".into(),
1025 properties: BTreeMap::new(),
1026 subtype: None,
1027 },
1028 2,
1029 "inst-a",
1030 ));
1031 g.apply(&make_entry(
1032 GraphOp::AddEdge {
1033 edge_id: "e1".into(),
1034 edge_type: "RUNS_ON".into(),
1035 source_id: "a".into(),
1036 target_id: "b".into(),
1037 properties: BTreeMap::new(),
1038 },
1039 3,
1040 "inst-a",
1041 ));
1042 g.apply(&make_entry(
1043 GraphOp::RemoveEdge {
1044 edge_id: "e1".into(),
1045 },
1046 4,
1047 "inst-a",
1048 ));
1049
1050 assert!(g.get_node("a").is_some());
1052 assert!(g.get_node("b").is_some());
1053 assert!(g.get_edge("e1").is_none());
1055 assert_eq!(g.all_edges().len(), 0);
1056 }
1057
1058 #[test]
1059 fn query_by_type_filters() {
1060 let mut g = MaterializedGraph::new(test_ontology());
1061 g.apply(&make_entry(
1062 GraphOp::AddNode {
1063 node_id: "s1".into(),
1064 node_type: "entity".into(),
1065 label: "s1".into(),
1066 properties: BTreeMap::new(),
1067 subtype: None,
1068 },
1069 1,
1070 "inst-a",
1071 ));
1072 g.apply(&make_entry(
1073 GraphOp::AddNode {
1074 node_id: "s2".into(),
1075 node_type: "entity".into(),
1076 label: "s2".into(),
1077 properties: BTreeMap::new(),
1078 subtype: None,
1079 },
1080 2,
1081 "inst-a",
1082 ));
1083 g.apply(&make_entry(
1084 GraphOp::AddNode {
1085 node_id: "alert".into(),
1086 node_type: "signal".into(),
1087 label: "alert".into(),
1088 properties: BTreeMap::new(),
1089 subtype: None,
1090 },
1091 3,
1092 "inst-a",
1093 ));
1094
1095 let entities = g.nodes_by_type("entity");
1096 assert_eq!(entities.len(), 2);
1097 let signals = g.nodes_by_type("signal");
1098 assert_eq!(signals.len(), 1);
1099 assert_eq!(signals[0].node_id, "alert");
1100 }
1101
1102 #[test]
1103 fn query_by_property_filters() {
1104 let mut g = MaterializedGraph::new(test_ontology());
1105 g.apply(&make_entry(
1106 GraphOp::AddNode {
1107 node_id: "s1".into(),
1108 node_type: "entity".into(),
1109 label: "s1".into(),
1110 properties: BTreeMap::from([("status".into(), Value::String("alive".into()))]),
1111 subtype: None,
1112 },
1113 1,
1114 "inst-a",
1115 ));
1116 g.apply(&make_entry(
1117 GraphOp::AddNode {
1118 node_id: "s2".into(),
1119 node_type: "entity".into(),
1120 label: "s2".into(),
1121 properties: BTreeMap::from([("status".into(), Value::String("dead".into()))]),
1122 subtype: None,
1123 },
1124 2,
1125 "inst-a",
1126 ));
1127
1128 let alive = g.nodes_by_property("status", &Value::String("alive".into()));
1129 assert_eq!(alive.len(), 1);
1130 assert_eq!(alive[0].node_id, "s1");
1131 }
1132
1133 #[test]
1134 fn materialization_from_empty() {
1135 let mut g1 = MaterializedGraph::new(test_ontology());
1137 let entries = vec![
1138 make_entry(
1139 GraphOp::DefineOntology {
1140 ontology: test_ontology(),
1141 },
1142 0,
1143 "inst-a",
1144 ),
1145 make_entry(
1146 GraphOp::AddNode {
1147 node_id: "a".into(),
1148 node_type: "entity".into(),
1149 label: "a".into(),
1150 properties: BTreeMap::new(),
1151 subtype: None,
1152 },
1153 1,
1154 "inst-a",
1155 ),
1156 make_entry(
1157 GraphOp::AddNode {
1158 node_id: "b".into(),
1159 node_type: "entity".into(),
1160 label: "b".into(),
1161 properties: BTreeMap::new(),
1162 subtype: None,
1163 },
1164 2,
1165 "inst-a",
1166 ),
1167 make_entry(
1168 GraphOp::AddEdge {
1169 edge_id: "e1".into(),
1170 edge_type: "RUNS_ON".into(),
1171 source_id: "a".into(),
1172 target_id: "b".into(),
1173 properties: BTreeMap::new(),
1174 },
1175 3,
1176 "inst-a",
1177 ),
1178 ];
1179 for e in &entries {
1180 g1.apply(e);
1181 }
1182
1183 let mut g2 = MaterializedGraph::new(test_ontology());
1185 let refs: Vec<&Entry> = entries.iter().collect();
1186 g2.rebuild(&refs);
1187
1188 assert_eq!(g1.all_nodes().len(), g2.all_nodes().len());
1190 assert_eq!(g1.all_edges().len(), g2.all_edges().len());
1191 for node in g1.all_nodes() {
1192 let n2 = g2.get_node(&node.node_id).unwrap();
1193 assert_eq!(node.node_type, n2.node_type);
1194 assert_eq!(node.properties, n2.properties);
1195 }
1196 }
1197
1198 #[test]
1199 fn incremental_equals_full() {
1200 let entries = vec![
1201 make_entry(
1202 GraphOp::DefineOntology {
1203 ontology: test_ontology(),
1204 },
1205 0,
1206 "inst-a",
1207 ),
1208 make_entry(
1209 GraphOp::AddNode {
1210 node_id: "a".into(),
1211 node_type: "entity".into(),
1212 label: "a".into(),
1213 properties: BTreeMap::from([("x".into(), Value::Int(1))]),
1214 subtype: None,
1215 },
1216 1,
1217 "inst-a",
1218 ),
1219 make_entry(
1220 GraphOp::UpdateProperty {
1221 entity_id: "a".into(),
1222 key: "x".into(),
1223 value: Value::Int(2),
1224 },
1225 2,
1226 "inst-a",
1227 ),
1228 make_entry(
1229 GraphOp::AddNode {
1230 node_id: "b".into(),
1231 node_type: "entity".into(),
1232 label: "b".into(),
1233 properties: BTreeMap::new(),
1234 subtype: None,
1235 },
1236 3,
1237 "inst-a",
1238 ),
1239 make_entry(
1240 GraphOp::AddEdge {
1241 edge_id: "e1".into(),
1242 edge_type: "RUNS_ON".into(),
1243 source_id: "a".into(),
1244 target_id: "b".into(),
1245 properties: BTreeMap::new(),
1246 },
1247 4,
1248 "inst-a",
1249 ),
1250 make_entry(
1251 GraphOp::RemoveEdge {
1252 edge_id: "e1".into(),
1253 },
1254 5,
1255 "inst-a",
1256 ),
1257 ];
1258
1259 let mut g_inc = MaterializedGraph::new(test_ontology());
1261 for e in &entries {
1262 g_inc.apply(e);
1263 }
1264
1265 let mut g_full = MaterializedGraph::new(test_ontology());
1267 let refs: Vec<&Entry> = entries.iter().collect();
1268 g_full.rebuild(&refs);
1269
1270 assert_eq!(
1272 g_inc.get_node("a").unwrap().properties.get("x"),
1273 Some(&Value::Int(2))
1274 );
1275 assert_eq!(
1276 g_full.get_node("a").unwrap().properties.get("x"),
1277 Some(&Value::Int(2))
1278 );
1279 assert_eq!(g_inc.all_edges().len(), 0);
1281 assert_eq!(g_full.all_edges().len(), 0);
1282 }
1283
1284 #[test]
1285 fn lww_concurrent_property_update() {
1286 let mut g = MaterializedGraph::new(test_ontology());
1288 g.apply(&make_entry(
1289 GraphOp::AddNode {
1290 node_id: "s1".into(),
1291 node_type: "entity".into(),
1292 label: "s1".into(),
1293 properties: BTreeMap::new(),
1294 subtype: None,
1295 },
1296 1,
1297 "inst-a",
1298 ));
1299 g.apply(&make_entry(
1301 GraphOp::UpdateProperty {
1302 entity_id: "s1".into(),
1303 key: "status".into(),
1304 value: Value::String("alive".into()),
1305 },
1306 2,
1307 "inst-a",
1308 ));
1309 g.apply(&make_entry(
1311 GraphOp::UpdateProperty {
1312 entity_id: "s1".into(),
1313 key: "status".into(),
1314 value: Value::String("dead".into()),
1315 },
1316 3,
1317 "inst-b",
1318 ));
1319 assert_eq!(
1320 g.get_node("s1").unwrap().properties.get("status"),
1321 Some(&Value::String("dead".into()))
1322 );
1323 }
1324
1325 #[test]
1326 fn lww_tiebreak_by_instance_id() {
1327 let mut g = MaterializedGraph::new(test_ontology());
1329 g.apply(&make_entry(
1330 GraphOp::AddNode {
1331 node_id: "s1".into(),
1332 node_type: "entity".into(),
1333 label: "s1".into(),
1334 properties: BTreeMap::new(),
1335 subtype: None,
1336 },
1337 1,
1338 "inst-a",
1339 ));
1340 g.apply(&make_entry(
1342 GraphOp::UpdateProperty {
1343 entity_id: "s1".into(),
1344 key: "x".into(),
1345 value: Value::Int(1),
1346 },
1347 5,
1348 "inst-a",
1349 ));
1350 g.apply(&make_entry(
1351 GraphOp::UpdateProperty {
1352 entity_id: "s1".into(),
1353 key: "x".into(),
1354 value: Value::Int(2),
1355 },
1356 5,
1357 "inst-b",
1358 ));
1359 assert_eq!(
1361 g.get_node("s1").unwrap().properties.get("x"),
1362 Some(&Value::Int(1))
1363 );
1364 }
1365
1366 #[test]
1367 fn lww_per_property_concurrent_different_keys() {
1368 let mut g = MaterializedGraph::new(test_ontology());
1372 g.apply(&make_entry(
1373 GraphOp::AddNode {
1374 node_id: "s1".into(),
1375 node_type: "entity".into(),
1376 label: "s1".into(),
1377 properties: BTreeMap::from([
1378 ("x".into(), Value::Int(0)),
1379 ("y".into(), Value::Int(0)),
1380 ]),
1381 subtype: None,
1382 },
1383 1,
1384 "inst-a",
1385 ));
1386 g.apply(&make_entry(
1388 GraphOp::UpdateProperty {
1389 entity_id: "s1".into(),
1390 key: "x".into(),
1391 value: Value::Int(42),
1392 },
1393 3,
1394 "inst-a",
1395 ));
1396 g.apply(&make_entry(
1398 GraphOp::UpdateProperty {
1399 entity_id: "s1".into(),
1400 key: "y".into(),
1401 value: Value::Int(99),
1402 },
1403 3,
1404 "inst-b",
1405 ));
1406
1407 let node = g.get_node("s1").unwrap();
1408 assert_eq!(
1410 node.properties.get("x"),
1411 Some(&Value::Int(42)),
1412 "update to 'x' must not be rejected by concurrent update to 'y'"
1413 );
1414 assert_eq!(
1415 node.properties.get("y"),
1416 Some(&Value::Int(99)),
1417 "update to 'y' must not be rejected by concurrent update to 'x'"
1418 );
1419 }
1420
1421 #[test]
1422 fn lww_per_property_order_independent() {
1423 let mut g = MaterializedGraph::new(test_ontology());
1425 g.apply(&make_entry(
1426 GraphOp::AddNode {
1427 node_id: "s1".into(),
1428 node_type: "entity".into(),
1429 label: "s1".into(),
1430 properties: BTreeMap::from([
1431 ("x".into(), Value::Int(0)),
1432 ("y".into(), Value::Int(0)),
1433 ]),
1434 subtype: None,
1435 },
1436 1,
1437 "inst-a",
1438 ));
1439 g.apply(&make_entry(
1441 GraphOp::UpdateProperty {
1442 entity_id: "s1".into(),
1443 key: "y".into(),
1444 value: Value::Int(99),
1445 },
1446 3,
1447 "inst-b",
1448 ));
1449 g.apply(&make_entry(
1450 GraphOp::UpdateProperty {
1451 entity_id: "s1".into(),
1452 key: "x".into(),
1453 value: Value::Int(42),
1454 },
1455 3,
1456 "inst-a",
1457 ));
1458
1459 let node = g.get_node("s1").unwrap();
1460 assert_eq!(node.properties.get("x"), Some(&Value::Int(42)));
1461 assert_eq!(node.properties.get("y"), Some(&Value::Int(99)));
1462 }
1463
1464 #[test]
1465 fn add_wins_over_remove() {
1466 let mut g = MaterializedGraph::new(test_ontology());
1468 g.apply(&make_entry(
1469 GraphOp::AddNode {
1470 node_id: "s1".into(),
1471 node_type: "entity".into(),
1472 label: "s1".into(),
1473 properties: BTreeMap::new(),
1474 subtype: None,
1475 },
1476 1,
1477 "inst-a",
1478 ));
1479 g.apply(&make_entry(
1481 GraphOp::RemoveNode {
1482 node_id: "s1".into(),
1483 },
1484 2,
1485 "inst-a",
1486 ));
1487 assert!(g.get_node("s1").is_none());
1488
1489 g.apply(&make_entry(
1491 GraphOp::AddNode {
1492 node_id: "s1".into(),
1493 node_type: "entity".into(),
1494 label: "s1 v2".into(),
1495 properties: BTreeMap::new(),
1496 subtype: None,
1497 },
1498 3,
1499 "inst-b",
1500 ));
1501 let node = g.get_node("s1").unwrap();
1502 assert_eq!(node.label, "s1 v2");
1503 assert!(!node.tombstoned);
1504 }
1505
1506 #[test]
1510 fn checkpoint_replay_applies_inner_define_ontology() {
1511 let mut base = test_ontology();
1513 base.node_types.remove("signal");
1514 base.edge_types.remove("OBSERVES");
1515 let mut g = MaterializedGraph::new(base);
1516
1517 let checkpoint = make_entry(
1518 GraphOp::Checkpoint {
1519 ops: vec![
1520 GraphOp::DefineOntology {
1521 ontology: test_ontology(), },
1523 GraphOp::AddNode {
1524 node_id: "n1".into(),
1525 node_type: "entity".into(),
1526 label: "base-typed".into(),
1527 properties: BTreeMap::new(),
1528 subtype: None,
1529 },
1530 GraphOp::AddNode {
1531 node_id: "s1".into(),
1532 node_type: "signal".into(),
1533 label: "extension-typed".into(),
1534 properties: BTreeMap::new(),
1535 subtype: None,
1536 },
1537 ],
1538 op_clocks: vec![(1, 0), (1, 1), (1, 2)],
1539 compacted_at_physical_ms: 1,
1540 compacted_at_logical: 2,
1541 },
1542 1,
1543 "inst-a",
1544 );
1545 g.apply(&checkpoint);
1546
1547 assert!(g.get_node("n1").is_some());
1548 assert!(g.get_node("s1").is_some(), "extension-typed node lost");
1549 assert!(g.ontology.node_types.contains_key("signal"));
1550 assert!(g.quarantined.is_empty());
1551 }
1552
1553 #[test]
1558 fn checkpoint_replay_trusts_inner_ops() {
1559 use crate::ontology::{PropertyDef, ValueType};
1560
1561 let mut ont = test_ontology();
1562 ont.node_types.get_mut("entity").unwrap().properties.insert(
1563 "name".into(),
1564 PropertyDef {
1565 value_type: ValueType::String,
1566 required: true,
1567 description: None,
1568 constraints: None,
1569 },
1570 );
1571 let mut g = MaterializedGraph::new(ont.clone());
1572
1573 let checkpoint = make_entry(
1574 GraphOp::Checkpoint {
1575 ops: vec![
1576 GraphOp::DefineOntology { ontology: ont },
1577 GraphOp::AddNode {
1578 node_id: "n1".into(),
1579 node_type: "entity".into(),
1580 label: "req".into(),
1581 properties: BTreeMap::new(), subtype: None,
1583 },
1584 GraphOp::UpdateProperty {
1585 entity_id: "n1".into(),
1586 key: "name".into(),
1587 value: Value::String("x".into()),
1588 },
1589 ],
1590 op_clocks: vec![(1, 0), (1, 1), (1, 2)],
1591 compacted_at_physical_ms: 1,
1592 compacted_at_logical: 2,
1593 },
1594 1,
1595 "inst-a",
1596 );
1597 g.apply(&checkpoint);
1598
1599 let node = g.get_node("n1").expect("required-property node lost");
1600 assert_eq!(
1601 node.properties.get("name"),
1602 Some(&Value::String("x".into()))
1603 );
1604 assert!(g.quarantined.is_empty());
1605 }
1606}