Skip to content

test(sync): I5 protection and CIP §2.3 negotiation compliance tests#1954

Closed
xilosada wants to merge 1 commit intomasterfrom
sync/snapshot-merge-protection-tests
Closed

test(sync): I5 protection and CIP §2.3 negotiation compliance tests#1954
xilosada wants to merge 1 commit intomasterfrom
sync/snapshot-merge-protection-tests

Conversation

@xilosada
Copy link
Member

@xilosada xilosada commented Feb 11, 2026

Summary

Adds comprehensive test coverage for sync protocol using the existing simulation framework:

  • 12 tests for Snapshot Merge Protection (Invariant I5)
  • 16 tests for CIP §2.3 Protocol Negotiation Compliance
  • Adds build_handshake() helper to SimNode for protocol negotiation testing

What's Tested

1. Snapshot Merge Protection (snapshot_merge_protection.rs)

Tests for Invariant I5: No Silent Data Loss - verifies:

  • Initialized nodes never receive Snapshot protocol
  • Protection holds across various entity counts and scenarios
  • Fresh nodes can still bootstrap via Snapshot

2. Protocol Negotiation Compliance (negotiation.rs)

Tests for CIP §2.3 protocol selection decision table:

  • All 7 rules (None, Snapshot, HashComparison, SubtreePrefetch, BloomFilter, LevelWise, default)
  • Rule priority ordering (R1 > R2 > R3 > ...)
  • Deterministic protocol selection (1000 iterations)
  • Symmetry and edge cases

Test Plan

  • cargo test --package calimero-node --test sync_sim sync_scenarios:: (12 tests pass)
  • cargo test --package calimero-node --test sync_sim sync_compliance:: (16 tests pass)
  • cargo fmt - no formatting issues
  • Pre-commit hooks pass

Related Issues

Relates to #1783 (Snapshot Merge Protection)
Relates to #1785 (Compliance Test Suite)

Introduces a deterministic simulation framework for testing the sync protocol
and adds comprehensive test coverage for:

1. Snapshot Merge Protection (Invariant I5)
   - Verifies initialized nodes never receive Snapshot protocol
   - Tests protection across various entity counts and scenarios
   - Confirms fresh nodes can still bootstrap via Snapshot

2. CIP §2.3 Protocol Negotiation Compliance
   - Tests all 7 rules of the protocol selection decision table
   - Verifies rule priority ordering
   - Confirms deterministic protocol selection

The simulation framework (`sync_sim`) provides:
- Deterministic RNG for reproducible tests
- SimNode with state tracking and handshake generation
- Scenario builders for common test setups
- Network simulation with partition and fault injection

Relates to #1783, #1785
@xilosada xilosada closed this Feb 11, 2026
Copy link

@meroreviewer meroreviewer bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 AI Code Reviewer

Reviewed by 3 agents | Quality score: 100% | Review time: 226.7s

🟡 1 warnings, 💡 1 suggestions. See inline comments.


🤖 Generated by AI Code Reviewer | Review ID: review-6f42797d

Scenario::force_hash_high_divergence(),
),
("force_subtree_prefetch", Scenario::force_subtree_prefetch()),
("force_bloom_filter", Scenario::force_bloom_filter()),
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Logic error: nodes created from separate scenario generations

Calls n_nodes_diverged(2) twice, taking node 0 from the first call and node 1 from the second call, resulting in nodes from different scenario generations rather than a single diverged pair as the comment suggests.

Suggested fix:

Store the result of a single `n_nodes_diverged(2)` call and extract both nodes from it: `let mut nodes = Scenario::n_nodes_diverged(2).into_iter(); let a = rt.add_existing_node(nodes.next().unwrap()); let b = rt.add_existing_node(nodes.next().unwrap());`


let hs_a = a.build_handshake();
let hs_b = b.build_handshake();

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 DRY: Divergence calculation duplicated

The divergence calculation logic (max_count/min_count/division) is repeated in test_cip23_rule3_high_divergence_hash_comparison and test_cip23_rule4_deep_tree_subtree_prefetch; consider extracting to a helper function.

Suggested fix:

Add `fn calculate_divergence(hs_a: &SyncHandshake, hs_b: &SyncHandshake) -> f64` helper and reuse across tests.

@xilosada xilosada changed the title test(sync): Simulation framework and I5/negotiation compliance tests test(sync): I5 protection and CIP §2.3 negotiation compliance tests Feb 11, 2026
Copy link
Contributor

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 3 potential issues.

Bugbot Autofix is ON. A Cloud Agent has been kicked off to fix the reported issues.

1.0 - (min_count / max_count)
} else {
0.0
};
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test divergence formula differs from actual implementation

Low Severity

The test precondition checks compute divergence as 1.0 - (min_count / max_count), a symmetric formula. However, the actual calculate_divergence() in protocol.rs uses abs_diff / remote.entity_count.max(1), an asymmetric formula. These formulas agree only when local ≤ remote (as happens to be the case now), but differ when local > remote. For example, with local=150, remote=100: the test formula gives ~33% while the real function gives 50%. The precondition assertions would not catch a scenario misconfiguration if entity counts are ever reordered.

Additional Locations (1)

Fix in Cursor Fix in Web

seed,
selection.protocol
);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Random seeds test uses fully deterministic identical inputs

Medium Severity

test_protection_holds_across_random_seeds claims to test across 100 random seeds, but Scenario::n_nodes_diverged(2) is completely deterministic and takes no seed parameter — every call returns identical nodes. The SimRuntime::new(seed) creates a seeded RNG that is never consumed since the test only builds pre-configured nodes and calls select_protocol. This loop effectively runs the exact same assertion 100 times, providing a false sense of coverage. Using RandomScenario::two_nodes_random(seed) or similar would actually vary the test data per seed.

Fix in Cursor Fix in Web

0
} else {
(64 - entity_count.leading_zeros()).min(32)
};
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Scenarios don't force named protocols via build_handshake

Medium Severity

build_handshake() estimates max_depth as 64 - entity_count.leading_zeros(), which yields ≥6 for any node with ≥36 entities. Since select_protocol Rule 4 fires when max_depth > 3, this means Scenario::force_bloom_filter() and Scenario::force_levelwise() both produce SubtreePrefetch instead of their namesake protocols when used with build_handshake(). The depth estimate overwhelms the intended shallow-tree structure these scenarios create. The compliance tests in negotiation.rs work around this by constructing handshakes directly with explicit depth values, but the named scenario helpers are broken for their stated purpose.

Additional Locations (2)

Fix in Cursor Fix in Web

@cursor
Copy link
Contributor

cursor bot commented Feb 11, 2026

Bugbot Autofix prepared fixes for 3 of the 3 bugs found in the latest run.

  • ✅ Fixed: Test divergence formula differs from actual implementation
    • Updated test precondition checks in negotiation.rs to use the same formula as calculate_divergence(): abs_diff / remote.entity_count instead of the symmetric 1.0 - min/max.
  • ✅ Fixed: Random seeds test uses fully deterministic identical inputs
    • Replaced deterministic Scenario::n_nodes_diverged(2) with RandomScenario::two_nodes_random(seed) to ensure each iteration tests with varied entity counts, data, and tree structures.
  • ✅ Fixed: Scenarios don't force named protocols via build_handshake
    • Added handshake_depth_override field to SimNode and updated force_bloom_filter() and force_levelwise() to set appropriate depth values (3 and 2) to avoid triggering Rule 4 (SubtreePrefetch).

View PR

Or push these changes by commenting:

@cursor push 6c9acb56cb
Preview (6c9acb56cb)
diff --git a/crates/node/tests/sync_compliance/negotiation.rs b/crates/node/tests/sync_compliance/negotiation.rs
--- a/crates/node/tests/sync_compliance/negotiation.rs
+++ b/crates/node/tests/sync_compliance/negotiation.rs
@@ -128,14 +128,12 @@
     // Preconditions
     assert!(hs_a.has_state);
     assert!(hs_b.has_state);
-    // Calculate divergence
-    let max_count = hs_a.entity_count.max(hs_b.entity_count) as f64;
-    let min_count = hs_a.entity_count.min(hs_b.entity_count) as f64;
-    let divergence = if max_count > 0.0 {
-        1.0 - (min_count / max_count)
-    } else {
-        0.0
-    };
+    // Calculate divergence using the same formula as calculate_divergence():
+    // abs_diff / remote.entity_count.max(1)
+    // Here hs_b is treated as remote for symmetry with the select_protocol call.
+    let diff = hs_a.entity_count.abs_diff(hs_b.entity_count) as f64;
+    let denominator = hs_b.entity_count.max(1) as f64;
+    let divergence = diff / denominator;
     assert!(
         divergence > 0.5,
         "Precondition: divergence > 50%, got {:.2}%",
@@ -189,14 +187,12 @@
         "Precondition: max_depth > 3, got {}",
         hs_b.max_depth
     );
-    // divergence < 20%
-    let max_count = hs_a.entity_count.max(hs_b.entity_count) as f64;
-    let min_count = hs_a.entity_count.min(hs_b.entity_count) as f64;
-    let divergence = if max_count > 0.0 {
-        1.0 - (min_count / max_count)
-    } else {
-        0.0
-    };
+    // divergence < 20% using the same formula as calculate_divergence():
+    // abs_diff / remote.entity_count.max(1)
+    // Here hs_b is treated as remote for symmetry with the select_protocol call.
+    let diff = hs_a.entity_count.abs_diff(hs_b.entity_count) as f64;
+    let denominator = hs_b.entity_count.max(1) as f64;
+    let divergence = diff / denominator;
     assert!(
         divergence < 0.2,
         "Precondition: divergence < 20%, got {:.2}%",
@@ -429,9 +425,10 @@
             SyncProtocol::HashComparison { .. }
         ) {
             // Only count as R3 if divergence > 50%
-            let max = hs_a.entity_count.max(hs_b.entity_count) as f64;
-            let min = hs_a.entity_count.min(hs_b.entity_count) as f64;
-            let div = 1.0 - (min / max);
+            // Use the same formula as calculate_divergence(): abs_diff / remote.max(1)
+            let diff = hs_a.entity_count.abs_diff(hs_b.entity_count) as f64;
+            let denominator = hs_b.entity_count.max(1) as f64;
+            let div = diff / denominator;
             if div > 0.5 {
                 rules_hit[2] = true;
             }

diff --git a/crates/node/tests/sync_scenarios/snapshot_merge_protection.rs b/crates/node/tests/sync_scenarios/snapshot_merge_protection.rs
--- a/crates/node/tests/sync_scenarios/snapshot_merge_protection.rs
+++ b/crates/node/tests/sync_scenarios/snapshot_merge_protection.rs
@@ -250,21 +250,28 @@
 }
 
 /// Protection holds across 100 random seeds.
+///
+/// Uses `RandomScenario::two_nodes_random(seed)` to generate varied node
+/// configurations for each seed, ensuring actual coverage of different
+/// entity counts, tree depths, and divergence levels.
 #[test]
 fn test_protection_holds_across_random_seeds() {
     for seed in 0..100 {
         let mut rt = SimRuntime::new(seed);
 
-        // Create two diverged nodes (both have state)
-        let nodes = Scenario::n_nodes_diverged(2);
-        let a = rt.add_existing_node(nodes.into_iter().next().unwrap());
-        let b_node = Scenario::n_nodes_diverged(2).into_iter().nth(1).unwrap();
+        // Create two diverged nodes using seed-based random generation.
+        // Each seed produces different entity counts, data, and tree structure.
+        let mut nodes = RandomScenario::two_nodes_random(seed);
+        let b_node = nodes.pop().unwrap();
+        let a_node = nodes.pop().unwrap();
+
+        let a = rt.add_existing_node(a_node);
         let b = rt.add_existing_node(b_node);
 
         let hs_a = rt.node_mut(&a).unwrap().build_handshake();
         let hs_b = rt.node_mut(&b).unwrap().build_handshake();
 
-        // Both should have state
+        // Both should have state (RandomScenario default has fresh_node_probability=0.0)
         if !hs_a.has_state || !hs_b.has_state {
             continue;
         }

diff --git a/crates/node/tests/sync_sim/node/state.rs b/crates/node/tests/sync_sim/node/state.rs
--- a/crates/node/tests/sync_sim/node/state.rs
+++ b/crates/node/tests/sync_sim/node/state.rs
@@ -100,6 +100,9 @@
     pub has_state: bool,
     /// Whether node is currently crashed (offline).
     pub is_crashed: bool,
+    /// Optional override for max_depth in handshake.
+    /// Used by scenarios that need to control the depth independently of entity count.
+    pub handshake_depth_override: Option<u32>,
 }
 
 impl SimNode {
@@ -120,6 +123,7 @@
             sender_sessions: HashMap::new(),
             has_state: false,
             is_crashed: false,
+            handshake_depth_override: None,
         }
     }
 
@@ -325,21 +329,35 @@
     /// Build a SyncHandshake for this node.
     ///
     /// Used for protocol negotiation testing.
+    ///
+    /// If `handshake_depth_override` is set, uses that value for max_depth.
+    /// Otherwise estimates max_depth from entity count (log2 approximation).
     pub fn build_handshake(&mut self) -> SyncHandshake {
         let root_hash = self.root_hash();
         let entity_count = self.entity_count() as u64;
 
-        // Estimate max_depth from entity count (log2-ish for balanced tree)
-        let max_depth = if entity_count == 0 {
-            0
-        } else {
-            (64 - entity_count.leading_zeros()).min(32)
-        };
+        // Use override if set, otherwise estimate max_depth from entity count
+        let max_depth = self.handshake_depth_override.unwrap_or_else(|| {
+            if entity_count == 0 {
+                0
+            } else {
+                (64 - entity_count.leading_zeros()).min(32)
+            }
+        });
 
         let dag_heads: Vec<[u8; 32]> = self.dag_heads.iter().map(|d| d.0).collect();
 
         SyncHandshake::new(root_hash, entity_count, max_depth, dag_heads)
     }
+
+    /// Set the max_depth override for handshake generation.
+    ///
+    /// This allows scenarios to control the reported tree depth independently
+    /// of the actual entity count, which is necessary for testing specific
+    /// protocol selection rules.
+    pub fn set_handshake_depth(&mut self, depth: u32) {
+        self.handshake_depth_override = Some(depth);
+    }
 }
 
 #[cfg(test)]

diff --git a/crates/node/tests/sync_sim/scenarios/deterministic.rs b/crates/node/tests/sync_sim/scenarios/deterministic.rs
--- a/crates/node/tests/sync_sim/scenarios/deterministic.rs
+++ b/crates/node/tests/sync_sim/scenarios/deterministic.rs
@@ -179,6 +179,9 @@
     /// Rule 5: Large tree + small diff → BloomFilter.
     ///
     /// Creates nodes with mostly shared state and small difference.
+    ///
+    /// Requirements for Rule 5: `entity_count > 50 AND divergence < 10% AND NOT (max_depth > 3)`
+    /// Since Rule 4 (SubtreePrefetch) fires when `max_depth > 3`, we must set depth ≤ 3.
     pub fn force_bloom_filter() -> (SimNode, SimNode) {
         let mut a = SimNode::new("a");
         let mut b = SimNode::new("b");
@@ -195,6 +198,10 @@
             b.insert_entity_with_metadata(id, data, metadata);
         }
 
+        // Override depth to 3 to avoid triggering Rule 4 (SubtreePrefetch requires depth > 3)
+        a.set_handshake_depth(3);
+        b.set_handshake_depth(3);
+
         assert!(b.entity_count() > 50);
         (a, b)
     }
@@ -202,6 +209,9 @@
     /// Rule 6: Wide shallow tree → LevelWise.
     ///
     /// Creates shallow tree structures.
+    ///
+    /// Requirements for Rule 6: `max_depth 1-2 AND avg_children/level > 10`
+    /// We must override depth to 2 to ensure Rule 4 (SubtreePrefetch) doesn't fire first.
     pub fn force_levelwise() -> (SimNode, SimNode) {
         let mut a = SimNode::new("a");
         let mut b = SimNode::new("b");
@@ -214,6 +224,11 @@
             b.insert_entity_with_metadata(id, data, metadata);
         }
 
+        // Override depth to 2 to avoid triggering Rule 4 (SubtreePrefetch requires depth > 3)
+        // and to match Rule 6 requirements (max_depth 1-2)
+        a.set_handshake_depth(2);
+        b.set_handshake_depth(2);
+
         (a, b)
     }

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant