Skip to main content

ipfrs_semantic/
partial_sync.rs

1//! Partial HNSW sync with dirty region tracking.
2//!
3//! This module implements delta-based synchronisation of HNSW graph state
4//! between peers.  Rather than shipping the entire index on every gossip
5//! round, it tracks which nodes have changed since the last successful sync
6//! and ships only those deltas.
7//!
8//! # Architecture
9//!
10//! ```text
11//! ┌──────────────────────────────────────────────────────────────┐
12//! │  DirtyRegionTracker                                          │
13//! │   ┌─────────────────────────────────────────────────────┐   │
14//! │   │  dirty_nodes: HashMap<layer, HashSet<node_id>>      │   │
15//! │   │  generation:  AtomicU64                             │   │
16//! │   └─────────────────────────────────────────────────────┘   │
17//! │                        ▲  ▼                                  │
18//! │  PartialSyncManager                                          │
19//! │   ┌─────────────────────────────────────────────────────┐   │
20//! │   │  record_change / build_delta / apply_delta          │   │
21//! │   │  pending_deltas: Vec<EmbeddingDelta> (ack tracking) │   │
22//! │   └─────────────────────────────────────────────────────┘   │
23//! └──────────────────────────────────────────────────────────────┘
24//! ```
25
26use std::collections::{HashMap, HashSet};
27use std::sync::atomic::{AtomicU64, Ordering};
28use std::sync::Arc;
29
30use parking_lot::RwLock;
31
32// ──────────────────────────────────────────────────────────────────────────────
33// EmbeddingRegion
34// ──────────────────────────────────────────────────────────────────────────────
35
36/// A region of the HNSW graph identified by layer + node ID range.
37#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
38pub struct EmbeddingRegion {
39    /// HNSW layer (0 = bottom, highest connectivity).
40    pub layer: usize,
41    /// First node ID included in the region (inclusive).
42    pub node_start: u64,
43    /// First node ID **not** included in the region (exclusive).
44    pub node_end: u64,
45}
46
47impl EmbeddingRegion {
48    /// Create a new region for `layer` covering `[node_start, node_end)`.
49    pub fn new(layer: usize, node_start: u64, node_end: u64) -> Self {
50        Self {
51            layer,
52            node_start,
53            node_end,
54        }
55    }
56
57    /// Return `true` when `node_id` falls inside this region.
58    #[inline]
59    pub fn contains(&self, node_id: u64) -> bool {
60        node_id >= self.node_start && node_id < self.node_end
61    }
62
63    /// Number of node IDs spanned by this region.
64    #[inline]
65    pub fn size(&self) -> u64 {
66        self.node_end.saturating_sub(self.node_start)
67    }
68
69    /// Return `true` when the two regions share at least one node ID on the
70    /// same HNSW layer.
71    pub fn overlaps(&self, other: &EmbeddingRegion) -> bool {
72        if self.layer != other.layer {
73            return false;
74        }
75        // Two intervals [a, b) and [c, d) overlap iff a < d && c < b.
76        self.node_start < other.node_end && other.node_start < self.node_end
77    }
78}
79
80// ──────────────────────────────────────────────────────────────────────────────
81// EmbeddingDelta
82// ──────────────────────────────────────────────────────────────────────────────
83
84/// A delta snapshot of changed embeddings in a region.
85///
86/// `changed_ids` and `vectors` are parallel slices: `changed_ids[i]` is the
87/// node ID whose new vector is `vectors[i]`.
88#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
89pub struct EmbeddingDelta {
90    /// The region this delta covers.
91    pub region: EmbeddingRegion,
92    /// Node IDs that changed (parallel to `vectors`).
93    pub changed_ids: Vec<u64>,
94    /// Corresponding new vectors (parallel to `changed_ids`).
95    pub vectors: Vec<Vec<f32>>,
96    /// Monotonically increasing version counter.
97    pub generation: u64,
98    /// Originating peer identifier.
99    pub source_peer: String,
100    /// Wall-clock timestamp (milliseconds since Unix epoch) at creation.
101    pub created_at_ms: u64,
102}
103
104impl EmbeddingDelta {
105    /// Construct an empty delta for `region` at `generation`.
106    pub fn new(
107        region: EmbeddingRegion,
108        generation: u64,
109        source_peer: impl Into<String>,
110        now_ms: u64,
111    ) -> Self {
112        Self {
113            region,
114            changed_ids: Vec::new(),
115            vectors: Vec::new(),
116            generation,
117            source_peer: source_peer.into(),
118            created_at_ms: now_ms,
119        }
120    }
121
122    /// Append a single changed node.
123    pub fn add_change(&mut self, node_id: u64, vector: Vec<f32>) {
124        self.changed_ids.push(node_id);
125        self.vectors.push(vector);
126    }
127
128    /// Number of changed nodes recorded in this delta.
129    #[inline]
130    pub fn change_count(&self) -> usize {
131        self.changed_ids.len()
132    }
133
134    /// `true` when no changes have been recorded.
135    #[inline]
136    pub fn is_empty(&self) -> bool {
137        self.changed_ids.is_empty()
138    }
139
140    /// Rough byte estimate for gossip bandwidth accounting.
141    ///
142    /// Formula: `4 * Σ(dim_i) + 8 * n` where `n` is the number of changed
143    /// nodes and `dim_i` is the length of the i-th vector.
144    pub fn estimated_bytes(&self) -> usize {
145        let vector_bytes: usize = self.vectors.iter().map(|v| 4 * v.len()).sum();
146        let id_bytes = 8 * self.changed_ids.len();
147        vector_bytes + id_bytes
148    }
149}
150
151// ──────────────────────────────────────────────────────────────────────────────
152// DirtyRegionTracker
153// ──────────────────────────────────────────────────────────────────────────────
154
155/// Tracks which HNSW nodes are dirty (changed since the last successful sync).
156///
157/// Thread-safe: all public methods take `&self` and use interior mutability.
158pub struct DirtyRegionTracker {
159    /// Per-layer sets of dirty node IDs.
160    dirty_nodes: RwLock<HashMap<usize, HashSet<u64>>>,
161    /// Monotonically increasing sync-generation counter.
162    generation: AtomicU64,
163    /// Upper bound on how many dirty nodes we track before the ratio saturates
164    /// at 1.0.  Does *not* enforce a hard cap — it is a denominator for
165    /// `dirty_ratio`.
166    max_dirty_nodes: usize,
167}
168
169impl DirtyRegionTracker {
170    /// Create a new tracker.
171    ///
172    /// `max_dirty_nodes` is used only as the denominator when computing
173    /// `dirty_ratio`; it does not enforce a hard limit on dirty-set growth.
174    pub fn new(max_dirty_nodes: usize) -> Self {
175        Self {
176            dirty_nodes: RwLock::new(HashMap::new()),
177            generation: AtomicU64::new(0),
178            max_dirty_nodes,
179        }
180    }
181
182    /// Mark `node_id` as dirty in `layer`.
183    pub fn mark_dirty(&self, layer: usize, node_id: u64) {
184        let mut guard = self.dirty_nodes.write();
185        guard.entry(layer).or_default().insert(node_id);
186    }
187
188    /// Mark all nodes in `node_ids` as dirty in `layer`.
189    pub fn mark_dirty_batch(&self, layer: usize, node_ids: &[u64]) {
190        if node_ids.is_empty() {
191            return;
192        }
193        let mut guard = self.dirty_nodes.write();
194        let set = guard.entry(layer).or_default();
195        for &id in node_ids {
196            set.insert(id);
197        }
198    }
199
200    /// Remove all dirty entries whose layer and node ID fall inside `region`.
201    ///
202    /// Returns the number of entries removed.
203    pub fn clear_region(&self, region: &EmbeddingRegion) -> usize {
204        let mut guard = self.dirty_nodes.write();
205        let set = match guard.get_mut(&region.layer) {
206            Some(s) => s,
207            None => return 0,
208        };
209        let before = set.len();
210        set.retain(|&id| !region.contains(id));
211        let removed = before - set.len();
212        // Tidy up empty sets so dirty_layers() stays accurate.
213        if set.is_empty() {
214            guard.remove(&region.layer);
215        }
216        removed
217    }
218
219    /// Return all dirty node IDs whose layer and node ID fall inside `region`.
220    pub fn dirty_in_region(&self, region: &EmbeddingRegion) -> Vec<u64> {
221        let guard = self.dirty_nodes.read();
222        match guard.get(&region.layer) {
223            Some(set) => set
224                .iter()
225                .filter(|&&id| region.contains(id))
226                .copied()
227                .collect(),
228            None => Vec::new(),
229        }
230    }
231
232    /// Total number of dirty nodes across all layers.
233    pub fn total_dirty(&self) -> usize {
234        self.dirty_nodes.read().values().map(HashSet::len).sum()
235    }
236
237    /// Fraction of `max_dirty_nodes` that are currently dirty.
238    ///
239    /// Clamped to `[0.0, 1.0]`.
240    pub fn dirty_ratio(&self) -> f64 {
241        if self.max_dirty_nodes == 0 {
242            return 0.0;
243        }
244        let ratio = self.total_dirty() as f64 / self.max_dirty_nodes as f64;
245        ratio.min(1.0)
246    }
247
248    /// `true` when at least one node is dirty.
249    pub fn has_dirty(&self) -> bool {
250        self.dirty_nodes.read().values().any(|s| !s.is_empty())
251    }
252
253    /// Current generation counter value.
254    pub fn generation(&self) -> u64 {
255        self.generation.load(Ordering::Acquire)
256    }
257
258    /// Atomically increment the generation counter and return the new value.
259    ///
260    /// Call this after a sync round completes to invalidate stale deltas.
261    pub fn advance_generation(&self) -> u64 {
262        self.generation.fetch_add(1, Ordering::AcqRel) + 1
263    }
264
265    /// Layers that have at least one dirty node, in ascending order.
266    pub fn dirty_layers(&self) -> Vec<usize> {
267        let guard = self.dirty_nodes.read();
268        let mut layers: Vec<usize> = guard
269            .iter()
270            .filter(|(_, s)| !s.is_empty())
271            .map(|(&l, _)| l)
272            .collect();
273        layers.sort_unstable();
274        layers
275    }
276}
277
278// ──────────────────────────────────────────────────────────────────────────────
279// PartialSyncManager
280// ──────────────────────────────────────────────────────────────────────────────
281
282/// Manages partial sync of HNSW graph changes between peers.
283///
284/// Wraps a [`DirtyRegionTracker`] and adds outbound delta bookkeeping so the
285/// caller can detect when a peer has not yet acknowledged a delta.
286pub struct PartialSyncManager {
287    tracker: Arc<DirtyRegionTracker>,
288    /// Deltas sent to peers that have not yet been acknowledged.
289    pending_deltas: RwLock<Vec<EmbeddingDelta>>,
290    /// Maximum number of unacknowledged deltas we keep.
291    max_pending: usize,
292    /// Identifier for the local peer (used as `source_peer` in new deltas).
293    local_peer_id: String,
294}
295
296impl PartialSyncManager {
297    /// Create a new manager.
298    ///
299    /// - `max_dirty_nodes`: denominator for `dirty_ratio` (forwarded to the
300    ///   inner [`DirtyRegionTracker`]).
301    /// - `max_pending`: maximum number of un-acked deltas held in memory.
302    /// - `local_peer_id`: identifier stamped onto outbound deltas.
303    pub fn new(
304        max_dirty_nodes: usize,
305        max_pending: usize,
306        local_peer_id: impl Into<String>,
307    ) -> Self {
308        Self {
309            tracker: Arc::new(DirtyRegionTracker::new(max_dirty_nodes)),
310            pending_deltas: RwLock::new(Vec::new()),
311            max_pending,
312            local_peer_id: local_peer_id.into(),
313        }
314    }
315
316    /// Record that the vector for `node_id` in `layer` has changed.
317    pub fn record_change(&self, layer: usize, node_id: u64) {
318        self.tracker.mark_dirty(layer, node_id);
319    }
320
321    /// Build a delta containing all dirty nodes that fall inside `region`.
322    ///
323    /// `get_vector` is called for each dirty node ID to retrieve its current
324    /// embedding.  If `get_vector` returns `None` for a node the node is
325    /// skipped (it may have been deleted).
326    pub fn build_delta<F>(
327        &self,
328        region: &EmbeddingRegion,
329        get_vector: F,
330        now_ms: u64,
331    ) -> EmbeddingDelta
332    where
333        F: Fn(u64) -> Option<Vec<f32>>,
334    {
335        let generation = self.tracker.generation();
336        let mut delta = EmbeddingDelta::new(
337            region.clone(),
338            generation,
339            self.local_peer_id.clone(),
340            now_ms,
341        );
342
343        let dirty_ids = self.tracker.dirty_in_region(region);
344        for node_id in dirty_ids {
345            if let Some(vec) = get_vector(node_id) {
346                delta.add_change(node_id, vec);
347            }
348        }
349        delta
350    }
351
352    /// Build one delta per dirty layer, covering all dirty nodes in that layer.
353    ///
354    /// `get_vector` receives `(layer, node_id)` and should return the current
355    /// embedding for that node, or `None` if the node no longer exists.
356    ///
357    /// Layers with no dirty nodes are omitted from the result.
358    pub fn build_all_deltas<F>(&self, get_vector: F, now_ms: u64) -> Vec<EmbeddingDelta>
359    where
360        F: Fn(usize, u64) -> Option<Vec<f32>>,
361    {
362        let generation = self.tracker.generation();
363
364        // Snapshot dirty state under the read lock.
365        let layer_dirty: Vec<(usize, Vec<u64>)> = {
366            let guard = self.tracker.dirty_nodes.read();
367            guard
368                .iter()
369                .filter(|(_, s)| !s.is_empty())
370                .map(|(&layer, ids)| (layer, ids.iter().copied().collect()))
371                .collect()
372        };
373
374        let mut deltas = Vec::with_capacity(layer_dirty.len());
375        for (layer, ids) in layer_dirty {
376            // Region that covers the entire layer (u64::MAX exclusive upper bound).
377            let region = EmbeddingRegion::new(layer, 0, u64::MAX);
378            let mut delta =
379                EmbeddingDelta::new(region, generation, self.local_peer_id.clone(), now_ms);
380            for node_id in ids {
381                if let Some(vec) = get_vector(layer, node_id) {
382                    delta.add_change(node_id, vec);
383                }
384            }
385            if !delta.is_empty() {
386                deltas.push(delta);
387            }
388        }
389        deltas
390    }
391
392    /// Apply an incoming delta from a peer.
393    ///
394    /// Clears the local dirty status for all nodes mentioned in the delta (the
395    /// peer has the authoritative version of those nodes).
396    pub fn apply_delta(&self, delta: &EmbeddingDelta) {
397        if delta.changed_ids.is_empty() {
398            return;
399        }
400        let region = &delta.region;
401        let mut guard = self.tracker.dirty_nodes.write();
402        if let Some(set) = guard.get_mut(&region.layer) {
403            for &node_id in &delta.changed_ids {
404                set.remove(&node_id);
405            }
406            if set.is_empty() {
407                guard.remove(&region.layer);
408            }
409        }
410    }
411
412    /// Push a delta onto the pending list (waiting for peer acknowledgement).
413    ///
414    /// Returns `false` when the pending list is already at capacity; the
415    /// caller should ack older deltas first.
416    pub fn push_pending(&self, delta: EmbeddingDelta) -> bool {
417        let mut guard = self.pending_deltas.write();
418        if guard.len() >= self.max_pending {
419            return false;
420        }
421        guard.push(delta);
422        true
423    }
424
425    /// Remove all pending deltas whose `generation` is ≤ `generation`.
426    ///
427    /// Returns the number of deltas removed.
428    pub fn ack_generation(&self, generation: u64) -> usize {
429        let mut guard = self.pending_deltas.write();
430        let before = guard.len();
431        guard.retain(|d| d.generation > generation);
432        before - guard.len()
433    }
434
435    /// Number of pending (un-acked) deltas.
436    pub fn pending_count(&self) -> usize {
437        self.pending_deltas.read().len()
438    }
439
440    /// Access the underlying [`DirtyRegionTracker`].
441    pub fn tracker(&self) -> &DirtyRegionTracker {
442        &self.tracker
443    }
444
445    /// Convenience wrapper: `dirty_ratio` of the inner tracker.
446    pub fn dirty_ratio(&self) -> f64 {
447        self.tracker.dirty_ratio()
448    }
449}
450
451// ──────────────────────────────────────────────────────────────────────────────
452// Tests
453// ──────────────────────────────────────────────────────────────────────────────
454
455#[cfg(test)]
456mod tests {
457    use super::*;
458
459    // ── EmbeddingRegion ───────────────────────────────────────────────────────
460
461    #[test]
462    fn test_region_contains() {
463        let r = EmbeddingRegion::new(0, 10, 20);
464        // Left boundary inclusive.
465        assert!(r.contains(10));
466        // Interior.
467        assert!(r.contains(15));
468        // Right boundary exclusive.
469        assert!(!r.contains(20));
470        // Below start.
471        assert!(!r.contains(9));
472        // Far above end.
473        assert!(!r.contains(100));
474    }
475
476    #[test]
477    fn test_region_overlaps() {
478        let a = EmbeddingRegion::new(0, 10, 20);
479        // Same region overlaps with itself.
480        assert!(a.overlaps(&a));
481        // Partial overlap from the right.
482        let b = EmbeddingRegion::new(0, 15, 25);
483        assert!(a.overlaps(&b));
484        assert!(b.overlaps(&a));
485        // Adjacent but non-overlapping.
486        let c = EmbeddingRegion::new(0, 20, 30);
487        assert!(!a.overlaps(&c));
488        // Different layers never overlap.
489        let d = EmbeddingRegion::new(1, 10, 20);
490        assert!(!a.overlaps(&d));
491        // Entirely contained.
492        let e = EmbeddingRegion::new(0, 12, 18);
493        assert!(a.overlaps(&e));
494        // Entirely disjoint (left).
495        let f = EmbeddingRegion::new(0, 0, 10);
496        assert!(!a.overlaps(&f));
497    }
498
499    #[test]
500    fn test_region_size() {
501        let r = EmbeddingRegion::new(0, 5, 15);
502        assert_eq!(r.size(), 10);
503        // Zero-size region.
504        let z = EmbeddingRegion::new(0, 7, 7);
505        assert_eq!(z.size(), 0);
506        // Saturating sub: node_end < node_start should give 0, not overflow.
507        let inv = EmbeddingRegion {
508            layer: 0,
509            node_start: 100,
510            node_end: 10,
511        };
512        assert_eq!(inv.size(), 0);
513    }
514
515    // ── EmbeddingDelta ────────────────────────────────────────────────────────
516
517    #[test]
518    fn test_embedding_delta_add_change() {
519        let region = EmbeddingRegion::new(0, 0, 100);
520        let mut delta = EmbeddingDelta::new(region, 1, "peer-a", 42_000);
521        assert!(delta.is_empty());
522        assert_eq!(delta.change_count(), 0);
523
524        delta.add_change(5, vec![1.0, 2.0, 3.0]);
525        delta.add_change(7, vec![4.0, 5.0]);
526        assert!(!delta.is_empty());
527        assert_eq!(delta.change_count(), 2);
528        assert_eq!(delta.changed_ids, vec![5, 7]);
529        assert_eq!(delta.vectors[0], vec![1.0, 2.0, 3.0]);
530    }
531
532    #[test]
533    fn test_embedding_delta_estimated_bytes() {
534        let region = EmbeddingRegion::new(0, 0, 100);
535        let mut delta = EmbeddingDelta::new(region, 1, "peer-a", 0);
536        // No changes → 0 bytes.
537        assert_eq!(delta.estimated_bytes(), 0);
538
539        // One node with a 4-element vector:  4*4 + 8*1 = 24
540        delta.add_change(1, vec![1.0; 4]);
541        assert_eq!(delta.estimated_bytes(), 24);
542
543        // Second node with 8-element vector: 24 + 4*8 + 8 = 64
544        delta.add_change(2, vec![0.5; 8]);
545        assert_eq!(delta.estimated_bytes(), 64);
546    }
547
548    // ── DirtyRegionTracker ────────────────────────────────────────────────────
549
550    #[test]
551    fn test_dirty_tracker_mark_and_query() {
552        let t = DirtyRegionTracker::new(100);
553        assert!(!t.has_dirty());
554        assert_eq!(t.total_dirty(), 0);
555
556        t.mark_dirty(0, 42);
557        t.mark_dirty(0, 43);
558        t.mark_dirty(1, 10);
559
560        assert!(t.has_dirty());
561        assert_eq!(t.total_dirty(), 3);
562
563        let region = EmbeddingRegion::new(0, 40, 50);
564        let mut ids = t.dirty_in_region(&region);
565        ids.sort_unstable();
566        assert_eq!(ids, vec![42, 43]);
567
568        // Layer 1 not visible in a layer-0 region.
569        let region1 = EmbeddingRegion::new(1, 0, 100);
570        let mut ids1 = t.dirty_in_region(&region1);
571        ids1.sort_unstable();
572        assert_eq!(ids1, vec![10]);
573    }
574
575    #[test]
576    fn test_dirty_tracker_mark_batch() {
577        let t = DirtyRegionTracker::new(100);
578        t.mark_dirty_batch(0, &[1, 2, 3, 4, 5]);
579        assert_eq!(t.total_dirty(), 5);
580        // Inserting the same IDs again should not grow the set.
581        t.mark_dirty_batch(0, &[1, 2, 3]);
582        assert_eq!(t.total_dirty(), 5);
583        // Empty slice is a no-op.
584        t.mark_dirty_batch(0, &[]);
585        assert_eq!(t.total_dirty(), 5);
586    }
587
588    #[test]
589    fn test_dirty_tracker_clear_region() {
590        let t = DirtyRegionTracker::new(100);
591        t.mark_dirty_batch(0, &[10, 11, 12, 50]);
592        t.mark_dirty(1, 10);
593
594        let region = EmbeddingRegion::new(0, 10, 13);
595        let cleared = t.clear_region(&region);
596        assert_eq!(cleared, 3);
597
598        // Node 50 in layer 0 remains.
599        let remaining = t.dirty_in_region(&EmbeddingRegion::new(0, 0, 100));
600        assert_eq!(remaining, vec![50]);
601        // Layer 1 untouched.
602        assert_eq!(
603            t.dirty_in_region(&EmbeddingRegion::new(1, 0, 100)),
604            vec![10]
605        );
606    }
607
608    #[test]
609    fn test_dirty_tracker_dirty_ratio() {
610        let t = DirtyRegionTracker::new(10);
611        assert_eq!(t.dirty_ratio(), 0.0);
612        t.mark_dirty_batch(0, &[0, 1, 2, 3, 4]);
613        assert!((t.dirty_ratio() - 0.5).abs() < 1e-9);
614        // Exceeding max should clamp at 1.0.
615        t.mark_dirty_batch(0, &[5, 6, 7, 8, 9, 10, 11]);
616        assert_eq!(t.dirty_ratio(), 1.0);
617    }
618
619    #[test]
620    fn test_dirty_tracker_advance_generation() {
621        let t = DirtyRegionTracker::new(100);
622        assert_eq!(t.generation(), 0);
623        let g1 = t.advance_generation();
624        assert_eq!(g1, 1);
625        assert_eq!(t.generation(), 1);
626        let g2 = t.advance_generation();
627        assert_eq!(g2, 2);
628    }
629
630    #[test]
631    fn test_dirty_tracker_dirty_layers() {
632        let t = DirtyRegionTracker::new(100);
633        assert!(t.dirty_layers().is_empty());
634        t.mark_dirty(2, 5);
635        t.mark_dirty(0, 1);
636        t.mark_dirty(1, 3);
637        assert_eq!(t.dirty_layers(), vec![0, 1, 2]);
638
639        // Clearing all nodes from layer 1 removes it from dirty_layers.
640        t.clear_region(&EmbeddingRegion::new(1, 0, u64::MAX));
641        assert_eq!(t.dirty_layers(), vec![0, 2]);
642    }
643
644    // ── PartialSyncManager ────────────────────────────────────────────────────
645
646    #[test]
647    fn test_partial_sync_record_change() {
648        let mgr = PartialSyncManager::new(100, 16, "local");
649        mgr.record_change(0, 7);
650        mgr.record_change(0, 8);
651        mgr.record_change(1, 3);
652        assert_eq!(mgr.tracker().total_dirty(), 3);
653        assert!(mgr.tracker().has_dirty());
654    }
655
656    #[test]
657    fn test_partial_sync_build_delta() {
658        let mgr = PartialSyncManager::new(100, 16, "local");
659        mgr.record_change(0, 10);
660        mgr.record_change(0, 20);
661        mgr.record_change(0, 30);
662
663        let region = EmbeddingRegion::new(0, 0, 100);
664        let delta = mgr.build_delta(&region, |node_id| Some(vec![node_id as f32; 4]), 1000);
665
666        assert_eq!(delta.change_count(), 3);
667        assert_eq!(delta.source_peer, "local");
668        assert_eq!(delta.created_at_ms, 1000);
669        // All three node IDs should be present.
670        let mut ids = delta.changed_ids.clone();
671        ids.sort_unstable();
672        assert_eq!(ids, vec![10, 20, 30]);
673    }
674
675    #[test]
676    fn test_partial_sync_build_all_deltas() {
677        let mgr = PartialSyncManager::new(100, 16, "local");
678        // Dirty in layer 0.
679        mgr.record_change(0, 1);
680        mgr.record_change(0, 2);
681        // Dirty in layer 1.
682        mgr.record_change(1, 99);
683
684        let deltas = mgr.build_all_deltas(|_layer, node_id| Some(vec![node_id as f32; 3]), 2000);
685
686        // One delta per dirty layer.
687        assert_eq!(deltas.len(), 2);
688
689        // Find each layer's delta and verify counts.
690        let delta_l0 = deltas
691            .iter()
692            .find(|d| d.region.layer == 0)
693            .expect("layer 0 delta");
694        let delta_l1 = deltas
695            .iter()
696            .find(|d| d.region.layer == 1)
697            .expect("layer 1 delta");
698        assert_eq!(delta_l0.change_count(), 2);
699        assert_eq!(delta_l1.change_count(), 1);
700    }
701
702    #[test]
703    fn test_partial_sync_apply_delta_clears_dirty() {
704        let mgr = PartialSyncManager::new(100, 16, "local");
705        mgr.record_change(0, 5);
706        mgr.record_change(0, 6);
707        mgr.record_change(0, 7);
708
709        // Build and apply an incoming delta that covers nodes 5 and 6.
710        let region = EmbeddingRegion::new(0, 0, 100);
711        let mut incoming = EmbeddingDelta::new(region, 1, "remote-peer", 999);
712        incoming.add_change(5, vec![1.0; 3]);
713        incoming.add_change(6, vec![2.0; 3]);
714
715        mgr.apply_delta(&incoming);
716
717        // Node 7 should still be dirty; 5 and 6 cleared.
718        assert_eq!(mgr.tracker().total_dirty(), 1);
719        let remaining = mgr
720            .tracker()
721            .dirty_in_region(&EmbeddingRegion::new(0, 0, 100));
722        assert_eq!(remaining, vec![7]);
723    }
724
725    #[test]
726    fn test_partial_sync_ack_generation() {
727        let mgr = PartialSyncManager::new(100, 16, "local");
728        let region = EmbeddingRegion::new(0, 0, 100);
729
730        // Push deltas at generations 1, 2, 3, 4.
731        for gen in 1u64..=4 {
732            let delta = EmbeddingDelta::new(region.clone(), gen, "local", gen * 1000);
733            assert!(mgr.push_pending(delta));
734        }
735        assert_eq!(mgr.pending_count(), 4);
736
737        // Ack up to generation 2 → removes deltas with gen ≤ 2.
738        let removed = mgr.ack_generation(2);
739        assert_eq!(removed, 2);
740        assert_eq!(mgr.pending_count(), 2);
741
742        // Ack everything.
743        let removed2 = mgr.ack_generation(u64::MAX);
744        assert_eq!(removed2, 2);
745        assert_eq!(mgr.pending_count(), 0);
746    }
747}