use std::collections::HashSet; use crate::{AttributeDelta, NetworkId, Node, NodeId, Registry, RegistryDelta, ResourceEntry, ResourceId}; /// Collect a `HashSet` walk (difference/intersection) into ascending order. The sets iterate in /// random order, so sorting keeps `compute_deltas` emitting a deterministic delta sequence. fn sorted<'a, T: Ord + Copy + 'a>(ids: impl Iterator) -> Vec { let mut ids: Vec = ids.copied().collect(); ids.sort_unstable(); ids } /// Minimal set of deltas to transform `from` into `to`. /// /// Emits timestamp-less op shapes; the caller (`Document::commit_local` or equivalent) wraps each /// in a `Delta` with a fresh clock tick. pub fn compute_deltas(from: &Registry, to: &Registry) -> Vec { let mut deltas = Vec::new(); let from_network_ids: HashSet = from.networks.keys().copied().collect(); let to_network_ids: HashSet = to.networks.keys().copied().collect(); // AddNetwork before any AddNode that references it. `HashSet` difference/intersection iterate in // random order, so every set walk below is sorted to keep the emitted delta sequence (and thus the // resulting `Rev` chain) deterministic across runs. for network_id in sorted(to_network_ids.difference(&from_network_ids)) { deltas.push(RegistryDelta::AddNetwork { id: network_id, network: to.networks[&network_id].clone(), }); } let from_node_ids: HashSet = from.node_instances.keys().copied().collect(); let to_node_ids: HashSet = to.node_instances.keys().copied().collect(); for node_id in sorted(from_node_ids.difference(&to_node_ids)) { deltas.push(RegistryDelta::RemoveNode { id: node_id, snapshot: from.node_instances[&node_id].clone(), }); } for node_id in sorted(to_node_ids.difference(&from_node_ids)) { deltas.push(RegistryDelta::AddNode { id: node_id, node: to.node_instances[&node_id].clone(), }); } for node_id in sorted(from_node_ids.intersection(&to_node_ids)) { let from_node = &from.node_instances[&node_id]; let to_node = &to.node_instances[&node_id]; // No `ChangeImplementation` op; the only path is remove + re-add. Same for input-count and // containing-network changes (a moved node has no in-place op either). `inputs_attributes` is // checked too: the per-slot loops below `zip` only the shared prefix, so a length change there // must force a remove + re-add rather than silently dropping the extra slots. let structural_change = !nodes_have_same_implementation(from_node, to_node) || from_node.inputs.len() != to_node.inputs.len() || from_node.network != to_node.network; if structural_change { deltas.push(RegistryDelta::RemoveNode { id: node_id, snapshot: from_node.clone(), }); deltas.push(RegistryDelta::AddNode { id: node_id, node: to_node.clone() }); continue; } // Compare by value, ignoring the per-slot timestamp. Timestamps are derived from the diff // (assigned by the caller via clock.tick), not part of the diff itself: a slot whose value // is unchanged but whose timestamp differs should not emit a delta. for (input_idx, (from_slot, to_slot)) in from_node.inputs.iter().zip(&to_node.inputs).enumerate() { if from_slot.input != to_slot.input { deltas.push(RegistryDelta::ChangeNodeInput { id: node_id, index: input_idx as u32, new_input: to_slot.input.clone(), }); } } for delta in compute_attribute_deltas(&from_node.attributes, &to_node.attributes) { deltas.push(RegistryDelta::ChangeNodeAttribute { id: node_id, delta }); } for (input_idx, (from_input, to_input)) in from_node.inputs.iter().zip(&to_node.inputs).enumerate() { for delta in compute_attribute_deltas(&from_input.attributes, &to_input.attributes) { deltas.push(RegistryDelta::ChangeNodeInputAttribute { id: node_id, index: input_idx as u32, delta, }); } } } for network_id in sorted(from_network_ids.difference(&to_network_ids)) { deltas.push(RegistryDelta::RemoveNetwork { id: network_id, snapshot: from.networks[&network_id].clone(), }); } for network_id in sorted(from_network_ids.intersection(&to_network_ids)) { let from_network = &from.networks[&network_id]; let to_network = &to.networks[&network_id]; let max_len = from_network.exports.len().max(to_network.exports.len()); for slot_idx in 0..max_len { let from_slot = from_network.exports.get(slot_idx); let to_slot = to_network.exports.get(slot_idx); let from_target = from_slot.and_then(|s| s.target.as_ref()); let to_target = to_slot.and_then(|s| s.target.as_ref()); if from_target != to_target { deltas.push(RegistryDelta::SetNetworkExport { id: network_id, index: slot_idx as u32, export: to_target.cloned(), }); } } // Per-network attributes. for delta in compute_attribute_deltas(&from_network.attributes, &to_network.attributes) { deltas.push(RegistryDelta::ChangeNetworkAttribute { id: network_id, delta }); } } // Document-level attributes (`ui::doc::*`, format version, ...). for delta in compute_attribute_deltas(&from.attributes, &to.attributes) { deltas.push(RegistryDelta::ChangeDocumentAttribute { delta }); } compute_resource_deltas(from, to, &mut deltas); deltas } /// Diff the resource store, emitting whole-entry add/remove for resources that appear or vanish and /// fine-grained hash/source ops for resources present in both. Value-only: per-entry and per-source /// timestamps are derived by the caller, so an unchanged resource emits nothing. fn compute_resource_deltas(from: &Registry, to: &Registry, deltas: &mut Vec) { let from_ids: HashSet = from.resources.keys().copied().collect(); let to_ids: HashSet = to.resources.keys().copied().collect(); for id in sorted(from_ids.difference(&to_ids)) { deltas.push(RegistryDelta::RemoveResource { id, snapshot: from.resources[&id].clone(), }); } for id in sorted(to_ids.difference(&from_ids)) { deltas.push(RegistryDelta::AddResource { id, entry: to.resources[&id].clone() }); } for id in sorted(from_ids.intersection(&to_ids)) { diff_resource_entry(id, &from.resources[&id], &to.resources[&id], deltas); } } /// Per-entry diff for a resource present in both registries: hash change, then source chain /// additions/changes/removals. fn diff_resource_entry(id: ResourceId, from: &ResourceEntry, to: &ResourceEntry, deltas: &mut Vec) { if from.hash != to.hash { deltas.push(RegistryDelta::SetResourceHash { id, hash: to.hash }); } for (key, _) in &from.sources { if to.source(key).is_none() { deltas.push(RegistryDelta::RemoveSource { id, key: *key }); } } // Compare source bodies only; the per-source timestamp is derived from the diff, not part of it. for (key, to_source) in &to.sources { if from.source(key).is_none_or(|from_source| from_source.source != to_source.source) { deltas.push(RegistryDelta::AddSource { id, key: *key, source: to_source.source.clone(), }); } } } fn nodes_have_same_implementation(a: &Node, b: &Node) -> bool { use crate::Implementation::*; match (&a.implementation, &b.implementation) { (ProtoNode(a_id), ProtoNode(b_id)) => a_id == b_id, (Network(a_id), Network(b_id)) => a_id == b_id, _ => false, } } fn compute_attribute_deltas(from: &crate::Attributes, to: &crate::Attributes) -> Vec { let mut deltas = Vec::new(); for key in from.keys() { if !to.contains_key(key) { deltas.push(AttributeDelta { key: key.clone(), value: None }); } } // Compare by `value` only; the per-entry `timestamp` is derived from the diff, not part of it. for (key, to_value) in to { if from.get(key).is_none_or(|from_value| from_value.value != to_value.value) { deltas.push(AttributeDelta { key: key.clone(), value: Some(to_value.value.clone()), }); } } deltas } #[cfg(test)] mod tests { use super::*; use crate::{Attributes, ExportSlot, Network, Node, NodeInput, TimeStamp}; #[test] fn test_compute_deltas_empty() { let registry = Registry::default(); let deltas = compute_deltas(®istry, ®istry); assert_eq!(deltas.len(), 0, "No deltas should be generated for identical registries"); } /// The emitted delta sequence must not depend on `HashMap`/`HashSet` iteration order, which varies /// per run and per compiler version. Building the same registry repeatedly (each `HashMap` gets a /// fresh random seed) must yield identical `AddNode` order, since the diff sorts its set walks. #[test] fn compute_deltas_emits_nodes_in_deterministic_order() { let make_registry = || { let mut registry = Registry::default(); registry.networks.insert(NetworkId(0), Network::default()); for node_id in [50, 3, 17, 999, 1, 42, 8, 256, 100, 7] { registry.node_instances.insert(NodeId(node_id), Node::dummy()); } registry }; let empty = Registry::default(); let add_node_ids = |registry: &Registry| -> Vec { compute_deltas(&empty, registry) .into_iter() .filter_map(|delta| match delta { RegistryDelta::AddNode { id: node_id, .. } => Some(node_id), _ => None, }) .collect() }; let expected = vec![1, 3, 7, 8, 17, 42, 50, 100, 256, 999].into_iter().map(NodeId).collect::>(); for _ in 0..16 { assert_eq!(add_node_ids(&make_registry()), expected, "AddNode order must be deterministic (ascending)"); } } #[test] fn test_compute_deltas_add_node() { let from = Registry::default(); let mut to = from.clone(); let node = Node::dummy(); to.node_instances.insert(NodeId(42), node); let deltas = compute_deltas(&from, &to); assert_eq!(deltas.len(), 1); assert!(matches!(deltas[0], RegistryDelta::AddNode { id: NodeId(42), .. })); } /// A change in `inputs_attributes` length is structural: the per-slot diff only `zip`s the shared /// prefix, so it must force a remove + re-add rather than dropping the extra attribute slots. #[test] fn compute_deltas_treats_inputs_attributes_length_change_as_structural() { // Same implementation/inputs/network in both registries; only `inputs_attributes` length differs. let base = Node::dummy(); let mut from = Registry::default(); from.node_instances.insert(NodeId(42), base.clone()); let mut to = from.clone(); to.node_instances.get_mut(&NodeId(42)).unwrap().inputs.push(crate::InputSlot { input: NodeInput::Import { index: 0 }, timestamp: TimeStamp::ORIGIN, attributes: Attributes::new(), }); let deltas = compute_deltas(&from, &to); assert!( deltas.iter().any(|delta| matches!(delta, RegistryDelta::RemoveNode { id: NodeId(42), .. })) && deltas.iter().any(|delta| matches!(delta, RegistryDelta::AddNode { id: NodeId(42), .. })), "an inputs_attributes length change must emit RemoveNode + AddNode, got {deltas:?}" ); } #[test] fn test_compute_deltas_change_network_attribute() { use crate::{AttributesWrite, TimeStamp}; let mut from = Registry::default(); from.networks.insert(NetworkId(0), Network::default()); let mut to = from.clone(); to.networks .get_mut(&NetworkId(0)) .unwrap() .attributes .set("ui::nav::width", serde_json::json!(640.0), TimeStamp::ORIGIN); let deltas = compute_deltas(&from, &to); assert_eq!(deltas.len(), 1, "a changed per-network attribute must emit one delta"); assert!( matches!(&deltas[0], RegistryDelta::ChangeNetworkAttribute { id: NetworkId(0), delta } if delta.key == "ui::nav::width"), "expected ChangeNetworkAttribute for ui::nav::width, got {:?}", deltas[0] ); } #[test] fn test_compute_deltas_remove_node() { let mut from = Registry::default(); let node = Node::dummy(); from.node_instances.insert(NodeId(42), node); let to = Registry::default(); let deltas = compute_deltas(&from, &to); assert_eq!(deltas.len(), 1); assert!(matches!(deltas[0], RegistryDelta::RemoveNode { id: NodeId(42), .. })); } #[test] fn test_compute_deltas_modify_attribute() { let mut from = Registry::default(); let mut node = Node::dummy(); let stamp = |counter: u64| TimeStamp { counter, peer: crate::PeerId(0) }; node.attributes.insert( "test".to_string(), crate::Value { value: serde_json::json!("old"), timestamp: stamp(0), }, ); from.node_instances.insert(NodeId(42), node); let mut to = from.clone(); to.node_instances.get_mut(&NodeId(42)).unwrap().attributes.insert( "test".to_string(), crate::Value { value: serde_json::json!("new"), timestamp: stamp(1), }, ); let deltas = compute_deltas(&from, &to); assert_eq!(deltas.len(), 1); assert!(matches!( &deltas[0], RegistryDelta::ChangeNodeAttribute { id: NodeId(42), delta: AttributeDelta { key, value: Some(_) } } if key == "test" )); } /// Document-level attributes (the `Registry.attributes` bucket) must diff into /// `ChangeDocumentAttribute` deltas, so a document-scoped attribute change reaches the commit path. /// (Per-peer `ui::doc::*` view settings live in `session.json`, not here.) #[test] fn test_compute_deltas_document_attribute() { let stamp = |counter: u64| TimeStamp { counter, peer: crate::PeerId(0) }; let from = Registry::default(); let mut to = from.clone(); to.attributes.insert( "doc::test_attribute".to_string(), crate::Value { value: serde_json::json!("value"), timestamp: stamp(1), }, ); let deltas = compute_deltas(&from, &to); assert_eq!(deltas.len(), 1); assert!(matches!( &deltas[0], RegistryDelta::ChangeDocumentAttribute { delta: AttributeDelta { key, value: Some(_) } } if key == "doc::test_attribute" )); } #[test] fn test_compute_deltas_network_changes() { let make_slot = |id: u64| ExportSlot { target: Some(NodeInput::Node { id: NodeId(id), index: 0 }), timestamp: TimeStamp::ORIGIN, }; let mut from = Registry::default(); from.networks.insert( NetworkId(0), Network { exports: vec![make_slot(1), make_slot(2)], ..Default::default() }, ); let mut to = from.clone(); to.networks.get_mut(&NetworkId(0)).unwrap().exports.push(make_slot(3)); let deltas = compute_deltas(&from, &to); // Only slot 2 changed (added). Slots 0 and 1 are unchanged so they don't emit ops. assert_eq!(deltas.len(), 1); assert!(matches!( &deltas[0], RegistryDelta::SetNetworkExport { id: NetworkId(0), index: 2, export: Some(NodeInput::Node { id: NodeId(3), .. }), .. } )); } }