Skip to main content

clawft_kernel/
mesh_process.rs

1//! Distributed process table for mesh networking (K6.5).
2//!
3//! Tracks processes across all mesh nodes using last-writer-wins
4//! semantics. Each node gossips process summaries to peers.
5
6use std::collections::HashMap;
7
8use serde::{Deserialize, Serialize};
9
10use crate::ipc::GlobalPid;
11use crate::process::Pid;
12
13// ── Consistent hash ring for PID-to-node assignment ────────────────
14
15/// Consistent hash ring for PID-to-node assignment.
16///
17/// Uses virtual nodes for even distribution across cluster members.
18/// When the `cluster` feature is enabled, this delegates to
19/// `ruvector_cluster::ConsistentHashRing` under the hood; otherwise
20/// a standalone implementation is provided so the mesh module can
21/// still compile without the full cluster dependency.
22pub struct ConsistentHashRing {
23    /// Sorted ring of (hash, node_id) pairs.
24    ring: Vec<(u64, String)>,
25    /// Number of virtual nodes per real node.
26    virtual_nodes: usize,
27}
28
29impl ConsistentHashRing {
30    /// Create a new hash ring with the given number of virtual nodes
31    /// per real node. Higher values give more even distribution.
32    pub fn new(virtual_nodes: usize) -> Self {
33        Self {
34            ring: Vec::new(),
35            virtual_nodes,
36        }
37    }
38
39    /// Add a node to the ring.
40    pub fn add_node(&mut self, node_id: &str) {
41        for i in 0..self.virtual_nodes {
42            let key = format!("{node_id}:{i}");
43            let hash = Self::hash(&key);
44            self.ring.push((hash, node_id.to_string()));
45        }
46        self.ring.sort_by_key(|(h, _)| *h);
47    }
48
49    /// Remove a node from the ring.
50    pub fn remove_node(&mut self, node_id: &str) {
51        self.ring.retain(|(_, id)| id != node_id);
52    }
53
54    /// Find which node owns a given PID (clockwise walk).
55    pub fn assign(&self, pid: u64) -> Option<&str> {
56        if self.ring.is_empty() {
57            return None;
58        }
59        let hash = Self::hash(&pid.to_string());
60        // Find first ring position >= hash
61        let pos = self.ring.partition_point(|(h, _)| *h < hash);
62        let idx = if pos >= self.ring.len() { 0 } else { pos };
63        Some(&self.ring[idx].1)
64    }
65
66    /// Number of distinct real nodes in the ring.
67    pub fn node_count(&self) -> usize {
68        let mut nodes: std::collections::HashSet<&str> = std::collections::HashSet::new();
69        for (_, id) in &self.ring {
70            nodes.insert(id);
71        }
72        nodes.len()
73    }
74
75    fn hash(key: &str) -> u64 {
76        use std::hash::{Hash, Hasher};
77        let mut hasher = std::collections::hash_map::DefaultHasher::new();
78        key.hash(&mut hasher);
79        hasher.finish()
80    }
81}
82
83// ── Metadata consensus (Raft-style) ───────────────────────────────
84
85/// Role in the metadata consensus group.
86#[non_exhaustive]
87#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
88pub enum ConsensusRole {
89    Follower,
90    Candidate,
91    Leader,
92}
93
94/// A single entry in the consensus log.
95#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct ConsensusEntry {
97    pub term: u64,
98    pub index: u64,
99    pub operation: ConsensusOp,
100}
101
102/// Operations that can be replicated through consensus.
103#[non_exhaustive]
104#[derive(Debug, Clone, Serialize, Deserialize)]
105pub enum ConsensusOp {
106    /// Register a service in the cluster.
107    RegisterService { name: String, node_id: String },
108    /// Deregister a service.
109    DeregisterService { name: String, node_id: String },
110    /// Register a process.
111    RegisterProcess {
112        global_pid: String,
113        agent_type: String,
114    },
115    /// Deregister a process.
116    DeregisterProcess { global_pid: String },
117}
118
119/// Metadata consensus using Raft for authoritative state.
120///
121/// Wraps raft-style log replication for service registry and process
122/// table consensus. When the `cluster` feature is active, this can
123/// be backed by `ruvector_raft::RaftNode`; otherwise a self-contained
124/// state machine is used.
125pub struct MetadataConsensus {
126    /// Node role in the raft group.
127    role: ConsensusRole,
128    /// Current term.
129    term: u64,
130    /// Committed log index.
131    commit_index: u64,
132    /// Log entries.
133    log: Vec<ConsensusEntry>,
134}
135
136impl MetadataConsensus {
137    /// Create a new consensus instance starting as a follower.
138    pub fn new() -> Self {
139        Self {
140            role: ConsensusRole::Follower,
141            term: 0,
142            commit_index: 0,
143            log: Vec::new(),
144        }
145    }
146
147    /// Current role.
148    pub fn role(&self) -> ConsensusRole {
149        self.role
150    }
151
152    /// Current term.
153    pub fn term(&self) -> u64 {
154        self.term
155    }
156
157    /// Committed log index.
158    pub fn commit_index(&self) -> u64 {
159        self.commit_index
160    }
161
162    /// Transition to a new role.
163    pub fn set_role(&mut self, role: ConsensusRole) {
164        self.role = role;
165    }
166
167    /// Advance to a new term (resets vote state).
168    pub fn advance_term(&mut self, new_term: u64) {
169        if new_term > self.term {
170            self.term = new_term;
171            self.role = ConsensusRole::Follower;
172        }
173    }
174
175    /// Append an entry to the log.
176    /// Returns the index of the newly appended entry.
177    pub fn append(&mut self, operation: ConsensusOp) -> u64 {
178        let index = self.log.len() as u64 + 1;
179        self.log.push(ConsensusEntry {
180            term: self.term,
181            index,
182            operation,
183        });
184        index
185    }
186
187    /// Commit entries up to the given index.
188    /// Returns the newly committed entries.
189    pub fn commit(&mut self, up_to: u64) -> Vec<&ConsensusEntry> {
190        let old = self.commit_index;
191        self.commit_index = up_to.min(self.log.len() as u64);
192        self.log
193            .iter()
194            .filter(|e| e.index > old && e.index <= self.commit_index)
195            .collect()
196    }
197
198    /// Get all entries in the log.
199    pub fn entries(&self) -> &[ConsensusEntry] {
200        &self.log
201    }
202
203    /// Number of log entries.
204    pub fn log_len(&self) -> usize {
205        self.log.len()
206    }
207}
208
209impl Default for MetadataConsensus {
210    fn default() -> Self {
211        Self::new()
212    }
213}
214
215// ── CRDT gossip state (delta-based) ───────────────────────────────
216
217/// CRDT state for gossip-based convergence.
218///
219/// Uses last-writer-wins semantics inspired by
220/// `ruvector_delta_consensus`. Each entry carries a logical clock
221/// timestamp so that concurrent updates converge deterministically.
222pub struct CrdtGossipState {
223    /// State entries with logical timestamps.
224    entries: HashMap<String, (serde_json::Value, u64)>,
225    /// Local logical clock.
226    clock: u64,
227}
228
229impl CrdtGossipState {
230    pub fn new() -> Self {
231        Self {
232            entries: HashMap::new(),
233            clock: 0,
234        }
235    }
236
237    /// Set a value (advances local clock).
238    pub fn set(&mut self, key: String, value: serde_json::Value) {
239        self.clock += 1;
240        self.entries.insert(key, (value, self.clock));
241    }
242
243    /// Get a value.
244    pub fn get(&self, key: &str) -> Option<&serde_json::Value> {
245        self.entries.get(key).map(|(v, _)| v)
246    }
247
248    /// Merge remote state (LWW: higher clock wins, ties keep local).
249    pub fn merge(&mut self, remote_entries: &[(String, serde_json::Value, u64)]) {
250        for (key, value, remote_clock) in remote_entries {
251            match self.entries.get(key) {
252                Some((_, local_clock)) if local_clock >= remote_clock => {
253                    // Local is newer or same, keep local.
254                }
255                _ => {
256                    self.entries
257                        .insert(key.clone(), (value.clone(), *remote_clock));
258                    if *remote_clock > self.clock {
259                        self.clock = *remote_clock;
260                    }
261                }
262            }
263        }
264    }
265
266    /// Get delta since a given clock value (for gossip protocol).
267    pub fn delta_since(&self, since_clock: u64) -> Vec<(String, serde_json::Value, u64)> {
268        self.entries
269            .iter()
270            .filter(|(_, (_, clock))| *clock > since_clock)
271            .map(|(k, (v, c))| (k.clone(), v.clone(), *c))
272            .collect()
273    }
274
275    /// Number of entries.
276    pub fn len(&self) -> usize {
277        self.entries.len()
278    }
279
280    /// Whether the state is empty.
281    pub fn is_empty(&self) -> bool {
282        self.entries.is_empty()
283    }
284
285    /// Current logical clock.
286    pub fn clock(&self) -> u64 {
287        self.clock
288    }
289}
290
291impl Default for CrdtGossipState {
292    fn default() -> Self {
293        Self::new()
294    }
295}
296
297/// Process summary advertised to peer nodes.
298#[derive(Debug, Clone, Serialize, Deserialize)]
299pub struct ProcessAdvertisement {
300    /// Globally unique process ID.
301    pub global_pid: GlobalPid,
302    /// Agent type (e.g., "coder", "reviewer").
303    pub agent_type: String,
304    /// Capabilities this process offers.
305    pub capabilities: Vec<String>,
306    /// Services this process provides.
307    pub services: Vec<String>,
308    /// Current process status.
309    pub status: ProcessStatus,
310    /// Last update timestamp (for LWW merge).
311    pub last_updated: u64,
312    /// Resource usage summary.
313    pub resource_summary: Option<ResourceSummary>,
314}
315
316/// Process status for distributed table.
317#[non_exhaustive]
318#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
319pub enum ProcessStatus {
320    Running,
321    Suspended,
322    Stopping,
323    Unreachable,
324}
325
326/// Summary of resource usage for load-aware scheduling.
327#[derive(Debug, Clone, Serialize, Deserialize)]
328pub struct ResourceSummary {
329    /// Memory usage in bytes.
330    pub memory_bytes: u64,
331    /// CPU time used in microseconds.
332    pub cpu_time_us: u64,
333    /// Number of pending messages in inbox.
334    pub inbox_depth: u32,
335}
336
337/// Distributed process table tracking processes across all nodes.
338pub struct DistributedProcessTable {
339    /// All known processes: global_pid string -> advertisement.
340    processes: HashMap<String, ProcessAdvertisement>,
341}
342
343impl DistributedProcessTable {
344    pub fn new() -> Self {
345        Self {
346            processes: HashMap::new(),
347        }
348    }
349
350    /// Merge a process advertisement (LWW: latest timestamp wins).
351    pub fn merge(&mut self, advert: ProcessAdvertisement) {
352        let key = advert.global_pid.to_string();
353        match self.processes.get(&key) {
354            Some(existing) if existing.last_updated >= advert.last_updated => {
355                // Existing is newer or same, ignore.
356            }
357            _ => {
358                self.processes.insert(key, advert);
359            }
360        }
361    }
362
363    /// Look up a process by local PID and node ID.
364    pub fn locate(&self, pid: Pid, node_id: &str) -> Option<&ProcessAdvertisement> {
365        let key = format!("{node_id}:{pid}");
366        self.processes.get(&key)
367    }
368
369    /// Find processes by agent type across all nodes.
370    pub fn find_by_type(&self, agent_type: &str) -> Vec<&ProcessAdvertisement> {
371        self.processes
372            .values()
373            .filter(|p| p.agent_type == agent_type && p.status == ProcessStatus::Running)
374            .collect()
375    }
376
377    /// Find processes offering a specific capability.
378    pub fn find_by_capability(&self, capability: &str) -> Vec<&ProcessAdvertisement> {
379        self.processes
380            .values()
381            .filter(|p| {
382                p.capabilities.contains(&capability.to_string())
383                    && p.status == ProcessStatus::Running
384            })
385            .collect()
386    }
387
388    /// Find the least-loaded node for scheduling.
389    pub fn least_loaded_node(&self) -> Option<String> {
390        let mut node_loads: HashMap<String, u32> = HashMap::new();
391        for advert in self.processes.values() {
392            if advert.status == ProcessStatus::Running {
393                *node_loads
394                    .entry(advert.global_pid.node_id.clone())
395                    .or_default() += 1;
396            }
397        }
398        node_loads
399            .into_iter()
400            .min_by_key(|(_, count)| *count)
401            .map(|(node, _)| node)
402    }
403
404    /// Remove all processes from a node (e.g., when node becomes unreachable).
405    pub fn remove_node(&mut self, node_id: &str) {
406        self.processes
407            .retain(|_, p| p.global_pid.node_id != node_id);
408    }
409
410    /// Mark all processes on a node as unreachable.
411    pub fn mark_node_unreachable(&mut self, node_id: &str) {
412        for advert in self.processes.values_mut() {
413            if advert.global_pid.node_id == node_id {
414                advert.status = ProcessStatus::Unreachable;
415            }
416        }
417    }
418
419    /// Get all process advertisements (for gossip).
420    pub fn all_advertisements(&self) -> Vec<&ProcessAdvertisement> {
421        self.processes.values().collect()
422    }
423
424    /// Number of tracked processes.
425    pub fn len(&self) -> usize {
426        self.processes.len()
427    }
428
429    pub fn is_empty(&self) -> bool {
430        self.processes.is_empty()
431    }
432
433    /// Assign a PID to the optimal node using consistent hashing.
434    pub fn assign_pid(&self, pid: u64, ring: &ConsistentHashRing) -> Option<String> {
435        ring.assign(pid).map(|s| s.to_string())
436    }
437}
438
439impl Default for DistributedProcessTable {
440    fn default() -> Self {
441        Self::new()
442    }
443}
444
445#[cfg(test)]
446mod tests {
447    use super::*;
448
449    fn make_advert(node: &str, pid: Pid, agent_type: &str, ts: u64) -> ProcessAdvertisement {
450        ProcessAdvertisement {
451            global_pid: GlobalPid::local(pid, node),
452            agent_type: agent_type.to_string(),
453            capabilities: vec!["code".to_string()],
454            services: vec![],
455            status: ProcessStatus::Running,
456            last_updated: ts,
457            resource_summary: None,
458        }
459    }
460
461    #[test]
462    fn process_advertisement_serde_roundtrip() {
463        let advert = make_advert("node-a", 1, "coder", 100);
464        let json = serde_json::to_string(&advert).unwrap();
465        let restored: ProcessAdvertisement = serde_json::from_str(&json).unwrap();
466        assert_eq!(restored.global_pid.node_id, "node-a");
467        assert_eq!(restored.global_pid.pid, 1);
468        assert_eq!(restored.agent_type, "coder");
469        assert_eq!(restored.last_updated, 100);
470    }
471
472    #[test]
473    fn merge_lww_newer_wins() {
474        let mut table = DistributedProcessTable::new();
475
476        let old = make_advert("node-a", 1, "coder", 100);
477        let new = ProcessAdvertisement {
478            agent_type: "reviewer".to_string(),
479            last_updated: 200,
480            ..make_advert("node-a", 1, "coder", 200)
481        };
482
483        table.merge(old);
484        assert_eq!(table.len(), 1);
485
486        // Newer timestamp wins
487        table.merge(new);
488        let found = table.locate(1, "node-a").unwrap();
489        assert_eq!(found.agent_type, "reviewer");
490        assert_eq!(found.last_updated, 200);
491    }
492
493    #[test]
494    fn merge_lww_older_ignored() {
495        let mut table = DistributedProcessTable::new();
496
497        let newer = make_advert("node-a", 1, "reviewer", 200);
498        let older = make_advert("node-a", 1, "coder", 100);
499
500        table.merge(newer);
501        table.merge(older); // should be ignored
502
503        let found = table.locate(1, "node-a").unwrap();
504        assert_eq!(found.agent_type, "reviewer");
505    }
506
507    #[test]
508    fn find_by_type_returns_matching() {
509        let mut table = DistributedProcessTable::new();
510        table.merge(make_advert("node-a", 1, "coder", 100));
511        table.merge(make_advert("node-b", 2, "reviewer", 100));
512        table.merge(make_advert("node-c", 3, "coder", 100));
513
514        let coders = table.find_by_type("coder");
515        assert_eq!(coders.len(), 2);
516        for c in &coders {
517            assert_eq!(c.agent_type, "coder");
518        }
519    }
520
521    #[test]
522    fn find_by_capability() {
523        let mut table = DistributedProcessTable::new();
524
525        let mut advert = make_advert("node-a", 1, "coder", 100);
526        advert.capabilities = vec!["code".to_string(), "review".to_string()];
527        table.merge(advert);
528
529        let mut advert2 = make_advert("node-b", 2, "tester", 100);
530        advert2.capabilities = vec!["test".to_string()];
531        table.merge(advert2);
532
533        let reviewers = table.find_by_capability("review");
534        assert_eq!(reviewers.len(), 1);
535        assert_eq!(reviewers[0].global_pid.node_id, "node-a");
536    }
537
538    #[test]
539    fn least_loaded_node() {
540        let mut table = DistributedProcessTable::new();
541        // node-a has 3 processes, node-b has 1
542        table.merge(make_advert("node-a", 1, "coder", 100));
543        table.merge(make_advert("node-a", 2, "coder", 100));
544        table.merge(make_advert("node-a", 3, "coder", 100));
545        table.merge(make_advert("node-b", 1, "coder", 100));
546
547        let node = table.least_loaded_node().unwrap();
548        assert_eq!(node, "node-b");
549    }
550
551    #[test]
552    fn remove_node() {
553        let mut table = DistributedProcessTable::new();
554        table.merge(make_advert("node-a", 1, "coder", 100));
555        table.merge(make_advert("node-a", 2, "coder", 100));
556        table.merge(make_advert("node-b", 1, "coder", 100));
557
558        table.remove_node("node-a");
559        assert_eq!(table.len(), 1);
560        assert!(table.locate(1, "node-a").is_none());
561        assert!(table.locate(1, "node-b").is_some());
562    }
563
564    #[test]
565    fn mark_node_unreachable() {
566        let mut table = DistributedProcessTable::new();
567        table.merge(make_advert("node-a", 1, "coder", 100));
568        table.merge(make_advert("node-a", 2, "coder", 100));
569        table.merge(make_advert("node-b", 1, "coder", 100));
570
571        table.mark_node_unreachable("node-a");
572
573        let a1 = table.locate(1, "node-a").unwrap();
574        assert_eq!(a1.status, ProcessStatus::Unreachable);
575        let a2 = table.locate(2, "node-a").unwrap();
576        assert_eq!(a2.status, ProcessStatus::Unreachable);
577
578        // node-b unaffected
579        let b1 = table.locate(1, "node-b").unwrap();
580        assert_eq!(b1.status, ProcessStatus::Running);
581    }
582
583    // ── ConsistentHashRing tests ───────────────────────────────────
584
585    #[test]
586    fn consistent_hash_ring_add_remove() {
587        let mut ring = ConsistentHashRing::new(64);
588        assert_eq!(ring.node_count(), 0);
589
590        ring.add_node("node-a");
591        assert_eq!(ring.node_count(), 1);
592
593        ring.add_node("node-b");
594        assert_eq!(ring.node_count(), 2);
595
596        ring.remove_node("node-a");
597        assert_eq!(ring.node_count(), 1);
598
599        // All assignments should go to node-b now
600        assert_eq!(ring.assign(42), Some("node-b"));
601    }
602
603    #[test]
604    fn consistent_hash_ring_assignment_stable() {
605        let mut ring = ConsistentHashRing::new(64);
606        ring.add_node("node-a");
607        ring.add_node("node-b");
608        ring.add_node("node-c");
609
610        // Same PID always maps to same node
611        let first = ring.assign(12345).unwrap().to_string();
612        for _ in 0..100 {
613            assert_eq!(ring.assign(12345).unwrap(), first);
614        }
615    }
616
617    #[test]
618    fn consistent_hash_ring_redistribution() {
619        let mut ring = ConsistentHashRing::new(64);
620        ring.add_node("node-a");
621        ring.add_node("node-b");
622        ring.add_node("node-c");
623
624        // Record assignments for 1000 PIDs
625        let before: Vec<String> = (0..1000)
626            .map(|pid| ring.assign(pid).unwrap().to_string())
627            .collect();
628
629        // Remove one node
630        ring.remove_node("node-b");
631
632        let after: Vec<String> = (0..1000)
633            .map(|pid| ring.assign(pid).unwrap().to_string())
634            .collect();
635
636        // Only PIDs that were on node-b should have moved
637        let mut moved = 0;
638        for i in 0..1000 {
639            if before[i] != after[i] {
640                moved += 1;
641                // Moved PID must have been on the removed node
642                assert_eq!(before[i], "node-b");
643            }
644        }
645        // Some PIDs should have moved, but not all of them
646        assert!(moved > 0, "at least some PIDs should be redistributed");
647        assert!(moved < 1000, "not all PIDs should move");
648    }
649
650    #[test]
651    fn consistent_hash_ring_empty_returns_none() {
652        let ring = ConsistentHashRing::new(64);
653        assert!(ring.assign(1).is_none());
654    }
655
656    #[test]
657    fn pid_to_node_assignment() {
658        let mut ring = ConsistentHashRing::new(64);
659        ring.add_node("node-a");
660        ring.add_node("node-b");
661
662        let table = DistributedProcessTable::new();
663        let node = table.assign_pid(42, &ring);
664        assert!(node.is_some());
665        let n = node.unwrap();
666        assert!(n == "node-a" || n == "node-b");
667    }
668
669    // ── MetadataConsensus tests ────────────────────────────────────
670
671    #[test]
672    fn consensus_append_commit_cycle() {
673        let mut mc = MetadataConsensus::new();
674        assert_eq!(mc.role(), ConsensusRole::Follower);
675        assert_eq!(mc.term(), 0);
676        assert_eq!(mc.commit_index(), 0);
677
678        mc.set_role(ConsensusRole::Leader);
679        assert_eq!(mc.role(), ConsensusRole::Leader);
680
681        let idx1 = mc.append(ConsensusOp::RegisterService {
682            name: "cache".into(),
683            node_id: "node-a".into(),
684        });
685        assert_eq!(idx1, 1);
686
687        let idx2 = mc.append(ConsensusOp::RegisterProcess {
688            global_pid: "node-a:1".into(),
689            agent_type: "coder".into(),
690        });
691        assert_eq!(idx2, 2);
692
693        assert_eq!(mc.log_len(), 2);
694
695        // Commit up to index 2
696        let committed = mc.commit(2);
697        assert_eq!(committed.len(), 2);
698        assert_eq!(mc.commit_index(), 2);
699
700        // Committing again yields nothing new
701        let committed2 = mc.commit(2);
702        assert_eq!(committed2.len(), 0);
703    }
704
705    #[test]
706    fn consensus_advance_term() {
707        let mut mc = MetadataConsensus::new();
708        mc.set_role(ConsensusRole::Leader);
709        mc.advance_term(5);
710        assert_eq!(mc.term(), 5);
711        // Advancing term resets role to follower
712        assert_eq!(mc.role(), ConsensusRole::Follower);
713
714        // Old term is ignored
715        mc.advance_term(3);
716        assert_eq!(mc.term(), 5);
717    }
718
719    #[test]
720    fn consensus_commit_clamps_to_log_len() {
721        let mut mc = MetadataConsensus::new();
722        mc.append(ConsensusOp::DeregisterProcess {
723            global_pid: "node-a:1".into(),
724        });
725        // Commit beyond log length is clamped
726        mc.commit(999);
727        assert_eq!(mc.commit_index(), 1);
728    }
729
730    // ── CrdtGossipState tests ──────────────────────────────────────
731
732    #[test]
733    fn crdt_set_get() {
734        let mut state = CrdtGossipState::new();
735        assert!(state.is_empty());
736
737        state.set("key1".into(), serde_json::json!("value1"));
738        assert_eq!(state.len(), 1);
739        assert_eq!(state.get("key1"), Some(&serde_json::json!("value1")));
740        assert!(state.get("missing").is_none());
741    }
742
743    #[test]
744    fn crdt_merge_lww() {
745        let mut local = CrdtGossipState::new();
746        local.set("key1".into(), serde_json::json!("local_v1"));
747        // local clock is now 1
748
749        // Remote has a higher clock for key1
750        let remote = vec![("key1".into(), serde_json::json!("remote_v2"), 5u64)];
751        local.merge(&remote);
752        assert_eq!(local.get("key1"), Some(&serde_json::json!("remote_v2")));
753        assert_eq!(local.clock(), 5);
754
755        // Merge with older clock -- should be ignored
756        let old_remote = vec![("key1".into(), serde_json::json!("old_value"), 2u64)];
757        local.merge(&old_remote);
758        assert_eq!(local.get("key1"), Some(&serde_json::json!("remote_v2")));
759    }
760
761    #[test]
762    fn crdt_delta_since() {
763        let mut state = CrdtGossipState::new();
764        state.set("a".into(), serde_json::json!(1));
765        state.set("b".into(), serde_json::json!(2));
766        state.set("c".into(), serde_json::json!(3));
767
768        // Delta since clock=1 should include b (clock=2) and c (clock=3)
769        let delta = state.delta_since(1);
770        assert_eq!(delta.len(), 2);
771
772        // Delta since clock=0 should include everything
773        let all = state.delta_since(0);
774        assert_eq!(all.len(), 3);
775
776        // Delta since current clock should be empty
777        let none = state.delta_since(state.clock());
778        assert_eq!(none.len(), 0);
779    }
780
781    #[test]
782    fn crdt_concurrent_updates_converge() {
783        // Simulate two nodes with different clocks, then merge.
784        // With LWW, the higher clock always wins on both sides.
785        let mut node_a = CrdtGossipState::new();
786        let mut node_b = CrdtGossipState::new();
787
788        // Node A sets key1 at clock 1
789        node_a.set("key1".into(), serde_json::json!("A"));
790        // Node B sets key1 then key2, so clock is 2 (higher than A's 1)
791        node_b.set("warmup".into(), serde_json::json!("x"));
792        node_b.set("key1".into(), serde_json::json!("B"));
793        // node_b clock for key1 = 2, node_a clock for key1 = 1
794
795        // Exchange deltas
796        let delta_a = node_a.delta_since(0);
797        let delta_b = node_b.delta_since(0);
798
799        node_a.merge(&delta_b);
800        node_b.merge(&delta_a);
801
802        // Both should converge to B's value (clock 2 > clock 1)
803        assert_eq!(node_a.get("key1"), Some(&serde_json::json!("B")));
804        assert_eq!(node_b.get("key1"), Some(&serde_json::json!("B")));
805    }
806}