Skip to main content

silk/
graph.rs

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/// A materialized node in the graph.
8#[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    /// Per-property clocks for LWW conflict resolution.
16    /// Each property key tracks the clock of its last write, so
17    /// concurrent updates to different properties don't conflict.
18    pub property_clocks: HashMap<String, LamportClock>,
19    /// Clock of the entry that last modified this node.
20    /// Used for add-wins semantics and label LWW.
21    pub last_clock: LamportClock,
22    /// Clock of the most recent AddNode for this node.
23    /// Used for add-wins semantics: remove only wins if its clock
24    /// is strictly greater than last_add_clock.
25    pub last_add_clock: LamportClock,
26    /// Whether this node has been tombstoned (removed).
27    pub tombstoned: bool,
28}
29
30/// A materialized edge in the graph.
31#[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    /// Per-property clocks for LWW conflict resolution.
39    pub property_clocks: HashMap<String, LamportClock>,
40    pub last_clock: LamportClock,
41    /// Clock of the most recent AddEdge for this edge.
42    pub last_add_clock: LamportClock,
43    pub tombstoned: bool,
44}
45
46/// Why an entry was quarantined (S1).
47///
48/// The validators produce a precise diagnosis — type name, property,
49/// expected type, allowed set, constraint name — and every quarantine site
50/// used to drop it one line before the operator needed it, leaving a bare
51/// hash and no way to tell an unknown type from a constraint violation.
52#[derive(Debug, Clone, PartialEq)]
53pub struct QuarantineRecord {
54    /// The operation kind that was rejected, e.g. "add_node".
55    pub op_kind: String,
56    /// The validator's own message.
57    pub reason: String,
58    /// Hex content hash of the ontology this decision was made against, so a
59    /// stale diagnosis is recognizable after the schema moves.
60    pub ontology_hash: String,
61}
62
63/// Materialized graph — derived from the op log.
64///
65/// Provides fast queries without replaying the full log.
66/// Updated incrementally as new entries arrive, or rebuilt
67/// from scratch by replaying the entire op log.
68///
69/// CRDT semantics:
70/// - **Add-wins** for topology (concurrent add + remove → node/edge exists)
71/// - **LWW** (Last-Writer-Wins) per property key (highest Lamport clock wins)
72/// - **Tombstones** for deletes (mark as deleted, don't physically remove)
73pub struct MaterializedGraph {
74    /// node_id → Node
75    pub nodes: HashMap<String, Node>,
76    /// edge_id → Edge
77    pub edges: HashMap<String, Edge>,
78    /// node_id → set of outgoing edge_ids
79    pub outgoing: HashMap<String, HashSet<String>>,
80    /// node_id → set of incoming edge_ids
81    pub incoming: HashMap<String, HashSet<String>>,
82    /// node_type → set of node_ids (type index)
83    pub by_type: HashMap<String, HashSet<String>>,
84    /// The ontology (for validation during materialization)
85    pub ontology: Ontology,
86    /// The ontology this graph started from, before any `ExtendOntology` was
87    /// replayed. `rebuild` resets to it so that replaying the log is
88    /// idempotent: without this, an extension already folded into `ontology`
89    /// is re-merged on rebuild, fails as a duplicate, and quarantines the
90    /// store's own schema entry. It also makes the effective ontology a
91    /// function of (base, oplog) rather than of how many rebuilds have run,
92    /// which is what I-06's proof assumes when it says both peers replay
93    /// "against the same evolved ontology".
94    base_ontology: Ontology,
95    /// R-02: entries that failed ontology validation during apply().
96    /// These entries exist in the oplog (for CRDT convergence) but are
97    /// invisible in the materialized graph. Grow-only within a single
98    /// materialization pass. Cleared and rebuilt on `rebuild()` — this
99    /// allows previously-quarantined entries to be re-evaluated when the
100    /// ontology evolves (e.g., after ExtendOntology arrives via sync).
101    pub quarantined: HashMap<Hash, QuarantineRecord>,
102    /// S9: edges that arrived before their endpoints. Held (and reported
103    /// quarantined) until both endpoints materialize, then re-evaluated with
104    /// full endpoint-type validation. Previously such edges were admitted
105    /// unvalidated on the promise of a rebuild that only fires when the merge
106    /// batch happens to carry a schema change.
107    pending_edges: HashMap<Hash, Entry>,
108}
109
110impl MaterializedGraph {
111    /// Create an empty materialized graph with the given ontology.
112    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    /// Apply a single entry to the graph (incremental materialization).
127    ///
128    /// R-02: Validates AddNode/AddEdge payloads against the ontology.
129    /// Invalid entries are quarantined (added to `self.quarantined`) and
130    /// skipped for materialization. They remain in the oplog for CRDT
131    /// convergence — quarantine is a graph-layer concern, not an oplog concern.
132    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    /// Core of `apply`. `mode` is `SkipRequired` only for a checkpoint's inner
141    /// ops, because `build_checkpoint_ops` emits `AddNode` with an empty
142    /// property map by design (EXP-02) with the values following as separate
143    /// `UpdateProperty` ops — enforcing required-presence there would
144    /// quarantine every such entity (Bug 14b). S2: the bypass is limited to
145    /// that one rule. Declared types, constraints and edge endpoints are
146    /// enforced on checkpoint inner ops like anything else.
147    ///
148    /// The checkpoint's own trustworthiness is established at the merge
149    /// boundary, not here: a foreign checkpoint may only enter a store whose
150    /// oplog is still genesis-only (H1). Replay has no valid baseline of its
151    /// own to judge against, since the ontology it carries is precisely what
152    /// the rest of the replay is relative to.
153    /// `adopt_checkpoint_ontology` is false only during `rebuild`'s second
154    /// pass, where the effective ontology has already been folded and a
155    /// checkpoint's inner `DefineOntology` must not clobber the extensions
156    /// that causally follow it.
157    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                // R-08: Replay synthetic ops to restore graph state.
176                // Bug 6 fix: use per-op clocks (preserves LWW metadata).
177                for (i, op) in ops.iter().enumerate() {
178                    // Bug 14 fix: compaction folds every ExtendOntology into the
179                    // checkpoint's inner DefineOntology. Apply it — otherwise
180                    // extension-typed entities fail validation and quarantine on
181                    // replay, and a reopened store materializes without them.
182                    // The oplog is authoritative; a declared ontology only seeds
183                    // new stores.
184                    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() // fallback for old checkpoints without op_clocks
194                    };
195                    let synthetic = Entry::new(op.clone(), vec![], vec![], clock, &entry.author);
196                    self.apply_entry(&synthetic, ValidationMode::SkipRequired);
197                }
198                // H6: the checkpoint itself is now materialized.
199                self.quarantined.remove(&entry.hash);
200            }
201            GraphOp::DefineOntology { .. } => {
202                // Genesis — nothing to materialize.
203            }
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                // R-02: validate against ontology, quarantine if invalid
219                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                // H6: quarantine must be a function of the oplog, not of sync
229                // history. An entry that validates now is not quarantined,
230                // even if a previous pass rejected it.
231                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                // S9: an endpoint just arrived — edges held for it can proceed.
241                self.retry_pending_edges();
242            }
243            GraphOp::AddEdge {
244                edge_id,
245                edge_type,
246                source_id,
247                target_id,
248                properties,
249            } => {
250                // R-02: validate edge type exists.
251                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                // Bug 13 fix: validate source/target type constraints when both nodes
259                // are materialized. S9: when an endpoint is missing the edge is
260                // held pending instead of admitted unvalidated, because the
261                // rebuild the old comment promised only fires when the batch
262                // happens to carry a schema change.
263                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                // H2: this arm applied the value with no validation at all, so
307                // a peer's UpdateProperty entered the graph regardless of the
308                // local ontology — the v0.1.6 bug reopened on the sync side.
309                // The entity's declared type is the one the graph already
310                // holds; if it is not materialized yet there is nothing to
311                // validate against and the value is accepted, exactly as the
312                // local path does.
313                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                // Reserved. No materialization — lenses are metadata, not graph state.
343            }
344        }
345    }
346
347    /// Apply a sequence of entries (full rematerialization from op log).
348    pub fn apply_all(&mut self, entries: &[&Entry]) {
349        for entry in entries {
350            self.apply(entry);
351        }
352    }
353
354    /// Rebuild from scratch: clear everything and replay all entries.
355    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        // Pass 1: fold the effective ontology from the log, starting at the
365        // base so each extension merges exactly once.
366        //
367        // Validity is then judged against the FINAL ontology, not against the
368        // ontology as of each entry's position. That is what makes the
369        // documented un-quarantine promise true in the ordinary case: an
370        // operator receives data whose type is unknown, extends the schema,
371        // and the data becomes visible — even though the extension is
372        // causally later than the data it rescues. Judging each entry against
373        // the schema as of its own position would re-quarantine it on every
374        // replay, forever.
375        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        // Pass 2: materialize everything else against that ontology.
403        // ExtendOntology entries are already folded and already judged.
404        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    /// S9: re-evaluate edges that were waiting on an endpoint. Called whenever
413    /// a node materializes. Edges whose endpoints are still missing stay
414    /// pending; the rest go through full validation and are admitted or
415    /// quarantined on their merits.
416    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    // -- Queries --
443
444    /// Get a node by ID (returns None if not found or tombstoned).
445    pub fn get_node(&self, node_id: &str) -> Option<&Node> {
446        self.nodes.get(node_id).filter(|n| !n.tombstoned)
447    }
448
449    /// Get an edge by ID (returns None if not found or tombstoned).
450    pub fn get_edge(&self, edge_id: &str) -> Option<&Edge> {
451        self.edges.get(edge_id).filter(|e| !e.tombstoned)
452    }
453
454    /// Query all live nodes of a given type, including descendants (RDFS rdfs9).
455    /// If "entity" has children "server" and "project", querying "entity" returns all three.
456    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    /// Query all live nodes of a given subtype.
473    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    /// Query nodes by a property value.
481    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    /// Get outgoing edges for a node (only live edges with live endpoints).
489    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    /// Get incoming edges for a node (only live edges with live endpoints).
501    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    /// Approximate heap memory used by the materialized graph (bytes).
513    /// Uses fixed overhead estimates per node/edge. Does not account for heap
514    /// allocations behind String/Vec in property values or allocator fragmentation.
515    /// Actual memory may be 2-3x higher for string-heavy graphs.
516    pub fn estimated_memory_bytes(&self) -> usize {
517        let mut total = 0;
518        // Nodes: id string + type string + label + properties + clocks + overhead
519        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            // Properties: key + estimated value size + clock per property
523            for (k, v) in &node.properties {
524                total += k.len() + std::mem::size_of_val(v) + 48; // key + value + clock overhead
525            }
526            total += 128; // fixed struct overhead (clocks, bools, HashMap shells)
527        }
528        // Edges: similar structure
529        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        // Adjacency indexes: outgoing + incoming (id strings + HashSet overhead)
538        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        // Type index
545        for (k, set) in &self.by_type {
546            total += k.len() + set.len() * 32;
547        }
548        // Quarantine set
549        total += self.quarantined.len() * 48;
550        total
551    }
552
553    /// All live nodes.
554    pub fn all_nodes(&self) -> Vec<&Node> {
555        self.nodes.values().filter(|n| !n.tombstoned).collect()
556    }
557
558    /// All live edges (with live endpoints).
559    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    /// Neighbors of a node (connected via outgoing edges).
569    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    /// Reverse neighbors (connected via incoming edges).
577    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    // -- CRDT application helpers --
585
586    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            // Add-wins: always resurrect from tombstone.
597            existing.tombstoned = false;
598            // Track the latest add clock for add-wins semantics.
599            if clock_wins(clock, &existing.last_add_clock) {
600                existing.last_add_clock = clock.clone();
601            }
602            // LWW merge for label, subtype, and properties.
603            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            // Add-wins: always resurrect if tombstoned.
649            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        // Try node first, then edge. Per-property LWW: each key competes
698        // only with other writes to the same key, not the entire entity.
699        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            // Update entity-level clock for add-wins tracking.
710            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        // If entity not found, silently ignore (may arrive out of order in sync).
728    }
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            // Add-wins: only tombstone if the remove clock is strictly greater
733            // than the last add clock. If a concurrent (or later) add exists,
734            // the node stays alive.
735            if clock_wins(clock, &node.last_add_clock) {
736                node.tombstoned = true;
737                node.last_clock = clock.clone();
738            }
739        }
740        // Tombstoning a node doesn't physically remove edges — they just become
741        // invisible via is_node_live() checks in queries.
742    }
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            // Add-wins: only tombstone if remove clock > last add clock.
747            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
762/// Per-property LWW merge: each property from `new_props` competes with
763/// existing properties. Higher clock wins per key.
764fn 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
782/// LWW comparison: returns true if `new_clock` wins over `existing_clock`.
783/// Uses HybridClock total ordering: (physical_ms, logical, id).
784fn 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_graph.rs spec from docs/silk.md --
850
851    #[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        // Both endpoints know about the edge.
914        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        // Remove node 'b' — edge becomes invisible (dangling target).
992        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        // Edge still exists but not returned by all_edges (target tombstoned).
1001        assert_eq!(g.all_edges().len(), 0);
1002        // Outgoing from 'a' also filters out dangling edges.
1003        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        // Nodes still exist.
1051        assert!(g.get_node("a").is_some());
1052        assert!(g.get_node("b").is_some());
1053        // Edge is gone.
1054        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        // Build graph incrementally.
1136        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        // Rebuild from scratch.
1184        let mut g2 = MaterializedGraph::new(test_ontology());
1185        let refs: Vec<&Entry> = entries.iter().collect();
1186        g2.rebuild(&refs);
1187
1188        // Same result.
1189        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        // Incremental.
1260        let mut g_inc = MaterializedGraph::new(test_ontology());
1261        for e in &entries {
1262            g_inc.apply(e);
1263        }
1264
1265        // Full replay.
1266        let mut g_full = MaterializedGraph::new(test_ontology());
1267        let refs: Vec<&Entry> = entries.iter().collect();
1268        g_full.rebuild(&refs);
1269
1270        // Property should be 2 (updated).
1271        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        // Edge should be removed.
1280        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        // Two instances update the same property — higher clock wins.
1287        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        // inst-a sets status=alive at time 2
1300        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        // inst-b sets status=dead at time 3 — wins (higher clock)
1310        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        // Same clock time — higher instance ID wins.
1328        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        // Both at physical_ms=5, logical=0. Lower id wins → "inst-a" wins.
1341        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        // inst-a has lower id → wins the tiebreak → value stays Int(1).
1360        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        // Two instances concurrently update DIFFERENT properties at the same
1369        // clock time. Both updates must be accepted — they don't conflict.
1370        // This requires per-property LWW, not node-level LWW.
1371        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        // inst-a updates "x" at time 3
1387        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        // inst-b updates "y" at time 3 (concurrent, different property)
1397        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        // Both updates must be applied — no conflict.
1409        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        // Same scenario but applied in reverse order — result must be identical.
1424        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        // Apply inst-b first this time
1440        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        // Concurrent add + remove → node should exist (add-wins).
1467        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        // Remove at time 2.
1480        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        // Re-add at time 3 (add-wins — resurrects).
1490        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    /// Bug 14: a checkpoint's inner DefineOntology carries the merged ontology
1507    /// (compaction folds ExtendOntology entries into it). Replay must apply it,
1508    /// or entities typed by an extension quarantine and vanish from the graph.
1509    #[test]
1510    fn checkpoint_replay_applies_inner_define_ontology() {
1511        // Base ontology: "entity" only — no "signal".
1512        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(), // merged: has "signal"
1522                    },
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    /// Bug 14b: build_checkpoint_ops emits AddNode with empty properties
1554    /// (EXP-02: per-property clocks ride in separate UpdateProperty ops).
1555    /// Replay must trust checkpoint inner ops — validating them fails any
1556    /// required property against the empty map and quarantines the node.
1557    #[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(), // empty by design
1582                        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}