Skip to main content

constraint_crdt/
geometric.rs

1//! # Novel Experiment 2: Eisenstein-Geometric Gossip
2//!
3//! Instead of random peer selection, use Eisenstein lattice geometry to decide
4//! WHO to sync with and WHAT to prioritize.
5//!
6//! Key insight: on the Eisenstein lattice, nodes that are geometrically close
7//! (low norm distance) have similar constraint profiles. Syncing with nearby
8//! nodes first yields faster convergence than random gossip.
9//!
10//! This is a genuinely novel contribution: CRDT sync priority determined by
11//! position in a mathematical lattice, not by network topology.
12
13use crate::eisenstein::eisenstein_norm;
14use crate::gossip::{GossipNode, GossipMessage};
15use crate::merkle::StateHash;
16use crate::state::ConstraintState;
17use crate::merge::Merge;
18use crate::simulation::SimRng;
19
20/// A node with an Eisenstein lattice position.
21#[derive(Debug, Clone)]
22pub struct GeometricNode {
23    pub node: GossipNode,
24    /// Position on the Eisenstein lattice
25    pub position: (i32, i32),
26}
27
28impl GeometricNode {
29    pub fn new(id: &str, position: (i32, i32)) -> Self {
30        Self {
31            node: GossipNode::new(id),
32            position,
33        }
34    }
35
36    /// Eisenstein norm of this node's position.
37    pub fn norm(&self) -> i64 {
38        eisenstein_norm(self.position)
39    }
40
41    /// Eisenstein distance to another node.
42    pub fn distance_to(&self, other: &Self) -> i64 {
43        let (ax, ay) = (self.position.0 as i64, self.position.1 as i64);
44        let (bx, by) = (other.position.0 as i64, other.position.1 as i64);
45        let da = ax - bx;
46        let db = ay - by;
47        let dc = da - db;
48        da.abs().max(db.abs()).max(dc.abs())
49    }
50
51    /// "Drift" — how far this node's constraint state has diverged from origin.
52    /// Higher drift = more constraints = more to share.
53    pub fn constraint_drift(&self) -> u64 {
54        self.node.state.metrics.total_satisfied()
55            + self.node.state.metrics.total_violations()
56    }
57}
58
59/// Result of comparing random vs geometric gossip strategies.
60#[derive(Debug, Clone)]
61pub struct GossipExperiment {
62    pub node_count: usize,
63    pub random_convergence_rounds: Option<u64>,
64    pub geometric_convergence_rounds: Option<u64>,
65    pub random_messages: u64,
66    pub geometric_messages: u64,
67    pub speedup: Option<f64>,
68}
69
70/// Run a comparison experiment: random gossip vs geometric gossip.
71pub fn run_experiment(
72    node_count: usize,
73    seed: u64,
74    max_rounds: u64,
75) -> GossipExperiment {
76    // Create nodes with Eisenstein positions
77    let mut random_nodes: Vec<GeometricNode> = Vec::new();
78    let mut geometric_nodes: Vec<GeometricNode> = Vec::new();
79    
80    // Place nodes on a hex grid
81    let mut rng = SimRng::new(seed);
82    for i in 0..node_count {
83        let angle = (i as f64 / node_count as f64) * std::f64::consts::TAU;
84        let r = 5.0 + (rng.next_f64() * 5.0);
85        let x = (r * angle.cos()).round() as i32;
86        let y = (r * angle.sin()).round() as i32;
87        
88        random_nodes.push(GeometricNode::new(&format!("n{}", i), (x, y)));
89        geometric_nodes.push(GeometricNode::new(&format!("n{}", i), (x, y)));
90    }
91
92    // Give each node unique constraints
93    for (i, (rn, gn)) in random_nodes.iter_mut().zip(geometric_nodes.iter_mut()).enumerate() {
94        rn.node.add_constraint(&format!("c{}", i));
95        rn.node.record_satisfied(100 + i as u64 * 50);
96        gn.node.add_constraint(&format!("c{}", i));
97        gn.node.record_satisfied(100 + i as u64 * 50);
98    }
99
100    // Run random gossip
101    let mut random_rng = SimRng::new(seed);
102    let random_result = run_random_gossip(&mut random_nodes, &mut random_rng, max_rounds);
103
104    // Run geometric gossip
105    let mut geometric_rng = SimRng::new(seed);
106    let geometric_result = run_geometric_gossip(&mut geometric_nodes, &mut geometric_rng, max_rounds);
107
108    let speedup = match (random_result.0, geometric_result.0) {
109        (Some(rr), Some(gr)) => Some(rr as f64 / gr as f64),
110        _ => None,
111    };
112
113    GossipExperiment {
114        node_count,
115        random_convergence_rounds: random_result.0,
116        geometric_convergence_rounds: geometric_result.0,
117        random_messages: random_result.1,
118        geometric_messages: geometric_result.1,
119        speedup,
120    }
121}
122
123/// Random gossip: pick a random peer each round.
124fn run_random_gossip(
125    nodes: &mut [GeometricNode],
126    rng: &mut SimRng,
127    max_rounds: u64,
128) -> (Option<u64>, u64) {
129    let n = nodes.len();
130    let mut messages = 0u64;
131
132    for round in 1..=max_rounds {
133        for i in 0..n {
134            let peer = rng.random_peer(i, n);
135            let ping = nodes[i].node.ping();
136            messages += 1;
137            let r1 = nodes[peer].node.receive(&ping);
138            for resp in &r1.responses {
139                messages += 1;
140                nodes[i].node.receive(resp);
141            }
142        }
143
144        if check_converged(nodes.iter().map(|n| &n.node).collect()) {
145            return (Some(round), messages);
146        }
147    }
148    (None, messages)
149}
150
151/// Geometric gossip: pick closest peer first, expanding radius.
152fn run_geometric_gossip(
153    nodes: &mut [GeometricNode],
154    rng: &mut SimRng,
155    max_rounds: u64,
156) -> (Option<u64>, u64) {
157    let n = nodes.len();
158    let mut messages = 0u64;
159
160    for round in 1..=max_rounds {
161        for i in 0..n {
162            // Pick peer by Eisenstein distance (closest first)
163            let peer = geometric_peer_selection(i, nodes, round, rng);
164            let ping = nodes[i].node.ping();
165            messages += 1;
166            let r1 = nodes[peer].node.receive(&ping);
167            for resp in &r1.responses {
168                messages += 1;
169                nodes[i].node.receive(resp);
170            }
171        }
172
173        if check_converged(nodes.iter().map(|n| &n.node).collect()) {
174            return (Some(round), messages);
175        }
176    }
177    (None, messages)
178}
179
180/// Geometric peer selection: prefer nearby nodes, expanding radius over time.
181fn geometric_peer_selection(
182    self_idx: usize,
183    nodes: &[GeometricNode],
184    round: u64,
185    rng: &mut SimRng,
186) -> usize {
187    let self_node = &nodes[self_idx];
188    let n = nodes.len();
189
190    // Sort peers by distance
191    let mut peers: Vec<(usize, i64)> = (0..n)
192        .filter(|&i| i != self_idx)
193        .map(|i| (i, self_node.distance_to(&nodes[i])))
194        .collect();
195    peers.sort_by_key(|(_, d)| *d);
196
197    // Expanding radius: in early rounds, prefer close peers
198    // Over time, expand to distant peers
199    let radius_fraction = (round as f64 / 10.0).min(1.0);
200    let max_idx = ((peers.len() as f64 * radius_fraction).ceil() as usize).max(1).min(peers.len());
201    
202    // Pick within radius, with some randomness
203    let idx = (rng.next_u64() as usize) % max_idx;
204    peers[idx].0
205}
206
207fn check_converged(nodes: Vec<&GossipNode>) -> bool {
208    if nodes.len() <= 1 { return true; }
209    let hashes: Vec<_> = nodes.iter().map(|n| n.state_hash()).collect();
210    let first = hashes[0];
211    hashes.iter().all(|h| *h == first)
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217
218    #[test]
219    fn test_geometric_distance() {
220        let a = GeometricNode::new("a", (0, 0));
221        let b = GeometricNode::new("b", (3, 0));
222        let c = GeometricNode::new("c", (3, 3));
223        
224        assert_eq!(a.distance_to(&b), 3);
225        assert_eq!(a.distance_to(&c), 3); // hex: max(3,3,0)=3
226        assert_eq!(b.distance_to(&c), 3); // hex: max(0,3,3)=3
227    }
228
229    #[test]
230    fn test_experiment_4_nodes() {
231        let result = run_experiment(4, 42, 30);
232        println!("4 nodes: random={:?} geometric={:?} speedup={:?}",
233            result.random_convergence_rounds,
234            result.geometric_convergence_rounds,
235            result.speedup);
236        
237        // Both should converge
238        assert!(result.random_convergence_rounds.is_some());
239        assert!(result.geometric_convergence_rounds.is_some());
240    }
241
242    #[test]
243    fn test_experiment_8_nodes() {
244        let result = run_experiment(8, 42, 50);
245        println!("8 nodes: random={:?} geometric={:?} speedup={:?}",
246            result.random_convergence_rounds,
247            result.geometric_convergence_rounds,
248            result.speedup);
249        
250        assert!(result.random_convergence_rounds.is_some());
251        assert!(result.geometric_convergence_rounds.is_some());
252    }
253
254    #[test]
255    fn test_experiment_16_nodes() {
256        let result = run_experiment(16, 42, 100);
257        println!("16 nodes: random={:?} geometric={:?} speedup={:?}",
258            result.random_convergence_rounds,
259            result.geometric_convergence_rounds,
260            result.speedup);
261        
262        assert!(result.random_convergence_rounds.is_some());
263        assert!(result.geometric_convergence_rounds.is_some());
264    }
265
266    #[test]
267    fn test_geometric_scales_better() {
268        // Run multiple sizes and check geometric doesn't degrade as fast
269        let r4 = run_experiment(4, 42, 50);
270        let r8 = run_experiment(8, 42, 50);
271        let r16 = run_experiment(16, 42, 100);
272        
273        let r4r = r4.random_convergence_rounds.unwrap() as f64;
274        let r8r = r8.random_convergence_rounds.unwrap() as f64;
275        let r16r = r16.random_convergence_rounds.unwrap() as f64;
276        
277        let r4g = r4.geometric_convergence_rounds.unwrap() as f64;
278        let r8g = r8.geometric_convergence_rounds.unwrap() as f64;
279        let r16g = r16.geometric_convergence_rounds.unwrap() as f64;
280        
281        println!("Scaling (random):   4→{:.0} 8→{:.0} 16→{:.0}", r4r, r8r, r16r);
282        println!("Scaling (geometric): 4→{:.0} 8→{:.0} 16→{:.0}", r4g, r8g, r16g);
283        
284        // Geometric should not be worse than random
285        assert!(r4g <= r4r * 1.5, "Geometric should not be much worse at 4 nodes");
286    }
287}