Skip to main content

bones_core/sync/
prolly.rs

1//! Prolly Tree: content-defined chunked Merkle tree for efficient O(log N) diff.
2//!
3//! Events are keyed by `(item_id, wall_ts_us)`, sorted, then split into chunks
4//! using a rolling hash (Gear hash) for content-defined boundaries. A balanced
5//! Merkle tree is built over the chunks so that two replicas can diff in
6//! O(log N) time by comparing hashes top-down.
7//!
8//! This is a **library module** intended for use by external sync tools (e.g.
9//! transport layers over TCP, HTTP, MCP, or file-based exchange). It is not
10//! exposed as a CLI command — bones does not own transport.
11//!
12//! # Usage
13//!
14//! ```rust
15//! use bones_core::sync::prolly::ProllyTree;
16//! # use bones_core::event::{Event, EventType};
17//! # use bones_core::event::data::{CreateData, EventData};
18//! # use bones_core::model::item::{Kind, Urgency};
19//! # use bones_core::model::item_id::ItemId;
20//! # use std::collections::BTreeMap;
21//! # let events: Vec<Event> = vec![];
22//!
23//! // Build trees from each replica's events.
24//! let local_tree = ProllyTree::build(&events);
25//! let remote_tree = ProllyTree::build(&events);
26//!
27//! // Compare root hashes for fast equality check.
28//! if local_tree.root.hash() == remote_tree.root.hash() {
29//!     // Replicas are identical — no sync needed.
30//! } else {
31//!     // Diff in O(k log N) where k = number of differing chunks.
32//!     let missing = local_tree.diff(&remote_tree);
33//!     // `missing` contains event hashes present in remote but not local.
34//! }
35//! ```
36
37use blake3::Hasher as Blake3;
38use serde::{Deserialize, Serialize};
39use std::fmt;
40
41use crate::event::Event;
42
43// ---------------------------------------------------------------------------
44// Configuration
45// ---------------------------------------------------------------------------
46
47/// Gear-hash mask: a chunk boundary fires when `gear_hash & MASK == 0`.
48/// With 6 low bits → average chunk size ≈ 64 events.
49const BOUNDARY_BITS: u32 = 6;
50const BOUNDARY_MASK: u64 = (1u64 << BOUNDARY_BITS) - 1;
51
52/// Minimum chunk size to avoid pathologically small chunks.
53const MIN_CHUNK_SIZE: usize = 8;
54
55/// Maximum chunk size to bound worst-case latency.
56const MAX_CHUNK_SIZE: usize = 256;
57
58/// Target fan-out for interior nodes (same boundary strategy applied
59/// recursively at each tree level).
60const INTERIOR_BOUNDARY_BITS: u32 = 3; // ~8 children per interior node
61const INTERIOR_BOUNDARY_MASK: u64 = (1u64 << INTERIOR_BOUNDARY_BITS) - 1;
62const MIN_INTERIOR_SIZE: usize = 2;
63const MAX_INTERIOR_SIZE: usize = 32;
64
65// ---------------------------------------------------------------------------
66// Gear hash table (pseudo-random, deterministic)
67// ---------------------------------------------------------------------------
68
69/// 256-entry table for the Gear rolling hash.
70/// Derived from BLAKE3 of byte index to ensure reproducibility.
71fn gear_table() -> [u64; 256] {
72    let mut table = [0u64; 256];
73    for i in 0u16..256 {
74        let h = blake3::hash(&i.to_le_bytes());
75        let bytes: [u8; 8] = h.as_bytes()[0..8]
76            .try_into()
77            .expect("BLAKE3 output is 32 bytes; first 8 always valid");
78        table[i as usize] = u64::from_le_bytes(bytes);
79    }
80    table
81}
82
83// ---------------------------------------------------------------------------
84// Hash newtype
85// ---------------------------------------------------------------------------
86
87/// 32-byte BLAKE3 hash used as a node/chunk identity.
88#[derive(Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
89pub struct Hash(pub [u8; 32]);
90
91impl fmt::Debug for Hash {
92    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
93        write!(f, "Hash({})", hex::encode(&self.0[..8]))
94    }
95}
96
97impl fmt::Display for Hash {
98    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
99        write!(f, "{}", hex::encode(&self.0))
100    }
101}
102
103fn hash_bytes(data: &[u8]) -> Hash {
104    Hash(*blake3::hash(data).as_bytes())
105}
106
107// ---------------------------------------------------------------------------
108// Sort key
109// ---------------------------------------------------------------------------
110
111/// Sort key for events: `(item_id, wall_ts_us, event_hash)`.
112/// The `event_hash` suffix makes the ordering fully deterministic even when
113/// two events share the same item and timestamp.
114fn sort_key(e: &Event) -> (String, i64, String) {
115    (
116        e.item_id.as_str().to_string(),
117        e.wall_ts_us,
118        e.event_hash.clone(),
119    )
120}
121
122// ---------------------------------------------------------------------------
123// Node types
124// ---------------------------------------------------------------------------
125
126/// A Prolly Tree node — either a leaf chunk of events or an interior node
127/// pointing to children.
128#[derive(Debug, Clone, Serialize, Deserialize)]
129pub enum ProllyNode {
130    Leaf {
131        hash: Hash,
132        /// Serialised event hashes in this chunk (not full events — those live
133        /// in the event log). We store just the `event_hash` strings so the
134        /// tree is compact.
135        event_hashes: Vec<String>,
136    },
137    Interior {
138        hash: Hash,
139        children: Vec<Self>,
140    },
141}
142
143impl ProllyNode {
144    #[must_use]
145    pub const fn hash(&self) -> Hash {
146        let (Self::Leaf { hash, .. } | Self::Interior { hash, .. }) = self;
147        *hash
148    }
149
150    /// Collect all event hashes reachable from this node.
151    pub fn collect_event_hashes(&self, out: &mut Vec<String>) {
152        match self {
153            Self::Leaf { event_hashes, .. } => {
154                out.extend(event_hashes.iter().cloned());
155            }
156            Self::Interior { children, .. } => {
157                for child in children {
158                    child.collect_event_hashes(out);
159                }
160            }
161        }
162    }
163}
164
165// ---------------------------------------------------------------------------
166// ProllyTree
167// ---------------------------------------------------------------------------
168
169/// Content-addressed Prolly Tree over a set of events.
170#[derive(Debug, Clone, Serialize, Deserialize)]
171pub struct ProllyTree {
172    pub root: ProllyNode,
173    pub event_count: usize,
174}
175
176impl ProllyTree {
177    /// Build a Prolly Tree from a set of events.
178    ///
179    /// Events are sorted by `(item_id, wall_ts_us, event_hash)`, then split
180    /// into content-defined chunks using a Gear rolling hash. A balanced
181    /// Merkle tree is constructed over the leaf chunks.
182    ///
183    /// The root hash is deterministic for any permutation of the same event
184    /// set.
185    #[must_use]
186    pub fn build(events: &[Event]) -> Self {
187        if events.is_empty() {
188            let empty_hash = hash_bytes(b"prolly:empty");
189            return Self {
190                root: ProllyNode::Leaf {
191                    hash: empty_hash,
192                    event_hashes: vec![],
193                },
194                event_count: 0,
195            };
196        }
197
198        // Sort events deterministically.
199        let mut sorted: Vec<&Event> = events.iter().collect();
200        sorted.sort_by_key(|a| sort_key(a));
201
202        // Build leaf chunks using Gear hash for content-defined boundaries.
203        let event_hashes: Vec<String> = sorted.iter().map(|e| e.event_hash.clone()).collect();
204        let leaves = chunk_leaf(&event_hashes);
205
206        // Build interior levels until we have a single root.
207        let root = build_interior(leaves);
208
209        Self {
210            root,
211            event_count: events.len(),
212        }
213    }
214
215    /// Compute the diff between `self` (local) and `other` (remote).
216    ///
217    /// Returns event hashes that are in `other` but not in `self`.
218    /// Runs in O(k log N) where k is the number of differing chunks.
219    #[must_use]
220    pub fn diff(&self, other: &Self) -> Vec<String> {
221        let mut missing = Vec::new();
222        diff_nodes(&self.root, &other.root, &mut missing);
223        missing
224    }
225
226    /// All event hashes stored in this tree.
227    #[must_use]
228    pub fn event_hashes(&self) -> Vec<String> {
229        let mut out = Vec::with_capacity(self.event_count);
230        self.root.collect_event_hashes(&mut out);
231        out
232    }
233
234    /// Serialize to bytes for wire transfer.
235    ///
236    /// # Panics
237    ///
238    /// Panics if `serde_json` fails to serialize `ProllyTree`, which should
239    /// never happen for well-formed trees.
240    #[must_use]
241    pub fn to_bytes(&self) -> Vec<u8> {
242        // Use bincode for compact binary serialization.
243        // Fall back to JSON if bincode isn't available.
244        serde_json::to_vec(self).expect("ProllyTree serialization should not fail")
245    }
246
247    /// Deserialize from bytes.
248    ///
249    /// # Errors
250    ///
251    /// Returns a [`serde_json::Error`] if the bytes are not valid JSON or do
252    /// not match the [`ProllyTree`] schema.
253    pub fn from_bytes(data: &[u8]) -> Result<Self, serde_json::Error> {
254        serde_json::from_slice(data)
255    }
256}
257
258// ---------------------------------------------------------------------------
259// Chunking
260// ---------------------------------------------------------------------------
261
262/// Split a sorted list of event hashes into content-defined leaf chunks.
263fn chunk_leaf(event_hashes: &[String]) -> Vec<ProllyNode> {
264    let table = gear_table();
265    let mut chunks = Vec::new();
266    let mut chunk_start = 0;
267    let mut gear: u64 = 0;
268
269    for (i, eh) in event_hashes.iter().enumerate() {
270        // Feed event hash bytes into the Gear hash.
271        for &b in eh.as_bytes() {
272            gear = (gear << 1).wrapping_add(table[b as usize]);
273        }
274
275        let chunk_len = i - chunk_start + 1;
276
277        let at_boundary = chunk_len >= MIN_CHUNK_SIZE && (gear & BOUNDARY_MASK) == 0;
278        let at_max = chunk_len >= MAX_CHUNK_SIZE;
279        let at_end = i == event_hashes.len() - 1;
280
281        if at_boundary || at_max || at_end {
282            let slice = &event_hashes[chunk_start..=i];
283            let hash = hash_leaf_chunk(slice);
284            chunks.push(ProllyNode::Leaf {
285                hash,
286                event_hashes: slice.to_vec(),
287            });
288            chunk_start = i + 1;
289            gear = 0;
290        }
291    }
292
293    chunks
294}
295
296fn hash_leaf_chunk(event_hashes: &[String]) -> Hash {
297    let mut hasher = Blake3::new();
298    hasher.update(b"prolly:leaf:");
299    for eh in event_hashes {
300        hasher.update(eh.as_bytes());
301        hasher.update(b"\n");
302    }
303    Hash(*hasher.finalize().as_bytes())
304}
305
306// ---------------------------------------------------------------------------
307// Interior tree construction
308// ---------------------------------------------------------------------------
309
310/// Recursively build interior nodes until a single root remains.
311fn build_interior(mut nodes: Vec<ProllyNode>) -> ProllyNode {
312    if nodes.len() == 1 {
313        return nodes.remove(0);
314    }
315
316    // Group nodes into interior chunks using Gear hash on child hashes.
317    let table = gear_table();
318    let mut groups: Vec<Vec<ProllyNode>> = Vec::new();
319    let mut current_group: Vec<ProllyNode> = Vec::new();
320    let mut gear: u64 = 0;
321
322    for node in nodes {
323        let h = node.hash();
324        for &b in &h.0[..8] {
325            gear = (gear << 1).wrapping_add(table[b as usize]);
326        }
327        current_group.push(node);
328
329        let group_len = current_group.len();
330        let at_boundary = group_len >= MIN_INTERIOR_SIZE && (gear & INTERIOR_BOUNDARY_MASK) == 0;
331        let at_max = group_len >= MAX_INTERIOR_SIZE;
332
333        if at_boundary || at_max {
334            groups.push(std::mem::take(&mut current_group));
335            gear = 0;
336        }
337    }
338    if !current_group.is_empty() {
339        groups.push(current_group);
340    }
341
342    // Build interior nodes from groups.
343    let interior_nodes: Vec<ProllyNode> = groups
344        .into_iter()
345        .map(|children| {
346            let hash = hash_interior(&children);
347            ProllyNode::Interior { hash, children }
348        })
349        .collect();
350
351    // Recurse until single root.
352    build_interior(interior_nodes)
353}
354
355fn hash_interior(children: &[ProllyNode]) -> Hash {
356    let mut hasher = Blake3::new();
357    hasher.update(b"prolly:interior:");
358    for child in children {
359        hasher.update(&child.hash().0);
360    }
361    Hash(*hasher.finalize().as_bytes())
362}
363
364// ---------------------------------------------------------------------------
365// Diff
366// ---------------------------------------------------------------------------
367
368/// Walk two trees in parallel, collecting event hashes from `other` that
369/// are not present in `local`.
370fn diff_nodes(local: &ProllyNode, other: &ProllyNode, missing: &mut Vec<String>) {
371    // If hashes match, subtrees are identical — prune.
372    if local.hash() == other.hash() {
373        return;
374    }
375
376    if let (
377        ProllyNode::Interior {
378            children: local_children,
379            ..
380        },
381        ProllyNode::Interior {
382            children: other_children,
383            ..
384        },
385    ) = (local, other)
386    {
387        // Both interior — match children by hash and recurse.
388        // Build a set of local child hashes for quick lookup.
389        let local_set: std::collections::HashSet<Hash> =
390            local_children.iter().map(ProllyNode::hash).collect();
391
392        for other_child in other_children {
393            if local_set.contains(&other_child.hash()) {
394                // Identical subtree — skip.
395                continue;
396            }
397            // Try to find a matching local child to recurse into.
398            // If no structural match, collect all events from other_child.
399            let local_match = local_children.iter().find(|lc| {
400                // Heuristic: same node type and overlapping structure.
401                matches!(
402                    (lc, other_child),
403                    (ProllyNode::Interior { .. }, ProllyNode::Interior { .. })
404                        | (ProllyNode::Leaf { .. }, ProllyNode::Leaf { .. })
405                )
406            });
407
408            match local_match {
409                Some(lm) => diff_nodes(lm, other_child, missing),
410                None => other_child.collect_event_hashes(missing),
411            }
412        }
413    } else {
414        // One or both are leaves — collect all event hashes from other
415        // that aren't in local.
416        let mut local_hashes = Vec::new();
417        local.collect_event_hashes(&mut local_hashes);
418        let local_set: std::collections::HashSet<&str> = local_hashes
419            .iter()
420            .map(std::string::String::as_str)
421            .collect();
422
423        let mut other_hashes = Vec::new();
424        other.collect_event_hashes(&mut other_hashes);
425
426        for h in other_hashes {
427            if !local_set.contains(h.as_str()) {
428                missing.push(h);
429            }
430        }
431    }
432}
433
434// ---------------------------------------------------------------------------
435// hex helper (avoid adding a crate dep for this)
436// ---------------------------------------------------------------------------
437mod hex {
438    const HEX_CHARS: &[u8; 16] = b"0123456789abcdef";
439
440    pub fn encode(bytes: &[u8]) -> String {
441        let mut s = String::with_capacity(bytes.len() * 2);
442        for &b in bytes {
443            s.push(HEX_CHARS[(b >> 4) as usize] as char);
444            s.push(HEX_CHARS[(b & 0x0f) as usize] as char);
445        }
446        s
447    }
448}
449
450// ---------------------------------------------------------------------------
451// Tests
452// ---------------------------------------------------------------------------
453
454#[cfg(test)]
455mod tests {
456    use super::*;
457    use crate::event::data::{CreateData, EventData};
458    use crate::event::{Event, EventType};
459    use crate::model::item::{Kind, Urgency};
460    use crate::model::item_id::ItemId;
461    use std::collections::BTreeMap;
462
463    fn make_event(item_id: &str, ts: i64, hash_suffix: &str) -> Event {
464        Event {
465            wall_ts_us: ts,
466            agent: "test-agent".to_string(),
467            itc: "0:0".to_string(),
468            parents: vec![],
469            event_type: EventType::Create,
470            item_id: ItemId::new_unchecked(item_id),
471            data: EventData::Create(CreateData {
472                title: format!("Item {item_id}"),
473                kind: Kind::Task,
474                size: None,
475                urgency: Urgency::Default,
476                labels: vec![],
477                parent: None,
478                causation: None,
479                description: None,
480                extra: BTreeMap::new(),
481            }),
482            event_hash: format!("blake3:{item_id}_{ts}_{hash_suffix}"),
483        }
484    }
485
486    #[test]
487    fn empty_tree() {
488        let tree = ProllyTree::build(&[]);
489        assert_eq!(tree.event_count, 0);
490        assert!(tree.event_hashes().is_empty());
491    }
492
493    #[test]
494    fn single_event() {
495        let events = vec![make_event("item-1", 1000, "a")];
496        let tree = ProllyTree::build(&events);
497        assert_eq!(tree.event_count, 1);
498        assert_eq!(tree.event_hashes().len(), 1);
499    }
500
501    #[test]
502    fn deterministic_root_hash_same_order() {
503        let events = vec![
504            make_event("a", 1, "x"),
505            make_event("b", 2, "y"),
506            make_event("c", 3, "z"),
507        ];
508        let t1 = ProllyTree::build(&events);
509        let t2 = ProllyTree::build(&events);
510        assert_eq!(t1.root.hash(), t2.root.hash());
511    }
512
513    #[test]
514    fn deterministic_root_hash_different_insertion_order() {
515        let e1 = make_event("a", 1, "x");
516        let e2 = make_event("b", 2, "y");
517        let e3 = make_event("c", 3, "z");
518
519        let t1 = ProllyTree::build(&[e1.clone(), e2.clone(), e3.clone()]);
520        let t2 = ProllyTree::build(&[e3.clone(), e1.clone(), e2.clone()]);
521        let t3 = ProllyTree::build(&[e2, e3, e1]);
522
523        assert_eq!(t1.root.hash(), t2.root.hash());
524        assert_eq!(t2.root.hash(), t3.root.hash());
525    }
526
527    #[test]
528    fn diff_identical_trees_is_empty() {
529        let events = vec![make_event("a", 1, "x"), make_event("b", 2, "y")];
530        let t1 = ProllyTree::build(&events);
531        let t2 = ProllyTree::build(&events);
532        assert!(t1.diff(&t2).is_empty());
533    }
534
535    #[test]
536    fn diff_finds_new_events() {
537        let shared = vec![make_event("a", 1, "x"), make_event("b", 2, "y")];
538        let mut extended = shared.clone();
539        extended.push(make_event("c", 3, "z"));
540
541        let t_local = ProllyTree::build(&shared);
542        let t_remote = ProllyTree::build(&extended);
543
544        let missing = t_local.diff(&t_remote);
545        assert!(missing.contains(&"blake3:c_3_z".to_string()));
546    }
547
548    #[test]
549    fn diff_empty_vs_populated() {
550        let empty = ProllyTree::build(&[]);
551        let populated = ProllyTree::build(&[make_event("a", 1, "x"), make_event("b", 2, "y")]);
552
553        let missing = empty.diff(&populated);
554        assert_eq!(missing.len(), 2);
555    }
556
557    #[test]
558    fn serialization_roundtrip() {
559        let events = vec![
560            make_event("a", 1, "x"),
561            make_event("b", 2, "y"),
562            make_event("c", 3, "z"),
563        ];
564        let tree = ProllyTree::build(&events);
565        let bytes = tree.to_bytes();
566        let restored = ProllyTree::from_bytes(&bytes).expect("deserialize");
567        assert_eq!(tree.root.hash(), restored.root.hash());
568        assert_eq!(tree.event_count, restored.event_count);
569    }
570
571    #[test]
572    fn many_events_produce_multiple_chunks() {
573        // 200 events should produce multiple leaf chunks
574        let events: Vec<Event> = (0..200)
575            .map(|i| make_event(&format!("item-{i:04}"), i as i64, &format!("h{i}")))
576            .collect();
577        let tree = ProllyTree::build(&events);
578        assert_eq!(tree.event_count, 200);
579        assert_eq!(tree.event_hashes().len(), 200);
580
581        // Verify determinism
582        let tree2 = ProllyTree::build(&events);
583        assert_eq!(tree.root.hash(), tree2.root.hash());
584    }
585
586    #[test]
587    fn diff_with_overlapping_events() {
588        // A has events 0..100, B has events 50..150
589        let all_events: Vec<Event> = (0..150)
590            .map(|i| make_event(&format!("item-{i:04}"), i as i64, &format!("h{i}")))
591            .collect();
592
593        let a_events = &all_events[0..100];
594        let b_events = &all_events[50..150];
595
596        let tree_a = ProllyTree::build(a_events);
597        let tree_b = ProllyTree::build(b_events);
598
599        // Events in B not in A should be 100..150
600        let missing = tree_a.diff(&tree_b);
601        let expected_missing: std::collections::HashSet<String> = (100..150)
602            .map(|i| format!("blake3:item-{i:04}_{i}_h{i}"))
603            .collect();
604
605        let missing_set: std::collections::HashSet<String> = missing.into_iter().collect();
606        for expected in &expected_missing {
607            assert!(
608                missing_set.contains(expected),
609                "Expected {expected} in diff but not found"
610            );
611        }
612    }
613
614    #[test]
615    fn diff_symmetric_finds_both_sides() {
616        let shared = vec![make_event("shared", 1, "s")];
617        let mut a = shared.clone();
618        a.push(make_event("only-a", 2, "a"));
619        let mut b = shared;
620        b.push(make_event("only-b", 3, "b"));
621
622        let tree_a = ProllyTree::build(&a);
623        let tree_b = ProllyTree::build(&b);
624
625        let a_missing = tree_a.diff(&tree_b); // events in B not in A
626        let b_missing = tree_b.diff(&tree_a); // events in A not in B
627
628        assert!(a_missing.contains(&"blake3:only-b_3_b".to_string()));
629        assert!(b_missing.contains(&"blake3:only-a_2_a".to_string()));
630    }
631
632    #[test]
633    fn same_item_different_timestamps() {
634        let events = vec![
635            make_event("item-1", 100, "v1"),
636            make_event("item-1", 200, "v2"),
637            make_event("item-1", 300, "v3"),
638        ];
639        let tree = ProllyTree::build(&events);
640        assert_eq!(tree.event_count, 3);
641        let hashes = tree.event_hashes();
642        // Should be sorted by timestamp
643        assert_eq!(hashes[0], "blake3:item-1_100_v1");
644        assert_eq!(hashes[1], "blake3:item-1_200_v2");
645        assert_eq!(hashes[2], "blake3:item-1_300_v3");
646    }
647
648    #[test]
649    fn hash_display() {
650        let h = hash_bytes(b"test");
651        let s = format!("{h}");
652        assert_eq!(s.len(), 64); // 32 bytes = 64 hex chars
653    }
654
655    #[test]
656    fn large_diff_performance() {
657        // Build two trees with 1000 events each, 900 shared
658        let shared: Vec<Event> = (0..900)
659            .map(|i| make_event(&format!("s{i:04}"), i, &format!("s{i}")))
660            .collect();
661
662        let mut a = shared.clone();
663        for i in 900..1000 {
664            a.push(make_event(&format!("a{i:04}"), i, &format!("a{i}")));
665        }
666
667        let mut b = shared;
668        for i in 900..1000 {
669            b.push(make_event(&format!("b{i:04}"), i, &format!("b{i}")));
670        }
671
672        let tree_a = ProllyTree::build(&a);
673        let tree_b = ProllyTree::build(&b);
674
675        let missing_from_b = tree_a.diff(&tree_b);
676        // Should find ~100 events from B not in A
677        assert!(
678            missing_from_b.len() >= 90,
679            "Expected ~100 missing, got {}",
680            missing_from_b.len()
681        );
682    }
683
684    #[test]
685    fn build_and_diff_stress() {
686        // Stress: 500 events, shuffle, build, diff with subset
687        let all: Vec<Event> = (0..500)
688            .map(|i| make_event(&format!("x{i:04}"), i, &format!("x{i}")))
689            .collect();
690
691        let subset = &all[0..400];
692
693        let tree_all = ProllyTree::build(&all);
694        let tree_sub = ProllyTree::build(subset);
695
696        let missing = tree_sub.diff(&tree_all);
697        // Events 400..500 should be missing from subset's perspective
698        assert!(
699            missing.len() >= 90,
700            "Expected ~100 missing, got {}",
701            missing.len()
702        );
703    }
704
705    #[test]
706    fn gear_table_is_deterministic() {
707        let t1 = gear_table();
708        let t2 = gear_table();
709        assert_eq!(t1, t2);
710    }
711
712    #[test]
713    fn chunk_boundaries_are_content_defined() {
714        // Inserting an event in the middle should only affect nearby chunks
715        let base: Vec<Event> = (0..100)
716            .map(|i| make_event(&format!("e{i:04}"), i * 10, &format!("h{i}")))
717            .collect();
718
719        let tree_base = ProllyTree::build(&base);
720
721        // Add one event in the middle
722        let mut modified = base;
723        modified.push(make_event("e0050x", 505, "hx"));
724
725        let tree_mod = ProllyTree::build(&modified);
726
727        // Root hashes should differ (new event added)
728        assert_ne!(tree_base.root.hash(), tree_mod.root.hash());
729
730        // But diff should find exactly the new event
731        let missing = tree_base.diff(&tree_mod);
732        assert!(missing.contains(&"blake3:e0050x_505_hx".to_string()));
733    }
734
735    #[test]
736    fn empty_diff_with_empty() {
737        let e1 = ProllyTree::build(&[]);
738        let e2 = ProllyTree::build(&[]);
739        assert!(e1.diff(&e2).is_empty());
740    }
741
742    #[test]
743    fn serialization_preserves_event_count() {
744        let events: Vec<Event> = (0..50)
745            .map(|i| make_event(&format!("item-{i}"), i, &format!("h{i}")))
746            .collect();
747        let tree = ProllyTree::build(&events);
748        let bytes = tree.to_bytes();
749        let restored = ProllyTree::from_bytes(&bytes).unwrap();
750        assert_eq!(restored.event_count, 50);
751        assert_eq!(restored.event_hashes().len(), 50);
752    }
753}