806 lines
21 KiB
Rust
806 lines
21 KiB
Rust
//! Replay Determinism Tests
|
|
//!
|
|
//! Verifies that replaying the same sequence of events produces identical state.
|
|
//! This is critical for:
|
|
//! - Reproducible debugging
|
|
//! - Witness chain validation
|
|
//! - Distributed consensus
|
|
//! - Audit trail verification
|
|
|
|
use std::collections::HashMap;
|
|
use std::hash::{Hash, Hasher};
|
|
|
|
// ============================================================================
|
|
// EVENT TYPES
|
|
// ============================================================================
|
|
|
|
/// Domain events that can modify coherence state
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
enum DomainEvent {
|
|
/// Add a node with initial state
|
|
NodeAdded {
|
|
node_id: u64,
|
|
state: Vec<f32>,
|
|
timestamp: u64,
|
|
},
|
|
/// Update a node's state
|
|
NodeUpdated {
|
|
node_id: u64,
|
|
old_state: Vec<f32>,
|
|
new_state: Vec<f32>,
|
|
timestamp: u64,
|
|
},
|
|
/// Remove a node
|
|
NodeRemoved { node_id: u64, timestamp: u64 },
|
|
/// Add an edge between nodes
|
|
EdgeAdded {
|
|
source: u64,
|
|
target: u64,
|
|
weight: f32,
|
|
timestamp: u64,
|
|
},
|
|
/// Update edge weight
|
|
EdgeWeightUpdated {
|
|
source: u64,
|
|
target: u64,
|
|
old_weight: f32,
|
|
new_weight: f32,
|
|
timestamp: u64,
|
|
},
|
|
/// Remove an edge
|
|
EdgeRemoved {
|
|
source: u64,
|
|
target: u64,
|
|
timestamp: u64,
|
|
},
|
|
/// Policy threshold change
|
|
ThresholdChanged {
|
|
scope: String,
|
|
old_threshold: f32,
|
|
new_threshold: f32,
|
|
timestamp: u64,
|
|
},
|
|
}
|
|
|
|
impl DomainEvent {
|
|
fn timestamp(&self) -> u64 {
|
|
match self {
|
|
DomainEvent::NodeAdded { timestamp, .. } => *timestamp,
|
|
DomainEvent::NodeUpdated { timestamp, .. } => *timestamp,
|
|
DomainEvent::NodeRemoved { timestamp, .. } => *timestamp,
|
|
DomainEvent::EdgeAdded { timestamp, .. } => *timestamp,
|
|
DomainEvent::EdgeWeightUpdated { timestamp, .. } => *timestamp,
|
|
DomainEvent::EdgeRemoved { timestamp, .. } => *timestamp,
|
|
DomainEvent::ThresholdChanged { timestamp, .. } => *timestamp,
|
|
}
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// COHERENCE STATE
|
|
// ============================================================================
|
|
|
|
/// Coherence engine state (simplified for testing)
|
|
#[derive(Clone, Debug)]
|
|
struct CoherenceState {
|
|
nodes: HashMap<u64, Vec<f32>>,
|
|
edges: HashMap<(u64, u64), f32>,
|
|
thresholds: HashMap<String, f32>,
|
|
energy_cache: Option<f32>,
|
|
event_count: u64,
|
|
}
|
|
|
|
impl CoherenceState {
|
|
fn new() -> Self {
|
|
Self {
|
|
nodes: HashMap::new(),
|
|
edges: HashMap::new(),
|
|
thresholds: HashMap::new(),
|
|
energy_cache: None,
|
|
event_count: 0,
|
|
}
|
|
}
|
|
|
|
fn apply(&mut self, event: &DomainEvent) {
|
|
self.event_count += 1;
|
|
self.energy_cache = None; // Invalidate cache
|
|
|
|
match event {
|
|
DomainEvent::NodeAdded { node_id, state, .. } => {
|
|
self.nodes.insert(*node_id, state.clone());
|
|
}
|
|
DomainEvent::NodeUpdated {
|
|
node_id, new_state, ..
|
|
} => {
|
|
self.nodes.insert(*node_id, new_state.clone());
|
|
}
|
|
DomainEvent::NodeRemoved { node_id, .. } => {
|
|
self.nodes.remove(node_id);
|
|
// Remove incident edges
|
|
self.edges
|
|
.retain(|(s, t), _| *s != *node_id && *t != *node_id);
|
|
}
|
|
DomainEvent::EdgeAdded {
|
|
source,
|
|
target,
|
|
weight,
|
|
..
|
|
} => {
|
|
self.edges.insert((*source, *target), *weight);
|
|
}
|
|
DomainEvent::EdgeWeightUpdated {
|
|
source,
|
|
target,
|
|
new_weight,
|
|
..
|
|
} => {
|
|
self.edges.insert((*source, *target), *new_weight);
|
|
}
|
|
DomainEvent::EdgeRemoved { source, target, .. } => {
|
|
self.edges.remove(&(*source, *target));
|
|
}
|
|
DomainEvent::ThresholdChanged {
|
|
scope,
|
|
new_threshold,
|
|
..
|
|
} => {
|
|
self.thresholds.insert(scope.clone(), *new_threshold);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn compute_energy(&mut self) -> f32 {
|
|
if let Some(cached) = self.energy_cache {
|
|
return cached;
|
|
}
|
|
|
|
let mut total = 0.0;
|
|
for ((src, tgt), weight) in &self.edges {
|
|
if let (Some(src_state), Some(tgt_state)) = (self.nodes.get(src), self.nodes.get(tgt)) {
|
|
let dim = src_state.len().min(tgt_state.len());
|
|
let residual_norm_sq: f32 = src_state
|
|
.iter()
|
|
.take(dim)
|
|
.zip(tgt_state.iter().take(dim))
|
|
.map(|(a, b)| (a - b).powi(2))
|
|
.sum();
|
|
total += weight * residual_norm_sq;
|
|
}
|
|
}
|
|
|
|
self.energy_cache = Some(total);
|
|
total
|
|
}
|
|
|
|
/// Compute a deterministic fingerprint of the state
|
|
fn fingerprint(&self) -> u64 {
|
|
use std::collections::hash_map::DefaultHasher;
|
|
|
|
let mut hasher = DefaultHasher::new();
|
|
|
|
// Hash nodes in sorted order
|
|
let mut node_keys: Vec<_> = self.nodes.keys().collect();
|
|
node_keys.sort();
|
|
for key in node_keys {
|
|
key.hash(&mut hasher);
|
|
let state = self.nodes.get(key).unwrap();
|
|
for val in state {
|
|
val.to_bits().hash(&mut hasher);
|
|
}
|
|
}
|
|
|
|
// Hash edges in sorted order
|
|
let mut edge_keys: Vec<_> = self.edges.keys().collect();
|
|
edge_keys.sort();
|
|
for key in edge_keys {
|
|
key.hash(&mut hasher);
|
|
let weight = self.edges.get(key).unwrap();
|
|
weight.to_bits().hash(&mut hasher);
|
|
}
|
|
|
|
// Hash thresholds
|
|
let mut threshold_keys: Vec<_> = self.thresholds.keys().collect();
|
|
threshold_keys.sort();
|
|
for key in threshold_keys {
|
|
key.hash(&mut hasher);
|
|
let val = self.thresholds.get(key).unwrap();
|
|
val.to_bits().hash(&mut hasher);
|
|
}
|
|
|
|
hasher.finish()
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// EVENT LOG
|
|
// ============================================================================
|
|
|
|
/// Event log for replay
|
|
#[derive(Clone, Debug)]
|
|
struct EventLog {
|
|
events: Vec<DomainEvent>,
|
|
}
|
|
|
|
impl EventLog {
|
|
fn new() -> Self {
|
|
Self { events: Vec::new() }
|
|
}
|
|
|
|
fn append(&mut self, event: DomainEvent) {
|
|
self.events.push(event);
|
|
}
|
|
|
|
fn replay(&self) -> CoherenceState {
|
|
let mut state = CoherenceState::new();
|
|
for event in &self.events {
|
|
state.apply(event);
|
|
}
|
|
state
|
|
}
|
|
|
|
fn replay_until(&self, timestamp: u64) -> CoherenceState {
|
|
let mut state = CoherenceState::new();
|
|
for event in &self.events {
|
|
if event.timestamp() <= timestamp {
|
|
state.apply(event);
|
|
}
|
|
}
|
|
state
|
|
}
|
|
|
|
fn len(&self) -> usize {
|
|
self.events.len()
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// TESTS: BASIC REPLAY DETERMINISM
|
|
// ============================================================================
|
|
|
|
#[test]
|
|
fn test_empty_replay() {
|
|
let log = EventLog::new();
|
|
|
|
let state1 = log.replay();
|
|
let state2 = log.replay();
|
|
|
|
assert_eq!(state1.fingerprint(), state2.fingerprint());
|
|
assert_eq!(state1.event_count, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_single_event_replay() {
|
|
let mut log = EventLog::new();
|
|
log.append(DomainEvent::NodeAdded {
|
|
node_id: 1,
|
|
state: vec![1.0, 0.5, 0.3],
|
|
timestamp: 1000,
|
|
});
|
|
|
|
let state1 = log.replay();
|
|
let state2 = log.replay();
|
|
|
|
assert_eq!(state1.fingerprint(), state2.fingerprint());
|
|
assert_eq!(state1.nodes.len(), 1);
|
|
assert_eq!(state2.nodes.len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn test_multiple_events_replay() {
|
|
let mut log = EventLog::new();
|
|
|
|
// Create a small graph
|
|
log.append(DomainEvent::NodeAdded {
|
|
node_id: 1,
|
|
state: vec![1.0, 0.0],
|
|
timestamp: 1000,
|
|
});
|
|
log.append(DomainEvent::NodeAdded {
|
|
node_id: 2,
|
|
state: vec![0.5, 0.5],
|
|
timestamp: 1001,
|
|
});
|
|
log.append(DomainEvent::NodeAdded {
|
|
node_id: 3,
|
|
state: vec![0.0, 1.0],
|
|
timestamp: 1002,
|
|
});
|
|
log.append(DomainEvent::EdgeAdded {
|
|
source: 1,
|
|
target: 2,
|
|
weight: 1.0,
|
|
timestamp: 1003,
|
|
});
|
|
log.append(DomainEvent::EdgeAdded {
|
|
source: 2,
|
|
target: 3,
|
|
weight: 1.0,
|
|
timestamp: 1004,
|
|
});
|
|
|
|
let state1 = log.replay();
|
|
let state2 = log.replay();
|
|
let state3 = log.replay();
|
|
|
|
assert_eq!(state1.fingerprint(), state2.fingerprint());
|
|
assert_eq!(state2.fingerprint(), state3.fingerprint());
|
|
|
|
assert_eq!(state1.nodes.len(), 3);
|
|
assert_eq!(state1.edges.len(), 2);
|
|
}
|
|
|
|
// ============================================================================
|
|
// TESTS: ENERGY DETERMINISM
|
|
// ============================================================================
|
|
|
|
#[test]
|
|
fn test_energy_determinism() {
|
|
let mut log = EventLog::new();
|
|
|
|
log.append(DomainEvent::NodeAdded {
|
|
node_id: 1,
|
|
state: vec![1.0, 0.5, 0.3],
|
|
timestamp: 1000,
|
|
});
|
|
log.append(DomainEvent::NodeAdded {
|
|
node_id: 2,
|
|
state: vec![0.8, 0.6, 0.4],
|
|
timestamp: 1001,
|
|
});
|
|
log.append(DomainEvent::EdgeAdded {
|
|
source: 1,
|
|
target: 2,
|
|
weight: 1.0,
|
|
timestamp: 1002,
|
|
});
|
|
|
|
let mut state1 = log.replay();
|
|
let mut state2 = log.replay();
|
|
|
|
let energy1 = state1.compute_energy();
|
|
let energy2 = state2.compute_energy();
|
|
|
|
assert!(
|
|
(energy1 - energy2).abs() < 1e-10,
|
|
"Energy should be deterministic: {} vs {}",
|
|
energy1,
|
|
energy2
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_energy_determinism_after_updates() {
|
|
let mut log = EventLog::new();
|
|
|
|
log.append(DomainEvent::NodeAdded {
|
|
node_id: 1,
|
|
state: vec![1.0, 0.5],
|
|
timestamp: 1000,
|
|
});
|
|
log.append(DomainEvent::NodeAdded {
|
|
node_id: 2,
|
|
state: vec![0.5, 0.5],
|
|
timestamp: 1001,
|
|
});
|
|
log.append(DomainEvent::EdgeAdded {
|
|
source: 1,
|
|
target: 2,
|
|
weight: 1.0,
|
|
timestamp: 1002,
|
|
});
|
|
log.append(DomainEvent::NodeUpdated {
|
|
node_id: 1,
|
|
old_state: vec![1.0, 0.5],
|
|
new_state: vec![0.7, 0.6],
|
|
timestamp: 1003,
|
|
});
|
|
log.append(DomainEvent::EdgeWeightUpdated {
|
|
source: 1,
|
|
target: 2,
|
|
old_weight: 1.0,
|
|
new_weight: 2.0,
|
|
timestamp: 1004,
|
|
});
|
|
|
|
let mut state1 = log.replay();
|
|
let mut state2 = log.replay();
|
|
|
|
let energy1 = state1.compute_energy();
|
|
let energy2 = state2.compute_energy();
|
|
|
|
assert!(
|
|
(energy1 - energy2).abs() < 1e-10,
|
|
"Energy should be deterministic after updates"
|
|
);
|
|
}
|
|
|
|
// ============================================================================
|
|
// TESTS: PARTIAL REPLAY
|
|
// ============================================================================
|
|
|
|
#[test]
|
|
fn test_partial_replay_consistent() {
|
|
let mut log = EventLog::new();
|
|
|
|
for i in 1..=10 {
|
|
log.append(DomainEvent::NodeAdded {
|
|
node_id: i,
|
|
state: vec![i as f32 / 10.0],
|
|
timestamp: 1000 + i,
|
|
});
|
|
}
|
|
|
|
// Replay until different points
|
|
let state_5 = log.replay_until(1005);
|
|
let state_10 = log.replay_until(1010);
|
|
|
|
assert_eq!(state_5.nodes.len(), 5);
|
|
assert_eq!(state_10.nodes.len(), 10);
|
|
|
|
// Replaying to the same point should give the same state
|
|
let state_5_again = log.replay_until(1005);
|
|
assert_eq!(state_5.fingerprint(), state_5_again.fingerprint());
|
|
}
|
|
|
|
#[test]
|
|
fn test_partial_replay_monotonic() {
|
|
let mut log = EventLog::new();
|
|
|
|
for i in 1..=5 {
|
|
log.append(DomainEvent::NodeAdded {
|
|
node_id: i,
|
|
state: vec![i as f32],
|
|
timestamp: 1000 + i,
|
|
});
|
|
}
|
|
|
|
// State should grow monotonically with timestamp
|
|
let mut prev_nodes = 0;
|
|
for t in 1001..=1005 {
|
|
let state = log.replay_until(t);
|
|
assert!(state.nodes.len() > prev_nodes || prev_nodes == 0);
|
|
prev_nodes = state.nodes.len();
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// TESTS: EVENT ORDER INDEPENDENCE
|
|
// ============================================================================
|
|
|
|
#[test]
|
|
fn test_independent_events_commute() {
|
|
// Events on different parts of the graph should commute
|
|
let events_a = vec![
|
|
DomainEvent::NodeAdded {
|
|
node_id: 1,
|
|
state: vec![1.0],
|
|
timestamp: 1000,
|
|
},
|
|
DomainEvent::NodeAdded {
|
|
node_id: 2,
|
|
state: vec![2.0],
|
|
timestamp: 1001,
|
|
},
|
|
];
|
|
|
|
let events_b = vec![
|
|
DomainEvent::NodeAdded {
|
|
node_id: 2,
|
|
state: vec![2.0],
|
|
timestamp: 1001,
|
|
},
|
|
DomainEvent::NodeAdded {
|
|
node_id: 1,
|
|
state: vec![1.0],
|
|
timestamp: 1000,
|
|
},
|
|
];
|
|
|
|
let mut log_a = EventLog::new();
|
|
for e in events_a {
|
|
log_a.append(e);
|
|
}
|
|
|
|
let mut log_b = EventLog::new();
|
|
for e in events_b {
|
|
log_b.append(e);
|
|
}
|
|
|
|
let state_a = log_a.replay();
|
|
let state_b = log_b.replay();
|
|
|
|
// Independent node additions should give same final state
|
|
assert_eq!(state_a.nodes.len(), state_b.nodes.len());
|
|
assert_eq!(state_a.fingerprint(), state_b.fingerprint());
|
|
}
|
|
|
|
#[test]
|
|
fn test_dependent_events_order_matters() {
|
|
// Update after add vs. add directly with new value
|
|
let mut log1 = EventLog::new();
|
|
log1.append(DomainEvent::NodeAdded {
|
|
node_id: 1,
|
|
state: vec![1.0],
|
|
timestamp: 1000,
|
|
});
|
|
log1.append(DomainEvent::NodeUpdated {
|
|
node_id: 1,
|
|
old_state: vec![1.0],
|
|
new_state: vec![2.0],
|
|
timestamp: 1001,
|
|
});
|
|
|
|
let mut log2 = EventLog::new();
|
|
log2.append(DomainEvent::NodeAdded {
|
|
node_id: 1,
|
|
state: vec![2.0],
|
|
timestamp: 1000,
|
|
});
|
|
|
|
let state1 = log1.replay();
|
|
let state2 = log2.replay();
|
|
|
|
// Both should result in node 1 having state [2.0]
|
|
assert_eq!(state1.nodes.get(&1), state2.nodes.get(&1));
|
|
}
|
|
|
|
// ============================================================================
|
|
// TESTS: LARGE SCALE REPLAY
|
|
// ============================================================================
|
|
|
|
#[test]
|
|
fn test_large_event_log_replay() {
|
|
let mut log = EventLog::new();
|
|
|
|
// Create a moderately large graph
|
|
let num_nodes = 100;
|
|
let num_edges = 200;
|
|
|
|
for i in 0..num_nodes {
|
|
log.append(DomainEvent::NodeAdded {
|
|
node_id: i,
|
|
state: vec![i as f32 / num_nodes as f32; 4],
|
|
timestamp: 1000 + i,
|
|
});
|
|
}
|
|
|
|
for i in 0..num_edges {
|
|
log.append(DomainEvent::EdgeAdded {
|
|
source: i % num_nodes,
|
|
target: (i + 1) % num_nodes,
|
|
weight: 1.0,
|
|
timestamp: 1000 + num_nodes + i,
|
|
});
|
|
}
|
|
|
|
// Replay multiple times
|
|
let states: Vec<_> = (0..5).map(|_| log.replay()).collect();
|
|
|
|
// All replays should produce the same fingerprint
|
|
let first_fp = states[0].fingerprint();
|
|
for state in &states {
|
|
assert_eq!(state.fingerprint(), first_fp);
|
|
assert_eq!(state.nodes.len(), num_nodes as usize);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_replay_with_many_updates() {
|
|
let mut log = EventLog::new();
|
|
|
|
// Create nodes
|
|
for i in 0..10 {
|
|
log.append(DomainEvent::NodeAdded {
|
|
node_id: i,
|
|
state: vec![0.0; 3],
|
|
timestamp: 1000 + i,
|
|
});
|
|
}
|
|
|
|
// Many updates
|
|
for iteration in 0..100 {
|
|
let node_id = iteration % 10;
|
|
let new_val = iteration as f32 / 100.0;
|
|
log.append(DomainEvent::NodeUpdated {
|
|
node_id,
|
|
old_state: vec![0.0; 3], // Simplified
|
|
new_state: vec![new_val; 3],
|
|
timestamp: 2000 + iteration,
|
|
});
|
|
}
|
|
|
|
// Replay should be deterministic
|
|
let state1 = log.replay();
|
|
let state2 = log.replay();
|
|
|
|
assert_eq!(state1.fingerprint(), state2.fingerprint());
|
|
assert_eq!(log.len(), 110); // 10 adds + 100 updates
|
|
}
|
|
|
|
// ============================================================================
|
|
// TESTS: SNAPSHOT AND RESTORE
|
|
// ============================================================================
|
|
|
|
#[test]
|
|
fn test_snapshot_consistency() {
|
|
let mut log = EventLog::new();
|
|
|
|
// Build up state
|
|
for i in 0..5 {
|
|
log.append(DomainEvent::NodeAdded {
|
|
node_id: i,
|
|
state: vec![i as f32],
|
|
timestamp: 1000 + i,
|
|
});
|
|
}
|
|
|
|
// Take a "snapshot" (clone the state)
|
|
let snapshot = log.replay();
|
|
let snapshot_fp = snapshot.fingerprint();
|
|
|
|
// Add more events
|
|
for i in 5..10 {
|
|
log.append(DomainEvent::NodeAdded {
|
|
node_id: i,
|
|
state: vec![i as f32],
|
|
timestamp: 2000 + i,
|
|
});
|
|
}
|
|
|
|
// Replay up to snapshot point should match snapshot
|
|
let restored = log.replay_until(1004);
|
|
assert_eq!(restored.fingerprint(), snapshot_fp);
|
|
}
|
|
|
|
// ============================================================================
|
|
// TESTS: CONCURRENT REPLAYS
|
|
// ============================================================================
|
|
|
|
#[test]
|
|
fn test_concurrent_replays() {
|
|
use std::sync::Arc;
|
|
use std::thread;
|
|
|
|
let mut log = EventLog::new();
|
|
|
|
for i in 0..50 {
|
|
log.append(DomainEvent::NodeAdded {
|
|
node_id: i,
|
|
state: vec![i as f32 / 50.0; 4],
|
|
timestamp: 1000 + i,
|
|
});
|
|
}
|
|
|
|
for i in 0..100 {
|
|
log.append(DomainEvent::EdgeAdded {
|
|
source: i % 50,
|
|
target: (i + 1) % 50,
|
|
weight: 1.0,
|
|
timestamp: 2000 + i,
|
|
});
|
|
}
|
|
|
|
let log = Arc::new(log);
|
|
|
|
let handles: Vec<_> = (0..8)
|
|
.map(|_| {
|
|
let log = Arc::clone(&log);
|
|
thread::spawn(move || {
|
|
let state = log.replay();
|
|
state.fingerprint()
|
|
})
|
|
})
|
|
.collect();
|
|
|
|
let fingerprints: Vec<u64> = handles.into_iter().map(|h| h.join().unwrap()).collect();
|
|
|
|
// All concurrent replays should produce the same fingerprint
|
|
let first = fingerprints[0];
|
|
for fp in &fingerprints {
|
|
assert_eq!(
|
|
*fp, first,
|
|
"All replays should produce the same fingerprint"
|
|
);
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// TESTS: IDEMPOTENCY
|
|
// ============================================================================
|
|
|
|
#[test]
|
|
fn test_double_replay_idempotent() {
|
|
let mut log = EventLog::new();
|
|
|
|
log.append(DomainEvent::NodeAdded {
|
|
node_id: 1,
|
|
state: vec![1.0, 0.5],
|
|
timestamp: 1000,
|
|
});
|
|
log.append(DomainEvent::EdgeAdded {
|
|
source: 1,
|
|
target: 1, // Self-loop (edge case)
|
|
weight: 0.5,
|
|
timestamp: 1001,
|
|
});
|
|
|
|
// Replay twice from the log
|
|
let state1 = log.replay();
|
|
let state2 = log.replay();
|
|
|
|
// States should be identical
|
|
assert_eq!(state1.fingerprint(), state2.fingerprint());
|
|
assert_eq!(state1.event_count, state2.event_count);
|
|
}
|
|
|
|
// ============================================================================
|
|
// TESTS: DELETION HANDLING
|
|
// ============================================================================
|
|
|
|
#[test]
|
|
fn test_deletion_replay() {
|
|
let mut log = EventLog::new();
|
|
|
|
// Add nodes
|
|
log.append(DomainEvent::NodeAdded {
|
|
node_id: 1,
|
|
state: vec![1.0],
|
|
timestamp: 1000,
|
|
});
|
|
log.append(DomainEvent::NodeAdded {
|
|
node_id: 2,
|
|
state: vec![2.0],
|
|
timestamp: 1001,
|
|
});
|
|
log.append(DomainEvent::EdgeAdded {
|
|
source: 1,
|
|
target: 2,
|
|
weight: 1.0,
|
|
timestamp: 1002,
|
|
});
|
|
|
|
// Delete node (should cascade to edge)
|
|
log.append(DomainEvent::NodeRemoved {
|
|
node_id: 1,
|
|
timestamp: 1003,
|
|
});
|
|
|
|
let state1 = log.replay();
|
|
let state2 = log.replay();
|
|
|
|
assert_eq!(state1.fingerprint(), state2.fingerprint());
|
|
assert_eq!(state1.nodes.len(), 1); // Only node 2 remains
|
|
assert_eq!(state1.edges.len(), 0); // Edge was removed with node 1
|
|
}
|
|
|
|
#[test]
|
|
fn test_add_delete_add_determinism() {
|
|
let mut log = EventLog::new();
|
|
|
|
// Add node
|
|
log.append(DomainEvent::NodeAdded {
|
|
node_id: 1,
|
|
state: vec![1.0],
|
|
timestamp: 1000,
|
|
});
|
|
|
|
// Delete node
|
|
log.append(DomainEvent::NodeRemoved {
|
|
node_id: 1,
|
|
timestamp: 1001,
|
|
});
|
|
|
|
// Re-add node with different state
|
|
log.append(DomainEvent::NodeAdded {
|
|
node_id: 1,
|
|
state: vec![2.0],
|
|
timestamp: 1002,
|
|
});
|
|
|
|
let state1 = log.replay();
|
|
let state2 = log.replay();
|
|
|
|
assert_eq!(state1.fingerprint(), state2.fingerprint());
|
|
assert_eq!(state1.nodes.get(&1), Some(&vec![2.0]));
|
|
}
|