Skip to main content

ipfrs_semantic/
index_rebalancer.rs

1//! Embedding Index Rebalancer
2//!
3//! Plans and tracks the execution of a rebalancing operation across HNSW index
4//! shards, moving vectors from overloaded shards to underloaded ones.
5//!
6//! # Overview
7//!
8//! The rebalancer operates in three phases:
9//!
10//! 1. **Analysis** – [`EmbeddingIndexRebalancer::plan_rebalance`] inspects the
11//!    load factor of each shard and identifies those that are either overloaded
12//!    (above [`RebalancerConfig::overload_threshold`]) or underloaded (below
13//!    [`RebalancerConfig::underload_threshold`]).
14//!
15//! 2. **Planning** – For every overloaded shard the planner pairs it with the
16//!    underloaded shard that has the most available capacity, creates one or more
17//!    [`MoveTask`]s (each bounded by [`RebalancerConfig::max_moves_per_task`]),
18//!    and assembles them into a [`RebalancePlan`].
19//!
20//! 3. **Tracking** – Callers drive the plan forward by calling
21//!    [`EmbeddingIndexRebalancer::update_task_status`] as each task progresses
22//!    through [`MoveStatus::Pending`] → [`MoveStatus::InProgress`] →
23//!    [`MoveStatus::Completed`] (or [`MoveStatus::Failed`]).
24
25// ---------------------------------------------------------------------------
26// MoveStatus
27// ---------------------------------------------------------------------------
28
29/// Lifecycle state of a single [`MoveTask`].
30#[derive(Clone, Debug, PartialEq)]
31pub enum MoveStatus {
32    /// The task has been created but not yet started.
33    Pending,
34    /// The task is currently being executed.
35    InProgress {
36        /// Unix timestamp (seconds) at which execution began.
37        started_at_secs: u64,
38    },
39    /// The task finished successfully.
40    Completed {
41        /// Unix timestamp (seconds) at which execution finished.
42        finished_at_secs: u64,
43    },
44    /// The task failed.
45    Failed {
46        /// Human-readable reason for the failure.
47        reason: String,
48    },
49}
50
51// ---------------------------------------------------------------------------
52// ShardLoad
53// ---------------------------------------------------------------------------
54
55/// Snapshot of the load on a single HNSW index shard.
56#[derive(Debug, Clone)]
57pub struct ShardLoad {
58    /// Unique identifier for the shard.
59    pub shard_id: String,
60    /// Number of vectors currently stored in the shard.
61    pub vector_count: u64,
62    /// Maximum number of vectors the shard can hold.
63    pub capacity: u64,
64}
65
66impl ShardLoad {
67    /// Returns the fraction of capacity that is currently in use.
68    ///
69    /// The denominator is clamped to at least 1 so that zero-capacity shards
70    /// return 0.0 rather than NaN.
71    pub fn load_factor(&self) -> f64 {
72        self.vector_count as f64 / self.capacity.max(1) as f64
73    }
74
75    /// Returns `true` when the shard's load factor exceeds `threshold`.
76    pub fn is_overloaded(&self, threshold: f64) -> bool {
77        self.load_factor() > threshold
78    }
79
80    /// Returns `true` when the shard's load factor is below `threshold`.
81    pub fn is_underloaded(&self, threshold: f64) -> bool {
82        self.load_factor() < threshold
83    }
84
85    /// Returns the number of additional vectors the shard can accept.
86    ///
87    /// Uses saturating subtraction so that over-capacity shards return 0.
88    pub fn available_capacity(&self) -> u64 {
89        self.capacity.saturating_sub(self.vector_count)
90    }
91}
92
93// ---------------------------------------------------------------------------
94// MoveTask
95// ---------------------------------------------------------------------------
96
97/// A single unit of rebalancing work: move `vector_count` vectors from one
98/// shard to another.
99#[derive(Debug, Clone)]
100pub struct MoveTask {
101    /// Monotonically increasing identifier assigned by the rebalancer.
102    pub task_id: u64,
103    /// Source shard from which vectors will be moved.
104    pub from_shard: String,
105    /// Destination shard to which vectors will be moved.
106    pub to_shard: String,
107    /// Number of vectors to be moved by this task.
108    pub vector_count: u64,
109    /// Current lifecycle state of the task.
110    pub status: MoveStatus,
111}
112
113// ---------------------------------------------------------------------------
114// RebalancePlan
115// ---------------------------------------------------------------------------
116
117/// A set of [`MoveTask`]s that collectively rebalance a cluster.
118#[derive(Debug, Clone)]
119pub struct RebalancePlan {
120    /// Ordered list of tasks in this plan.
121    pub tasks: Vec<MoveTask>,
122    /// Total number of vectors that will be moved if the plan completes fully.
123    pub estimated_moves: u64,
124}
125
126impl RebalancePlan {
127    /// Returns the number of tasks that are still [`MoveStatus::Pending`].
128    pub fn pending_tasks(&self) -> usize {
129        self.tasks
130            .iter()
131            .filter(|t| t.status == MoveStatus::Pending)
132            .count()
133    }
134
135    /// Returns the number of tasks that have reached [`MoveStatus::Completed`].
136    pub fn completed_tasks(&self) -> usize {
137        self.tasks
138            .iter()
139            .filter(|t| matches!(t.status, MoveStatus::Completed { .. }))
140            .count()
141    }
142
143    /// Returns `true` when every task is either [`MoveStatus::Completed`] or
144    /// [`MoveStatus::Failed`] (i.e. no more work remains).
145    pub fn is_complete(&self) -> bool {
146        self.tasks.iter().all(|t| {
147            matches!(
148                t.status,
149                MoveStatus::Completed { .. } | MoveStatus::Failed { .. }
150            )
151        })
152    }
153}
154
155// ---------------------------------------------------------------------------
156// RebalancerConfig
157// ---------------------------------------------------------------------------
158
159/// Tuning knobs for the [`EmbeddingIndexRebalancer`].
160#[derive(Debug, Clone)]
161pub struct RebalancerConfig {
162    /// Load factor above which a shard is considered overloaded (default: 0.85).
163    pub overload_threshold: f64,
164    /// Load factor below which a shard is considered underloaded (default: 0.40).
165    pub underload_threshold: f64,
166    /// Upper bound on vectors that a single [`MoveTask`] may move (default:
167    /// 10 000).  Larger excesses are split into multiple tasks.
168    pub max_moves_per_task: u64,
169}
170
171impl Default for RebalancerConfig {
172    fn default() -> Self {
173        Self {
174            overload_threshold: 0.85,
175            underload_threshold: 0.40,
176            max_moves_per_task: 10_000,
177        }
178    }
179}
180
181// ---------------------------------------------------------------------------
182// EmbeddingIndexRebalancer
183// ---------------------------------------------------------------------------
184
185/// Plans and tracks rebalancing operations across HNSW index shards.
186///
187/// # Example
188///
189/// ```rust
190/// use ipfrs_semantic::index_rebalancer::{
191///     EmbeddingIndexRebalancer, RebalancerConfig, ShardLoad,
192/// };
193///
194/// let mut rebalancer = EmbeddingIndexRebalancer::new(RebalancerConfig::default());
195///
196/// let shards = vec![
197///     ShardLoad { shard_id: "s0".into(), vector_count: 9_000, capacity: 10_000 },
198///     ShardLoad { shard_id: "s1".into(), vector_count: 1_000, capacity: 10_000 },
199/// ];
200///
201/// let plan = rebalancer.plan_rebalance(&shards);
202/// assert!(!plan.tasks.is_empty());
203/// ```
204#[derive(Debug)]
205pub struct EmbeddingIndexRebalancer {
206    /// Configuration used when producing plans.
207    pub config: RebalancerConfig,
208    /// History of all plans produced by this rebalancer (newest last).
209    pub plans: Vec<RebalancePlan>,
210    /// The task ID that will be assigned to the next [`MoveTask`] created.
211    pub next_task_id: u64,
212}
213
214impl EmbeddingIndexRebalancer {
215    /// Creates a new rebalancer with the supplied configuration.
216    pub fn new(config: RebalancerConfig) -> Self {
217        Self {
218            config,
219            plans: Vec::new(),
220            next_task_id: 0,
221        }
222    }
223
224    /// Analyses `shards` and produces a [`RebalancePlan`] that moves vectors
225    /// from overloaded shards to underloaded ones.
226    ///
227    /// The plan is also appended to [`Self::plans`] for historical tracking.
228    ///
229    /// ## Algorithm
230    ///
231    /// For each overloaded shard the planner:
232    ///
233    /// 1. Computes the *excess* – the number of vectors above the underload
234    ///    threshold: `excess = (load_factor − underload_threshold) × capacity`.
235    /// 2. Sorts underloaded shards by available capacity descending and picks
236    ///    the one with the most room.
237    /// 3. Emits [`MoveTask`]s of at most `max_moves_per_task` vectors until
238    ///    the excess is exhausted.
239    pub fn plan_rebalance(&mut self, shards: &[ShardLoad]) -> RebalancePlan {
240        let overloaded: Vec<&ShardLoad> = shards
241            .iter()
242            .filter(|s| s.is_overloaded(self.config.overload_threshold))
243            .collect();
244
245        // Collect underloaded shards sorted by available capacity descending so
246        // that the most spacious shard is preferred.
247        let mut underloaded: Vec<ShardLoad> = shards
248            .iter()
249            .filter(|s| s.is_underloaded(self.config.underload_threshold))
250            .cloned()
251            .collect();
252        underloaded.sort_by_key(|b| std::cmp::Reverse(b.available_capacity()));
253
254        let mut tasks: Vec<MoveTask> = Vec::new();
255
256        'overloaded: for src in &overloaded {
257            // Number of vectors we want to move away from this shard.
258            let excess = {
259                let e = (src.load_factor() - self.config.underload_threshold) * src.capacity as f64;
260                e.ceil() as u64
261            };
262
263            let mut remaining = excess;
264
265            // Pair with underloaded shards in order of available capacity.
266            for dst in underloaded.iter_mut() {
267                if remaining == 0 {
268                    break;
269                }
270
271                let available = dst.available_capacity();
272                if available == 0 {
273                    continue;
274                }
275
276                // How many vectors will actually flow into this destination?
277                let to_move = remaining.min(available);
278                remaining = remaining.saturating_sub(to_move);
279
280                // Reduce the tracked available capacity so subsequent overloaded
281                // shards see an accurate picture.
282                dst.vector_count = dst.vector_count.saturating_add(to_move).min(dst.capacity);
283
284                // Split into tasks bounded by max_moves_per_task.
285                let cap = self.config.max_moves_per_task;
286                let mut left = to_move;
287                while left > 0 {
288                    let chunk = left.min(cap);
289                    left = left.saturating_sub(chunk);
290
291                    tasks.push(MoveTask {
292                        task_id: self.next_task_id,
293                        from_shard: src.shard_id.clone(),
294                        to_shard: dst.shard_id.clone(),
295                        vector_count: chunk,
296                        status: MoveStatus::Pending,
297                    });
298                    self.next_task_id += 1;
299                }
300
301                if remaining == 0 {
302                    continue 'overloaded;
303                }
304            }
305            // If remaining > 0 we ran out of underloaded destinations; that is
306            // acceptable – we emit as many tasks as we can.
307        }
308
309        let estimated_moves: u64 = tasks.iter().map(|t| t.vector_count).sum();
310
311        let plan = RebalancePlan {
312            tasks,
313            estimated_moves,
314        };
315
316        self.plans.push(plan.clone());
317        plan
318    }
319
320    /// Updates the status of the task identified by `task_id` across all
321    /// historical plans.
322    ///
323    /// Returns `true` when the task was found and updated, `false` otherwise.
324    pub fn update_task_status(&mut self, task_id: u64, status: MoveStatus) -> bool {
325        for plan in self.plans.iter_mut() {
326            for task in plan.tasks.iter_mut() {
327                if task.task_id == task_id {
328                    task.status = status;
329                    return true;
330                }
331            }
332        }
333        false
334    }
335
336    /// Returns a reference to the most recent plan if it has not yet completed.
337    pub fn active_plan(&self) -> Option<&RebalancePlan> {
338        self.plans
339            .last()
340            .and_then(|p| if p.is_complete() { None } else { Some(p) })
341    }
342
343    /// Returns the number of plans that have reached a terminal state (i.e.
344    /// all tasks are [`MoveStatus::Completed`] or [`MoveStatus::Failed`]).
345    pub fn completed_plans(&self) -> usize {
346        self.plans.iter().filter(|p| p.is_complete()).count()
347    }
348
349    /// Returns aggregate statistics: `(total_tasks, total_moves_planned)`.
350    pub fn stats(&self) -> (usize, u64) {
351        let total_tasks: usize = self.plans.iter().map(|p| p.tasks.len()).sum();
352        let total_moves: u64 = self.plans.iter().map(|p| p.estimated_moves).sum();
353        (total_tasks, total_moves)
354    }
355}
356
357// ---------------------------------------------------------------------------
358// Tests
359// ---------------------------------------------------------------------------
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364
365    fn default_rebalancer() -> EmbeddingIndexRebalancer {
366        EmbeddingIndexRebalancer::new(RebalancerConfig::default())
367    }
368
369    // 1. new() initialises with the supplied config
370    #[test]
371    fn test_new_with_config() {
372        let cfg = RebalancerConfig {
373            overload_threshold: 0.9,
374            underload_threshold: 0.3,
375            max_moves_per_task: 5_000,
376        };
377        let rb = EmbeddingIndexRebalancer::new(cfg.clone());
378        assert_eq!(rb.config.overload_threshold, cfg.overload_threshold);
379        assert_eq!(rb.config.underload_threshold, cfg.underload_threshold);
380        assert_eq!(rb.config.max_moves_per_task, cfg.max_moves_per_task);
381        assert!(rb.plans.is_empty());
382        assert_eq!(rb.next_task_id, 0);
383    }
384
385    // 2. load_factor returns vector_count / capacity
386    #[test]
387    fn test_load_factor() {
388        let s = ShardLoad {
389            shard_id: "s0".into(),
390            vector_count: 500,
391            capacity: 1_000,
392        };
393        assert!((s.load_factor() - 0.5).abs() < f64::EPSILON);
394    }
395
396    // 3. load_factor with zero capacity does not divide by zero
397    #[test]
398    fn test_load_factor_zero_capacity() {
399        let s = ShardLoad {
400            shard_id: "s0".into(),
401            vector_count: 0,
402            capacity: 0,
403        };
404        assert_eq!(s.load_factor(), 0.0);
405    }
406
407    // 4. is_overloaded
408    #[test]
409    fn test_is_overloaded() {
410        let s = ShardLoad {
411            shard_id: "s0".into(),
412            vector_count: 900,
413            capacity: 1_000,
414        };
415        assert!(s.is_overloaded(0.85));
416        assert!(!s.is_overloaded(0.95));
417    }
418
419    // 5. is_underloaded
420    #[test]
421    fn test_is_underloaded() {
422        let s = ShardLoad {
423            shard_id: "s0".into(),
424            vector_count: 300,
425            capacity: 1_000,
426        };
427        assert!(s.is_underloaded(0.40));
428        assert!(!s.is_underloaded(0.20));
429    }
430
431    // 6. available_capacity uses saturating_sub
432    #[test]
433    fn test_available_capacity_saturating() {
434        let s = ShardLoad {
435            shard_id: "s0".into(),
436            vector_count: 1_200,
437            capacity: 1_000,
438        };
439        assert_eq!(s.available_capacity(), 0);
440
441        let s2 = ShardLoad {
442            shard_id: "s1".into(),
443            vector_count: 400,
444            capacity: 1_000,
445        };
446        assert_eq!(s2.available_capacity(), 600);
447    }
448
449    // 7. plan_rebalance: no overloaded or underloaded shards → empty task list
450    #[test]
451    fn test_plan_no_action_needed() {
452        let mut rb = default_rebalancer();
453        let shards = vec![
454            ShardLoad {
455                shard_id: "s0".into(),
456                vector_count: 600,
457                capacity: 1_000,
458            },
459            ShardLoad {
460                shard_id: "s1".into(),
461                vector_count: 650,
462                capacity: 1_000,
463            },
464        ];
465        let plan = rb.plan_rebalance(&shards);
466        assert!(plan.tasks.is_empty());
467        assert_eq!(plan.estimated_moves, 0);
468    }
469
470    // 8. plan_rebalance: one overloaded + one underloaded → at least one task
471    #[test]
472    fn test_plan_one_overloaded_one_underloaded() {
473        let mut rb = default_rebalancer();
474        let shards = vec![
475            ShardLoad {
476                shard_id: "over".into(),
477                vector_count: 9_000,
478                capacity: 10_000,
479            }, // 0.9 > 0.85
480            ShardLoad {
481                shard_id: "under".into(),
482                vector_count: 1_000,
483                capacity: 10_000,
484            }, // 0.1 < 0.40
485        ];
486        let plan = rb.plan_rebalance(&shards);
487        assert!(!plan.tasks.is_empty());
488        assert_eq!(plan.tasks[0].from_shard, "over");
489        assert_eq!(plan.tasks[0].to_shard, "under");
490        assert!(plan.estimated_moves > 0);
491    }
492
493    // 9. plan_rebalance: excess > max_moves_per_task → multiple tasks
494    #[test]
495    fn test_plan_excess_splits_into_multiple_tasks() {
496        let cfg = RebalancerConfig {
497            overload_threshold: 0.5,
498            underload_threshold: 0.1,
499            max_moves_per_task: 1_000,
500        };
501        let mut rb = EmbeddingIndexRebalancer::new(cfg);
502        let shards = vec![
503            ShardLoad {
504                shard_id: "src".into(),
505                vector_count: 8_000,
506                capacity: 10_000,
507            }, // 0.8 > 0.5
508            ShardLoad {
509                shard_id: "dst".into(),
510                vector_count: 500,
511                capacity: 10_000,
512            }, // 0.05 < 0.1
513        ];
514        let plan = rb.plan_rebalance(&shards);
515        // excess ≈ (0.8 − 0.1) × 10 000 = 7 000 vectors → 7 tasks of 1 000
516        assert!(plan.tasks.len() >= 2, "expected multiple tasks");
517        for t in &plan.tasks {
518            assert!(t.vector_count <= 1_000, "task exceeds max_moves_per_task");
519        }
520    }
521
522    // 10. task_ids are monotonically increasing across plans
523    #[test]
524    fn test_task_ids_monotonically_increasing() {
525        let mut rb = default_rebalancer();
526        let shards = vec![
527            ShardLoad {
528                shard_id: "over".into(),
529                vector_count: 9_000,
530                capacity: 10_000,
531            },
532            ShardLoad {
533                shard_id: "under".into(),
534                vector_count: 1_000,
535                capacity: 10_000,
536            },
537        ];
538        let plan1 = rb.plan_rebalance(&shards);
539        let plan2 = rb.plan_rebalance(&shards);
540
541        let ids1: Vec<u64> = plan1.tasks.iter().map(|t| t.task_id).collect();
542        let ids2: Vec<u64> = plan2.tasks.iter().map(|t| t.task_id).collect();
543
544        // IDs within each plan are strictly increasing
545        for w in ids1.windows(2) {
546            assert!(w[0] < w[1]);
547        }
548        for w in ids2.windows(2) {
549            assert!(w[0] < w[1]);
550        }
551
552        // The second plan's first ID is strictly greater than the first plan's
553        // last ID (global monotonicity).
554        if let (Some(&last1), Some(&first2)) = (ids1.last(), ids2.first()) {
555            assert!(first2 > last1);
556        }
557    }
558
559    // 11. estimated_moves equals sum of vector_count over all tasks
560    #[test]
561    fn test_estimated_moves_correct() {
562        let mut rb = default_rebalancer();
563        let shards = vec![
564            ShardLoad {
565                shard_id: "over".into(),
566                vector_count: 9_000,
567                capacity: 10_000,
568            },
569            ShardLoad {
570                shard_id: "under".into(),
571                vector_count: 1_000,
572                capacity: 10_000,
573            },
574        ];
575        let plan = rb.plan_rebalance(&shards);
576        let sum: u64 = plan.tasks.iter().map(|t| t.vector_count).sum();
577        assert_eq!(plan.estimated_moves, sum);
578    }
579
580    // 12. plan is pushed to the history
581    #[test]
582    fn test_plan_history_pushed() {
583        let mut rb = default_rebalancer();
584        let shards = vec![ShardLoad {
585            shard_id: "s0".into(),
586            vector_count: 600,
587            capacity: 1_000,
588        }];
589        rb.plan_rebalance(&shards);
590        rb.plan_rebalance(&shards);
591        assert_eq!(rb.plans.len(), 2);
592    }
593
594    // 13. update_task_status: known task → returns true
595    #[test]
596    fn test_update_task_status_found() {
597        let mut rb = default_rebalancer();
598        let shards = vec![
599            ShardLoad {
600                shard_id: "over".into(),
601                vector_count: 9_000,
602                capacity: 10_000,
603            },
604            ShardLoad {
605                shard_id: "under".into(),
606                vector_count: 1_000,
607                capacity: 10_000,
608            },
609        ];
610        let plan = rb.plan_rebalance(&shards);
611        let tid = plan.tasks[0].task_id;
612        let found = rb.update_task_status(
613            tid,
614            MoveStatus::Completed {
615                finished_at_secs: 1_000,
616            },
617        );
618        assert!(found);
619        // Verify the change was actually applied.
620        let updated = rb
621            .plans
622            .iter()
623            .flat_map(|p| p.tasks.iter())
624            .find(|t| t.task_id == tid)
625            .expect("task must exist");
626        assert!(matches!(
627            updated.status,
628            MoveStatus::Completed {
629                finished_at_secs: 1_000
630            }
631        ));
632    }
633
634    // 14. update_task_status: unknown task → returns false
635    #[test]
636    fn test_update_task_status_not_found() {
637        let mut rb = default_rebalancer();
638        let found = rb.update_task_status(9999, MoveStatus::Pending);
639        assert!(!found);
640    }
641
642    // 15. is_complete: all tasks Completed → true
643    #[test]
644    fn test_is_complete_all_completed() {
645        let tasks = vec![
646            MoveTask {
647                task_id: 0,
648                from_shard: "a".into(),
649                to_shard: "b".into(),
650                vector_count: 100,
651                status: MoveStatus::Completed {
652                    finished_at_secs: 1,
653                },
654            },
655            MoveTask {
656                task_id: 1,
657                from_shard: "a".into(),
658                to_shard: "b".into(),
659                vector_count: 200,
660                status: MoveStatus::Failed {
661                    reason: "timeout".into(),
662                },
663            },
664        ];
665        let plan = RebalancePlan {
666            estimated_moves: 300,
667            tasks,
668        };
669        assert!(plan.is_complete());
670    }
671
672    // 16. is_complete: pending tasks → false
673    #[test]
674    fn test_is_complete_with_pending() {
675        let tasks = vec![MoveTask {
676            task_id: 0,
677            from_shard: "a".into(),
678            to_shard: "b".into(),
679            vector_count: 100,
680            status: MoveStatus::Pending,
681        }];
682        let plan = RebalancePlan {
683            estimated_moves: 100,
684            tasks,
685        };
686        assert!(!plan.is_complete());
687    }
688
689    // 17. pending_tasks / completed_tasks counts
690    #[test]
691    fn test_pending_and_completed_counts() {
692        let tasks = vec![
693            MoveTask {
694                task_id: 0,
695                from_shard: "a".into(),
696                to_shard: "b".into(),
697                vector_count: 100,
698                status: MoveStatus::Pending,
699            },
700            MoveTask {
701                task_id: 1,
702                from_shard: "a".into(),
703                to_shard: "b".into(),
704                vector_count: 100,
705                status: MoveStatus::Pending,
706            },
707            MoveTask {
708                task_id: 2,
709                from_shard: "a".into(),
710                to_shard: "b".into(),
711                vector_count: 100,
712                status: MoveStatus::Completed {
713                    finished_at_secs: 42,
714                },
715            },
716        ];
717        let plan = RebalancePlan {
718            estimated_moves: 300,
719            tasks,
720        };
721        assert_eq!(plan.pending_tasks(), 2);
722        assert_eq!(plan.completed_tasks(), 1);
723    }
724
725    // 18. active_plan returns None when the most recent plan is complete
726    #[test]
727    fn test_active_plan_none_when_complete() {
728        let mut rb = default_rebalancer();
729        let shards = vec![
730            ShardLoad {
731                shard_id: "over".into(),
732                vector_count: 9_000,
733                capacity: 10_000,
734            },
735            ShardLoad {
736                shard_id: "under".into(),
737                vector_count: 1_000,
738                capacity: 10_000,
739            },
740        ];
741        let plan = rb.plan_rebalance(&shards);
742
743        // Mark all tasks as completed.
744        for t in &plan.tasks {
745            rb.update_task_status(
746                t.task_id,
747                MoveStatus::Completed {
748                    finished_at_secs: 100,
749                },
750            );
751        }
752
753        assert!(rb.active_plan().is_none());
754    }
755
756    // 19. active_plan returns Some when the most recent plan has pending tasks
757    #[test]
758    fn test_active_plan_some_when_pending() {
759        let mut rb = default_rebalancer();
760        let shards = vec![
761            ShardLoad {
762                shard_id: "over".into(),
763                vector_count: 9_000,
764                capacity: 10_000,
765            },
766            ShardLoad {
767                shard_id: "under".into(),
768                vector_count: 1_000,
769                capacity: 10_000,
770            },
771        ];
772        rb.plan_rebalance(&shards);
773        assert!(rb.active_plan().is_some());
774    }
775
776    // 20. completed_plans count
777    #[test]
778    fn test_completed_plans_count() {
779        let mut rb = default_rebalancer();
780        // Empty plan (no overloaded shards) → is_complete() = true immediately.
781        let shards_balanced = vec![ShardLoad {
782            shard_id: "s0".into(),
783            vector_count: 600,
784            capacity: 1_000,
785        }];
786        rb.plan_rebalance(&shards_balanced);
787        rb.plan_rebalance(&shards_balanced);
788        // Both plans have zero tasks, so is_complete() returns true for each.
789        assert_eq!(rb.completed_plans(), 2);
790    }
791
792    // 21. stats returns correct totals
793    #[test]
794    fn test_stats() {
795        let mut rb = default_rebalancer();
796        let shards = vec![
797            ShardLoad {
798                shard_id: "over".into(),
799                vector_count: 9_000,
800                capacity: 10_000,
801            },
802            ShardLoad {
803                shard_id: "under".into(),
804                vector_count: 1_000,
805                capacity: 10_000,
806            },
807        ];
808        rb.plan_rebalance(&shards);
809        rb.plan_rebalance(&shards);
810
811        let (total_tasks, total_moves) = rb.stats();
812        let expected_tasks: usize = rb.plans.iter().map(|p| p.tasks.len()).sum();
813        let expected_moves: u64 = rb.plans.iter().map(|p| p.estimated_moves).sum();
814        assert_eq!(total_tasks, expected_tasks);
815        assert_eq!(total_moves, expected_moves);
816    }
817}