Skip to main content

ipfrs_network/
anti_entropy.rs

1//! Gossip Anti-Entropy — Merkle-digest-based state reconciliation between peers.
2//!
3//! Implements efficient detection and repair of state divergence using FNV-1a hashes,
4//! sorted digest entries, and a lightweight reconciliation protocol that classifies
5//! differences into sent, requested, and conflict categories.
6//!
7//! ## Overview
8//!
9//! Anti-entropy works in three phases:
10//! 1. **Build** — each node builds a [`MerkleDigest`] from its local [`DigestEntry`] table.
11//! 2. **Compare** — `diff_keys` finds keys that differ between two digests.
12//! 3. **Reconcile** — [`GossipAntiEntropy::reconcile`] classifies each diff key and returns
13//!    a [`ReconcileResult`] describing what to send, what to request, and what conflicts exist.
14//!
15//! ## Example
16//!
17//! ```rust
18//! use ipfrs_network::anti_entropy::{AntiEntropyConfig, GossipAntiEntropy};
19//!
20//! let config = AntiEntropyConfig::default();
21//! let mut ae = GossipAntiEntropy::new(config);
22//!
23//! ae.upsert("block/abc".to_string(), 0xdeadbeef, 1, 1_700_000_000);
24//! ae.upsert("block/xyz".to_string(), 0xc0ffee00, 2, 1_700_000_001);
25//!
26//! let digest = ae.build_digest();
27//! assert_eq!(digest.entries.len(), 2);
28//! let (count, _hash) = ae.stats();
29//! assert_eq!(count, 2);
30//! ```
31
32use std::collections::HashMap;
33
34// ---------------------------------------------------------------------------
35// FNV-1a constants and helpers
36// ---------------------------------------------------------------------------
37
38const FNV_OFFSET_BASIS: u64 = 14_695_981_039_346_656_037;
39const FNV_PRIME: u64 = 1_099_511_628_211;
40
41/// Compute a FNV-1a 64-bit hash of the given byte slice.
42fn fnv1a(data: &[u8]) -> u64 {
43    let mut hash = FNV_OFFSET_BASIS;
44    for &byte in data {
45        hash ^= u64::from(byte);
46        hash = hash.wrapping_mul(FNV_PRIME);
47    }
48    hash
49}
50
51/// Hash a UTF-8 string with FNV-1a.
52fn fnv1a_str(s: &str) -> u64 {
53    fnv1a(s.as_bytes())
54}
55
56// ---------------------------------------------------------------------------
57// DigestEntry
58// ---------------------------------------------------------------------------
59
60/// A single entry in a Merkle digest representing a keyed piece of state.
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct DigestEntry {
63    /// Logical key identifying this piece of state.
64    pub key: String,
65    /// FNV-1a hash of the serialized value at this key.
66    pub value_hash: u64,
67    /// Monotonic version counter; higher means newer.
68    pub version: u64,
69    /// Wall-clock timestamp (seconds since Unix epoch) when last updated.
70    pub updated_at_secs: u64,
71}
72
73impl DigestEntry {
74    /// Create a new digest entry.
75    pub fn new(key: String, value_hash: u64, version: u64, updated_at_secs: u64) -> Self {
76        Self {
77            key,
78            value_hash,
79            version,
80            updated_at_secs,
81        }
82    }
83
84    /// Compute the per-entry contribution to the root hash.
85    ///
86    /// Combines `key_hash XOR value_hash XOR version` so that any change to
87    /// any field shifts the root.
88    fn root_contribution(&self) -> u64 {
89        fnv1a_str(&self.key) ^ self.value_hash ^ self.version
90    }
91}
92
93// ---------------------------------------------------------------------------
94// MerkleDigest
95// ---------------------------------------------------------------------------
96
97/// A compact, sortable digest of local state suitable for peer comparison.
98///
99/// Entries are always kept sorted by key so that `diff_keys` is deterministic
100/// and independent of insertion order.
101#[derive(Debug, Clone, PartialEq, Eq)]
102pub struct MerkleDigest {
103    /// Sorted (by key) snapshot of local state entries.
104    pub entries: Vec<DigestEntry>,
105    /// XOR-fold of all per-entry `(key_hash XOR value_hash XOR version)` tuples.
106    pub root_hash: u64,
107}
108
109impl MerkleDigest {
110    /// Compute a [`MerkleDigest`] from an already-sorted list of entries.
111    ///
112    /// Assumes `entries` is sorted by key; callers must ensure this invariant.
113    pub fn from_sorted(entries: Vec<DigestEntry>) -> Self {
114        let root_hash = entries
115            .iter()
116            .fold(0u64, |acc, e| acc ^ e.root_contribution());
117        Self { entries, root_hash }
118    }
119
120    /// Returns `true` when both digests share the same root hash, meaning all
121    /// keys, values, and versions are identical.
122    pub fn matches(&self, other: &MerkleDigest) -> bool {
123        self.root_hash == other.root_hash
124    }
125
126    /// Return the set of keys that differ between `self` and `other`.
127    ///
128    /// A key is included when:
129    /// - It exists in one digest but not the other.
130    /// - It exists in both but `value_hash` or `version` differ.
131    pub fn diff_keys(&self, other: &MerkleDigest) -> Vec<String> {
132        // Build a lookup map from the `other` digest.
133        let other_map: HashMap<&str, &DigestEntry> =
134            other.entries.iter().map(|e| (e.key.as_str(), e)).collect();
135        let self_map: HashMap<&str, &DigestEntry> =
136            self.entries.iter().map(|e| (e.key.as_str(), e)).collect();
137
138        let mut diff: Vec<String> = Vec::new();
139
140        // Keys in self: check for missing or changed entries in other.
141        for e in &self.entries {
142            match other_map.get(e.key.as_str()) {
143                None => diff.push(e.key.clone()),
144                Some(o) => {
145                    if o.value_hash != e.value_hash || o.version != e.version {
146                        diff.push(e.key.clone());
147                    }
148                }
149            }
150        }
151
152        // Keys in other but missing from self.
153        for e in &other.entries {
154            if !self_map.contains_key(e.key.as_str()) {
155                diff.push(e.key.clone());
156            }
157        }
158
159        diff.sort();
160        diff.dedup();
161        diff
162    }
163}
164
165// ---------------------------------------------------------------------------
166// ReconcileResult
167// ---------------------------------------------------------------------------
168
169/// Outcome of a reconciliation round between two peers.
170#[derive(Debug, Clone, Default, PartialEq, Eq)]
171pub struct ReconcileResult {
172    /// Keys that this peer should *send* to the remote (we are ahead or remote is missing them).
173    pub sent_keys: Vec<String>,
174    /// Keys that this peer needs to *request* from the remote (remote is ahead or we are missing them).
175    pub requested_keys: Vec<String>,
176    /// Keys where both sides disagree on `value_hash` at the *same* version (true conflicts).
177    pub conflict_keys: Vec<String>,
178}
179
180// ---------------------------------------------------------------------------
181// AntiEntropyConfig
182// ---------------------------------------------------------------------------
183
184/// Configuration knobs for [`GossipAntiEntropy`].
185#[derive(Debug, Clone)]
186pub struct AntiEntropyConfig {
187    /// How often (in seconds) to run anti-entropy synchronisation with each peer.
188    pub sync_interval_secs: u64,
189    /// Maximum number of diff keys to process in a single reconciliation round.
190    /// Prevents unbounded message sizes during initial bootstrap.
191    pub max_diff_keys: usize,
192}
193
194impl Default for AntiEntropyConfig {
195    fn default() -> Self {
196        Self {
197            sync_interval_secs: 30,
198            max_diff_keys: 100,
199        }
200    }
201}
202
203// ---------------------------------------------------------------------------
204// GossipAntiEntropy
205// ---------------------------------------------------------------------------
206
207/// Anti-entropy engine that maintains local state and reconciles with remote peers.
208///
209/// Use [`GossipAntiEntropy::upsert`] to register or update state entries,
210/// [`GossipAntiEntropy::build_digest`] to create a digest for exchange, and
211/// [`GossipAntiEntropy::reconcile`] to classify differences once you have
212/// the remote peer's digest.
213pub struct GossipAntiEntropy {
214    /// Local keyed state.  Key is the entry's logical key string.
215    local_state: HashMap<String, DigestEntry>,
216    /// Configuration controlling sync frequency and batch limits.
217    config: AntiEntropyConfig,
218}
219
220impl GossipAntiEntropy {
221    /// Create a new anti-entropy engine with the given configuration.
222    pub fn new(config: AntiEntropyConfig) -> Self {
223        Self {
224            local_state: HashMap::new(),
225            config,
226        }
227    }
228
229    /// Insert or update an entry.
230    ///
231    /// The update is applied only when `version` is strictly greater than the
232    /// version already stored for `key`.  Stale updates are silently ignored.
233    pub fn upsert(&mut self, key: String, value_hash: u64, version: u64, now_secs: u64) {
234        let should_insert = match self.local_state.get(&key) {
235            None => true,
236            Some(existing) => version > existing.version,
237        };
238
239        if should_insert {
240            self.local_state.insert(
241                key.clone(),
242                DigestEntry::new(key, value_hash, version, now_secs),
243            );
244        }
245    }
246
247    /// Build a [`MerkleDigest`] snapshot of the current local state.
248    ///
249    /// Entries are sorted by key before computing the root hash so that
250    /// digests are comparable regardless of `HashMap` ordering.
251    pub fn build_digest(&self) -> MerkleDigest {
252        let mut entries: Vec<DigestEntry> = self.local_state.values().cloned().collect();
253        entries.sort_by(|a, b| a.key.cmp(&b.key));
254        MerkleDigest::from_sorted(entries)
255    }
256
257    /// Reconcile a local digest against a remote digest.
258    ///
259    /// Classifies each differing key into one of three buckets:
260    /// - `sent_keys`: we are ahead (higher version) or remote is missing the key entirely.
261    /// - `requested_keys`: remote is ahead or we are missing the key entirely.
262    /// - `conflict_keys`: same version but different `value_hash` — true conflict.
263    ///
264    /// The total number of keys across all three buckets is capped at
265    /// `config.max_diff_keys` to bound message sizes.
266    pub fn reconcile(&self, local: &MerkleDigest, remote: &MerkleDigest) -> ReconcileResult {
267        if local.matches(remote) {
268            return ReconcileResult::default();
269        }
270
271        let diff = local.diff_keys(remote);
272
273        // Build lookup maps for O(1) access.
274        let local_map: HashMap<&str, &DigestEntry> =
275            local.entries.iter().map(|e| (e.key.as_str(), e)).collect();
276        let remote_map: HashMap<&str, &DigestEntry> =
277            remote.entries.iter().map(|e| (e.key.as_str(), e)).collect();
278
279        let mut result = ReconcileResult::default();
280        let mut total = 0usize;
281
282        for key in &diff {
283            if total >= self.config.max_diff_keys {
284                break;
285            }
286
287            let local_entry = local_map.get(key.as_str()).copied();
288            let remote_entry = remote_map.get(key.as_str()).copied();
289
290            match (local_entry, remote_entry) {
291                // We have it, peer does not → send it.
292                (Some(_), None) => {
293                    result.sent_keys.push(key.clone());
294                    total += 1;
295                }
296                // Peer has it, we do not → request it.
297                (None, Some(_)) => {
298                    result.requested_keys.push(key.clone());
299                    total += 1;
300                }
301                // Both have it: compare versions.
302                (Some(l), Some(r)) => {
303                    if l.version > r.version {
304                        // We are ahead.
305                        result.sent_keys.push(key.clone());
306                        total += 1;
307                    } else if r.version > l.version {
308                        // Peer is ahead.
309                        result.requested_keys.push(key.clone());
310                        total += 1;
311                    } else {
312                        // Same version but different value_hash → conflict.
313                        result.conflict_keys.push(key.clone());
314                        total += 1;
315                    }
316                }
317                // Both missing — should not occur in practice (diff_keys
318                // only returns keys that are in at least one digest), but
319                // handle gracefully.
320                (None, None) => {}
321            }
322        }
323
324        result
325    }
326
327    /// Apply a remote entry, accepting it if its version is strictly newer.
328    pub fn apply_remote_entry(&mut self, entry: DigestEntry) {
329        self.upsert(
330            entry.key.clone(),
331            entry.value_hash,
332            entry.version,
333            entry.updated_at_secs,
334        );
335    }
336
337    /// Return basic statistics about the current local state.
338    ///
339    /// Returns `(entry_count, root_hash)`.
340    pub fn stats(&self) -> (usize, u64) {
341        let digest = self.build_digest();
342        (self.local_state.len(), digest.root_hash)
343    }
344}
345
346// ---------------------------------------------------------------------------
347// Tests
348// ---------------------------------------------------------------------------
349
350#[cfg(test)]
351mod tests {
352    use super::*;
353
354    fn make_ae() -> GossipAntiEntropy {
355        GossipAntiEntropy::new(AntiEntropyConfig::default())
356    }
357
358    // ------------------------------------------------------------------
359    // upsert tests
360    // ------------------------------------------------------------------
361
362    /// T01 — upsert creates a new entry when the key is absent.
363    #[test]
364    fn test_upsert_creates_entry() {
365        let mut ae = make_ae();
366        ae.upsert("key/a".to_string(), 0xABCD, 1, 1000);
367        let (count, _) = ae.stats();
368        assert_eq!(count, 1);
369    }
370
371    /// T02 — upsert with a higher version updates the stored entry.
372    #[test]
373    fn test_upsert_newer_version_updates() {
374        let mut ae = make_ae();
375        ae.upsert("key/a".to_string(), 0x0001, 1, 1000);
376        ae.upsert("key/a".to_string(), 0x0002, 2, 1001);
377        let digest = ae.build_digest();
378        assert_eq!(digest.entries.len(), 1);
379        assert_eq!(digest.entries[0].value_hash, 0x0002);
380        assert_eq!(digest.entries[0].version, 2);
381    }
382
383    /// T03 — upsert with an older or equal version must NOT overwrite.
384    #[test]
385    fn test_upsert_ignores_older_version() {
386        let mut ae = make_ae();
387        ae.upsert("key/a".to_string(), 0xAAAA, 5, 2000);
388        ae.upsert("key/a".to_string(), 0xBBBB, 3, 1000); // stale
389        ae.upsert("key/a".to_string(), 0xCCCC, 5, 2001); // same version, different hash
390        let digest = ae.build_digest();
391        assert_eq!(digest.entries[0].value_hash, 0xAAAA);
392        assert_eq!(digest.entries[0].version, 5);
393    }
394
395    /// T04 — upsert of multiple distinct keys accumulates all entries.
396    #[test]
397    fn test_upsert_multiple_keys() {
398        let mut ae = make_ae();
399        for i in 0u64..5 {
400            ae.upsert(format!("key/{}", i), i * 100, i + 1, 1000 + i);
401        }
402        let (count, _) = ae.stats();
403        assert_eq!(count, 5);
404    }
405
406    // ------------------------------------------------------------------
407    // build_digest / sorting tests
408    // ------------------------------------------------------------------
409
410    /// T05 — build_digest returns entries sorted lexicographically by key.
411    #[test]
412    fn test_build_digest_sorted() {
413        let mut ae = make_ae();
414        ae.upsert("zebra".to_string(), 1, 1, 0);
415        ae.upsert("apple".to_string(), 2, 1, 0);
416        ae.upsert("mango".to_string(), 3, 1, 0);
417        let digest = ae.build_digest();
418        let keys: Vec<&str> = digest.entries.iter().map(|e| e.key.as_str()).collect();
419        assert_eq!(keys, vec!["apple", "mango", "zebra"]);
420    }
421
422    /// T06 — build_digest on empty state produces zero root_hash and empty entries.
423    #[test]
424    fn test_build_digest_empty() {
425        let ae = make_ae();
426        let digest = ae.build_digest();
427        assert!(digest.entries.is_empty());
428        assert_eq!(digest.root_hash, 0);
429    }
430
431    // ------------------------------------------------------------------
432    // MerkleDigest::matches
433    // ------------------------------------------------------------------
434
435    /// T07 — two digests built from identical state must match.
436    #[test]
437    fn test_digest_matches_same_state() {
438        let mut ae1 = make_ae();
439        let mut ae2 = make_ae();
440        ae1.upsert("k1".to_string(), 111, 1, 0);
441        ae1.upsert("k2".to_string(), 222, 2, 0);
442        ae2.upsert("k1".to_string(), 111, 1, 0);
443        ae2.upsert("k2".to_string(), 222, 2, 0);
444        assert!(ae1.build_digest().matches(&ae2.build_digest()));
445    }
446
447    /// T08 — digests with different value_hashes must not match.
448    #[test]
449    fn test_digest_not_matches_different_hash() {
450        let mut ae1 = make_ae();
451        let mut ae2 = make_ae();
452        ae1.upsert("k1".to_string(), 111, 1, 0);
453        ae2.upsert("k1".to_string(), 999, 1, 0);
454        assert!(!ae1.build_digest().matches(&ae2.build_digest()));
455    }
456
457    // ------------------------------------------------------------------
458    // MerkleDigest::diff_keys
459    // ------------------------------------------------------------------
460
461    /// T09 — diff_keys returns keys missing from the other digest.
462    #[test]
463    fn test_diff_keys_finds_missing() {
464        let mut ae1 = make_ae();
465        let mut ae2 = make_ae();
466        ae1.upsert("a".to_string(), 1, 1, 0);
467        ae1.upsert("b".to_string(), 2, 1, 0);
468        ae2.upsert("a".to_string(), 1, 1, 0);
469        // "b" is missing from ae2
470
471        let d1 = ae1.build_digest();
472        let d2 = ae2.build_digest();
473        let diff = d1.diff_keys(&d2);
474        assert_eq!(diff, vec!["b"]);
475    }
476
477    /// T10 — diff_keys detects a version difference on a shared key.
478    #[test]
479    fn test_diff_keys_finds_version_diff() {
480        let mut ae1 = make_ae();
481        let mut ae2 = make_ae();
482        ae1.upsert("x".to_string(), 0xAA, 3, 0);
483        ae2.upsert("x".to_string(), 0xAA, 1, 0); // same hash, older version
484
485        let diff = ae1.build_digest().diff_keys(&ae2.build_digest());
486        assert_eq!(diff, vec!["x"]);
487    }
488
489    /// T11 — diff_keys detects a value_hash difference at the same version.
490    #[test]
491    fn test_diff_keys_finds_hash_diff_same_version() {
492        let mut ae1 = make_ae();
493        let mut ae2 = make_ae();
494        ae1.upsert("x".to_string(), 0xAA, 5, 0);
495        ae2.upsert("x".to_string(), 0xBB, 5, 0); // different hash, same version
496
497        let diff = ae1.build_digest().diff_keys(&ae2.build_digest());
498        assert_eq!(diff, vec!["x"]);
499    }
500
501    /// T12 — diff_keys returns empty when digests are identical.
502    #[test]
503    fn test_diff_keys_empty_when_identical() {
504        let mut ae = make_ae();
505        ae.upsert("p".to_string(), 7, 7, 7);
506        let d = ae.build_digest();
507        assert!(d.diff_keys(&d.clone()).is_empty());
508    }
509
510    // ------------------------------------------------------------------
511    // reconcile tests
512    // ------------------------------------------------------------------
513
514    /// T13 — reconcile correctly classifies a key that only we have as sent.
515    #[test]
516    fn test_reconcile_sent_key_local_only() {
517        let mut ae = make_ae();
518        ae.upsert("local_only".to_string(), 1, 1, 0);
519        let local = ae.build_digest();
520        let remote = MerkleDigest::from_sorted(vec![]);
521        let result = ae.reconcile(&local, &remote);
522        assert_eq!(result.sent_keys, vec!["local_only"]);
523        assert!(result.requested_keys.is_empty());
524        assert!(result.conflict_keys.is_empty());
525    }
526
527    /// T14 — reconcile classifies a key that only the remote has as requested.
528    #[test]
529    fn test_reconcile_requested_key_remote_only() {
530        let ae = make_ae();
531        let local = MerkleDigest::from_sorted(vec![]);
532        let remote =
533            MerkleDigest::from_sorted(vec![DigestEntry::new("remote_only".to_string(), 42, 1, 0)]);
534        let result = ae.reconcile(&local, &remote);
535        assert!(result.sent_keys.is_empty());
536        assert_eq!(result.requested_keys, vec!["remote_only"]);
537        assert!(result.conflict_keys.is_empty());
538    }
539
540    /// T15 — reconcile classifies same-version/different-hash keys as conflicts.
541    #[test]
542    fn test_reconcile_conflict_same_version_diff_hash() {
543        let mut ae = make_ae();
544        ae.upsert("conflict_key".to_string(), 0xAA, 5, 0);
545        let local = ae.build_digest();
546        let remote = MerkleDigest::from_sorted(vec![DigestEntry::new(
547            "conflict_key".to_string(),
548            0xBB,
549            5,
550            0,
551        )]);
552        let result = ae.reconcile(&local, &remote);
553        assert!(result.sent_keys.is_empty());
554        assert!(result.requested_keys.is_empty());
555        assert_eq!(result.conflict_keys, vec!["conflict_key"]);
556    }
557
558    /// T16 — reconcile sent when local version is higher.
559    #[test]
560    fn test_reconcile_sent_local_ahead() {
561        let mut ae = make_ae();
562        ae.upsert("k".to_string(), 0xAA, 10, 0);
563        let local = ae.build_digest();
564        let remote = MerkleDigest::from_sorted(vec![DigestEntry::new("k".to_string(), 0xAA, 2, 0)]);
565        let result = ae.reconcile(&local, &remote);
566        assert_eq!(result.sent_keys, vec!["k"]);
567        assert!(result.requested_keys.is_empty());
568    }
569
570    /// T17 — reconcile requested when remote version is higher.
571    #[test]
572    fn test_reconcile_requested_remote_ahead() {
573        let mut ae = make_ae();
574        ae.upsert("k".to_string(), 0xAA, 2, 0);
575        let local = ae.build_digest();
576        let remote =
577            MerkleDigest::from_sorted(vec![DigestEntry::new("k".to_string(), 0xBB, 10, 0)]);
578        let result = ae.reconcile(&local, &remote);
579        assert!(result.sent_keys.is_empty());
580        assert_eq!(result.requested_keys, vec!["k"]);
581    }
582
583    /// T18 — reconcile returns empty result when digests match.
584    #[test]
585    fn test_reconcile_empty_when_identical() {
586        let mut ae = make_ae();
587        ae.upsert("same".to_string(), 0x1234, 3, 0);
588        let digest = ae.build_digest();
589        let result = ae.reconcile(&digest, &digest.clone());
590        assert_eq!(result, ReconcileResult::default());
591    }
592
593    /// T19 — max_diff_keys cap limits total reconciled keys.
594    #[test]
595    fn test_reconcile_max_diff_keys_cap() {
596        let config = AntiEntropyConfig {
597            sync_interval_secs: 30,
598            max_diff_keys: 3,
599        };
600        let mut ae = GossipAntiEntropy::new(config);
601        // Add 10 keys that the remote does not have.
602        for i in 0u64..10 {
603            ae.upsert(format!("key/{:02}", i), i, i + 1, 0);
604        }
605        let local = ae.build_digest();
606        let remote = MerkleDigest::from_sorted(vec![]);
607        let result = ae.reconcile(&local, &remote);
608        // Total across all buckets must not exceed max_diff_keys.
609        let total =
610            result.sent_keys.len() + result.requested_keys.len() + result.conflict_keys.len();
611        assert!(total <= 3, "expected ≤3, got {}", total);
612    }
613
614    // ------------------------------------------------------------------
615    // apply_remote_entry
616    // ------------------------------------------------------------------
617
618    /// T20 — apply_remote_entry accepts a newer remote entry.
619    #[test]
620    fn test_apply_remote_entry_updates() {
621        let mut ae = make_ae();
622        ae.upsert("k".to_string(), 0x01, 1, 0);
623        ae.apply_remote_entry(DigestEntry::new("k".to_string(), 0x99, 5, 100));
624        let digest = ae.build_digest();
625        assert_eq!(digest.entries[0].value_hash, 0x99);
626        assert_eq!(digest.entries[0].version, 5);
627    }
628
629    /// T21 — apply_remote_entry rejects an older remote entry.
630    #[test]
631    fn test_apply_remote_entry_rejects_older() {
632        let mut ae = make_ae();
633        ae.upsert("k".to_string(), 0xFF, 10, 0);
634        ae.apply_remote_entry(DigestEntry::new("k".to_string(), 0x00, 3, 50));
635        let digest = ae.build_digest();
636        assert_eq!(digest.entries[0].value_hash, 0xFF);
637        assert_eq!(digest.entries[0].version, 10);
638    }
639
640    /// T22 — apply_remote_entry inserts a brand-new key.
641    #[test]
642    fn test_apply_remote_entry_new_key() {
643        let mut ae = make_ae();
644        ae.apply_remote_entry(DigestEntry::new("new_key".to_string(), 42, 1, 999));
645        let (count, _) = ae.stats();
646        assert_eq!(count, 1);
647        let digest = ae.build_digest();
648        assert_eq!(digest.entries[0].key, "new_key");
649    }
650
651    // ------------------------------------------------------------------
652    // stats
653    // ------------------------------------------------------------------
654
655    /// T23 — stats returns correct entry count and a deterministic root_hash.
656    #[test]
657    fn test_stats_deterministic() {
658        let mut ae1 = make_ae();
659        let mut ae2 = make_ae();
660        // Insert in different orders.
661        ae1.upsert("alpha".to_string(), 1, 1, 0);
662        ae1.upsert("beta".to_string(), 2, 1, 0);
663        ae2.upsert("beta".to_string(), 2, 1, 0);
664        ae2.upsert("alpha".to_string(), 1, 1, 0);
665        let (c1, h1) = ae1.stats();
666        let (c2, h2) = ae2.stats();
667        assert_eq!(c1, c2);
668        assert_eq!(h1, h2);
669    }
670
671    // ------------------------------------------------------------------
672    // Empty-digest reconciliation
673    // ------------------------------------------------------------------
674
675    /// T24 — reconciling two empty digests produces an empty result.
676    #[test]
677    fn test_reconcile_both_empty() {
678        let ae = make_ae();
679        let empty = MerkleDigest::from_sorted(vec![]);
680        let result = ae.reconcile(&empty, &empty.clone());
681        assert_eq!(result, ReconcileResult::default());
682    }
683
684    // ------------------------------------------------------------------
685    // Root-hash stability
686    // ------------------------------------------------------------------
687
688    /// T25 — root_hash changes when a value_hash is updated.
689    #[test]
690    fn test_root_hash_changes_on_update() {
691        let mut ae = make_ae();
692        ae.upsert("key".to_string(), 0x1111, 1, 0);
693        let h1 = ae.build_digest().root_hash;
694        ae.upsert("key".to_string(), 0x2222, 2, 0);
695        let h2 = ae.build_digest().root_hash;
696        assert_ne!(h1, h2);
697    }
698
699    /// T26 — reconcile mixed bag: some sent, some requested, some conflicts, capped.
700    #[test]
701    fn test_reconcile_mixed_classification() {
702        let config = AntiEntropyConfig {
703            sync_interval_secs: 30,
704            max_diff_keys: 100,
705        };
706        let mut ae = GossipAntiEntropy::new(config);
707        // local_only: we have it, remote does not.
708        ae.upsert("local_only".to_string(), 1, 1, 0);
709        // shared_ahead: we have higher version.
710        ae.upsert("shared_ahead".to_string(), 10, 10, 0);
711        // shared_conflict: same version, different hash.
712        ae.upsert("shared_conflict".to_string(), 0xAA, 5, 0);
713
714        let local = ae.build_digest();
715        let remote = MerkleDigest::from_sorted(vec![
716            DigestEntry::new("remote_only".to_string(), 99, 1, 0),
717            DigestEntry::new("shared_ahead".to_string(), 10, 2, 0), // older version
718            DigestEntry::new("shared_conflict".to_string(), 0xBB, 5, 0), // same version, different hash
719        ]);
720
721        let result = ae.reconcile(&local, &remote);
722        assert!(result.sent_keys.contains(&"local_only".to_string()));
723        assert!(result.sent_keys.contains(&"shared_ahead".to_string()));
724        assert!(result.requested_keys.contains(&"remote_only".to_string()));
725        assert!(result
726            .conflict_keys
727            .contains(&"shared_conflict".to_string()));
728    }
729}