Skip to main content

ipfrs_semantic/
index_compactor.rs

1//! # Index Compactor
2//!
3//! HNSW indexes accumulate deleted vectors over time that waste memory and degrade query
4//! performance. This module identifies fragmented indexes and coordinates rebuilds based
5//! on configurable compaction policies.
6//!
7//! ## Overview
8//!
9//! - [`CompactionPolicy`] — configures when compaction should be triggered
10//! - [`IndexFragmentStats`] — captures fragmentation metrics for a single index
11//! - [`IndexCompactor`] — analyzes indexes and produces [`CompactionPlan`]s
12//! - [`CompactionPlan`] — describes the planned compaction and its expected impact
13//! - [`CompactorStats`] — atomic counters tracking compactor activity
14
15use std::sync::atomic::{AtomicU64, Ordering};
16use std::sync::Arc;
17
18// ---------------------------------------------------------------------------
19// IndexFragmentStats
20// ---------------------------------------------------------------------------
21
22/// Statistics describing the fragmentation state of a single HNSW index.
23#[derive(Debug, Clone, PartialEq)]
24pub struct IndexFragmentStats {
25    /// Total number of vector slots (live + deleted).
26    pub total_vectors: usize,
27    /// Number of slots marked as deleted but not yet reclaimed.
28    pub deleted_vectors: usize,
29    /// Estimated on-disk / in-memory footprint in bytes.
30    pub index_bytes: u64,
31}
32
33impl IndexFragmentStats {
34    /// Creates a new `IndexFragmentStats`.
35    pub fn new(total_vectors: usize, deleted_vectors: usize, index_bytes: u64) -> Self {
36        Self {
37            total_vectors,
38            deleted_vectors,
39            index_bytes,
40        }
41    }
42
43    /// Ratio of deleted vectors to total vectors.
44    ///
45    /// Returns `0.0` when `total_vectors == 0` to avoid division-by-zero.
46    pub fn deleted_ratio(&self) -> f64 {
47        if self.total_vectors == 0 {
48            return 0.0;
49        }
50        self.deleted_vectors as f64 / self.total_vectors as f64
51    }
52
53    /// Number of live (non-deleted) vectors.
54    pub fn live_vectors(&self) -> usize {
55        self.total_vectors.saturating_sub(self.deleted_vectors)
56    }
57}
58
59// ---------------------------------------------------------------------------
60// CompactionReason
61// ---------------------------------------------------------------------------
62
63/// The primary reason a compaction was recommended.
64#[derive(Debug, Clone, PartialEq)]
65pub enum CompactionReason {
66    /// The fraction of deleted vectors exceeded the policy threshold.
67    HighDeletedRatio {
68        /// Observed deleted-to-total ratio.
69        ratio: f64,
70    },
71    /// The index size exceeded the policy's byte limit.
72    SizeExceeded {
73        /// Current measured size in bytes.
74        current_bytes: u64,
75        /// Configured size limit in bytes.
76        limit_bytes: u64,
77    },
78    /// Compaction was requested on a fixed schedule regardless of metrics.
79    Scheduled,
80}
81
82// ---------------------------------------------------------------------------
83// CompactionPriority
84// ---------------------------------------------------------------------------
85
86/// How urgently a compaction should be scheduled.
87///
88/// Variants are ordered from lowest to highest priority so that `Critical > High > Normal > Low`.
89#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90#[repr(u8)]
91pub enum CompactionPriority {
92    /// Index health is acceptable; schedule compaction during a maintenance window.
93    Low = 0,
94    /// Standard compaction during off-peak hours.
95    Normal = 1,
96    /// Elevated fragmentation; schedule soon.
97    High = 2,
98    /// Severe fragmentation; compact immediately.
99    Critical = 3,
100}
101
102impl PartialOrd for CompactionPriority {
103    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
104        Some(self.cmp(other))
105    }
106}
107
108impl Ord for CompactionPriority {
109    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
110        (*self as u8).cmp(&(*other as u8))
111    }
112}
113
114// ---------------------------------------------------------------------------
115// CompactionPlan
116// ---------------------------------------------------------------------------
117
118/// A concrete recommendation to compact an index, including estimated impact.
119#[derive(Debug, Clone, PartialEq)]
120pub struct CompactionPlan {
121    /// Why the compaction was triggered.
122    pub reason: CompactionReason,
123    /// Estimated number of live vectors after the rebuild.
124    pub estimated_vectors_after: usize,
125    /// Approximate bytes that will be freed by the compaction.
126    pub estimated_bytes_saved: u64,
127    /// How urgently the compaction should be performed.
128    pub priority: CompactionPriority,
129}
130
131// ---------------------------------------------------------------------------
132// CompactionPolicy
133// ---------------------------------------------------------------------------
134
135/// Configures the triggers that cause [`IndexCompactor`] to recommend a compaction.
136#[derive(Debug, Clone)]
137pub struct CompactionPolicy {
138    /// Recommend compaction when `deleted / total > max_deleted_ratio`.
139    ///
140    /// Default: `0.1` (10 %).
141    pub max_deleted_ratio: f64,
142    /// Recommend compaction when the index exceeds this many bytes.
143    ///
144    /// Default: 512 MiB (`512 * 1024 * 1024`).
145    pub max_index_bytes: u64,
146    /// Skip compaction analysis for indexes with fewer than this many total vectors.
147    ///
148    /// Default: `1000`.
149    pub min_vectors_for_compaction: usize,
150}
151
152impl Default for CompactionPolicy {
153    fn default() -> Self {
154        Self {
155            max_deleted_ratio: 0.1,
156            max_index_bytes: 512 * 1024 * 1024,
157            min_vectors_for_compaction: 1000,
158        }
159    }
160}
161
162impl CompactionPolicy {
163    /// Returns `true` when `stats` indicates that compaction is needed according to this policy.
164    ///
165    /// Compaction is *not* triggered when `total_vectors < min_vectors_for_compaction`, even if
166    /// other thresholds are exceeded (tiny indexes are cheap to traverse as-is).
167    pub fn should_compact(&self, stats: &IndexFragmentStats) -> bool {
168        if stats.total_vectors < self.min_vectors_for_compaction {
169            return false;
170        }
171        if stats.deleted_ratio() > self.max_deleted_ratio {
172            return true;
173        }
174        if stats.index_bytes > self.max_index_bytes {
175            return true;
176        }
177        false
178    }
179}
180
181// ---------------------------------------------------------------------------
182// CompactorStats (atomic counters)
183// ---------------------------------------------------------------------------
184
185/// Internal mutable state of [`CompactorStats`] — not exposed directly.
186struct CompactorStatsInner {
187    total_analyzed: AtomicU64,
188    total_compaction_needed: AtomicU64,
189    total_skipped: AtomicU64,
190}
191
192impl CompactorStatsInner {
193    fn new() -> Self {
194        Self {
195            total_analyzed: AtomicU64::new(0),
196            total_compaction_needed: AtomicU64::new(0),
197            total_skipped: AtomicU64::new(0),
198        }
199    }
200}
201
202/// A snapshot of [`CompactorStats`] counters taken at a point in time.
203#[derive(Debug, Clone, PartialEq, Eq)]
204pub struct CompactorStatsSnapshot {
205    /// Total number of [`IndexFragmentStats`] passed to [`IndexCompactor::analyze`].
206    pub total_analyzed: u64,
207    /// Analyses that resulted in a [`CompactionPlan`] being returned.
208    pub total_compaction_needed: u64,
209    /// Analyses where no compaction was necessary.
210    pub total_skipped: u64,
211}
212
213/// Thread-safe counters tracking compactor activity.
214///
215/// Internally uses [`AtomicU64`] values so no locking is required.
216#[derive(Clone)]
217pub struct CompactorStats {
218    inner: Arc<CompactorStatsInner>,
219}
220
221impl CompactorStats {
222    fn new() -> Self {
223        Self {
224            inner: Arc::new(CompactorStatsInner::new()),
225        }
226    }
227
228    fn record_analyzed(&self) {
229        self.inner.total_analyzed.fetch_add(1, Ordering::Relaxed);
230    }
231
232    fn record_compaction_needed(&self) {
233        self.inner
234            .total_compaction_needed
235            .fetch_add(1, Ordering::Relaxed);
236    }
237
238    fn record_skipped(&self) {
239        self.inner.total_skipped.fetch_add(1, Ordering::Relaxed);
240    }
241
242    /// Returns a consistent point-in-time snapshot of all counters.
243    pub fn snapshot(&self) -> CompactorStatsSnapshot {
244        CompactorStatsSnapshot {
245            total_analyzed: self.inner.total_analyzed.load(Ordering::Relaxed),
246            total_compaction_needed: self.inner.total_compaction_needed.load(Ordering::Relaxed),
247            total_skipped: self.inner.total_skipped.load(Ordering::Relaxed),
248        }
249    }
250}
251
252impl std::fmt::Debug for CompactorStats {
253    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
254        let snap = self.snapshot();
255        f.debug_struct("CompactorStats")
256            .field("total_analyzed", &snap.total_analyzed)
257            .field("total_compaction_needed", &snap.total_compaction_needed)
258            .field("total_skipped", &snap.total_skipped)
259            .finish()
260    }
261}
262
263// ---------------------------------------------------------------------------
264// IndexCompactor
265// ---------------------------------------------------------------------------
266
267/// Analyses HNSW index fragmentation and recommends compaction plans.
268///
269/// `IndexCompactor` is the central entry point for compaction decisions.  Given
270/// an [`IndexFragmentStats`] it applies the configured [`CompactionPolicy`] and
271/// returns an [`Option<CompactionPlan>`]: `Some` when compaction is warranted,
272/// `None` when the index is healthy enough to leave alone.
273///
274/// All activity is recorded in [`CompactorStats`] so operators can observe how
275/// frequently indexes require maintenance.
276#[derive(Debug, Clone)]
277pub struct IndexCompactor {
278    /// Configurable thresholds that drive compaction decisions.
279    pub policy: CompactionPolicy,
280    /// Atomic activity counters.
281    pub stats: CompactorStats,
282}
283
284impl IndexCompactor {
285    /// Creates a new `IndexCompactor` with the supplied policy.
286    pub fn new(policy: CompactionPolicy) -> Self {
287        Self {
288            policy,
289            stats: CompactorStats::new(),
290        }
291    }
292
293    /// Analyses `fragment_stats` and returns a [`CompactionPlan`] when compaction is needed.
294    ///
295    /// Returns `None` when the index is below all policy thresholds.
296    pub fn analyze(&self, fragment_stats: &IndexFragmentStats) -> Option<CompactionPlan> {
297        self.stats.record_analyzed();
298
299        if !self.policy.should_compact(fragment_stats) {
300            self.stats.record_skipped();
301            return None;
302        }
303
304        self.stats.record_compaction_needed();
305
306        let reason = self.primary_reason(fragment_stats);
307        let priority = self.estimate_priority(fragment_stats);
308        let estimated_bytes_saved = self.estimate_bytes_saved(fragment_stats);
309        let estimated_vectors_after = fragment_stats.live_vectors();
310
311        Some(CompactionPlan {
312            reason,
313            estimated_vectors_after,
314            estimated_bytes_saved,
315            priority,
316        })
317    }
318
319    /// Determines the primary compaction reason for the given stats.
320    ///
321    /// Deleted-ratio threshold takes precedence over size threshold when both are exceeded.
322    fn primary_reason(&self, stats: &IndexFragmentStats) -> CompactionReason {
323        let ratio = stats.deleted_ratio();
324        if ratio > self.policy.max_deleted_ratio {
325            return CompactionReason::HighDeletedRatio { ratio };
326        }
327        if stats.index_bytes > self.policy.max_index_bytes {
328            return CompactionReason::SizeExceeded {
329                current_bytes: stats.index_bytes,
330                limit_bytes: self.policy.max_index_bytes,
331            };
332        }
333        // Fallback — only reached when `should_compact` returned `true` via a scheduled path
334        // that bypasses both checks.  Defined here for exhaustiveness.
335        CompactionReason::Scheduled
336    }
337
338    /// Estimates the urgency of the required compaction.
339    ///
340    /// | Condition | Priority |
341    /// |-----------|----------|
342    /// | `deleted_ratio > 0.5` **or** `index_bytes > 2 × limit` | `Critical` |
343    /// | `deleted_ratio > 0.25` | `High` |
344    /// | compaction needed | `Normal` |
345    /// | compaction *not* needed | `Low` |
346    pub fn estimate_priority(&self, stats: &IndexFragmentStats) -> CompactionPriority {
347        let ratio = stats.deleted_ratio();
348        let double_limit = self.policy.max_index_bytes.saturating_mul(2);
349
350        if ratio > 0.5 || stats.index_bytes > double_limit {
351            return CompactionPriority::Critical;
352        }
353        if ratio > 0.25 {
354            return CompactionPriority::High;
355        }
356        if self.policy.should_compact(stats) {
357            return CompactionPriority::Normal;
358        }
359        CompactionPriority::Low
360    }
361
362    /// Estimates how many bytes will be reclaimed by a compaction.
363    ///
364    /// Uses the approximation `deleted_ratio × index_bytes`.
365    pub fn estimate_bytes_saved(&self, stats: &IndexFragmentStats) -> u64 {
366        let ratio = stats.deleted_ratio();
367        (ratio * stats.index_bytes as f64) as u64
368    }
369}
370
371// ---------------------------------------------------------------------------
372// Tests
373// ---------------------------------------------------------------------------
374
375#[cfg(test)]
376mod tests {
377    use super::*;
378
379    // ------------------------------------------------------------------
380    // Helpers
381    // ------------------------------------------------------------------
382
383    fn default_compactor() -> IndexCompactor {
384        IndexCompactor::new(CompactionPolicy::default())
385    }
386
387    /// Builds stats with `total = 10_000` vectors unless overridden.
388    fn stats(total: usize, deleted: usize, bytes: u64) -> IndexFragmentStats {
389        IndexFragmentStats::new(total, deleted, bytes)
390    }
391
392    const MB: u64 = 1024 * 1024;
393
394    // ------------------------------------------------------------------
395    // 1. should_compact — high deleted ratio trigger
396    // ------------------------------------------------------------------
397
398    #[test]
399    fn test_should_compact_high_deleted_ratio() {
400        let policy = CompactionPolicy::default();
401        // 15 % deleted exceeds the 10 % default threshold
402        let s = stats(10_000, 1_500, 100 * MB);
403        assert!(policy.should_compact(&s));
404    }
405
406    // ------------------------------------------------------------------
407    // 2. should_compact — size exceeded trigger
408    // ------------------------------------------------------------------
409
410    #[test]
411    fn test_should_compact_size_exceeded() {
412        let policy = CompactionPolicy::default();
413        // 0 deleted vectors, but index is over the 512 MiB limit
414        let s = stats(10_000, 0, 600 * MB);
415        assert!(policy.should_compact(&s));
416    }
417
418    // ------------------------------------------------------------------
419    // 3. should_compact — scheduled (custom policy overriding thresholds)
420    //    We test both triggers simultaneously to verify that either alone suffices.
421    // ------------------------------------------------------------------
422
423    #[test]
424    fn test_should_compact_both_triggers() {
425        let policy = CompactionPolicy::default();
426        let s = stats(10_000, 2_000, 700 * MB);
427        assert!(policy.should_compact(&s));
428    }
429
430    // ------------------------------------------------------------------
431    // 4. should_compact returns false when all metrics are below thresholds
432    // ------------------------------------------------------------------
433
434    #[test]
435    fn test_should_compact_returns_false_when_healthy() {
436        let policy = CompactionPolicy::default();
437        // 5 % deleted (below 10 %), 100 MiB (below 512 MiB)
438        let s = stats(10_000, 500, 100 * MB);
439        assert!(!policy.should_compact(&s));
440    }
441
442    // ------------------------------------------------------------------
443    // 5. min_vectors guard — tiny index must not be compacted even if ratio is high
444    // ------------------------------------------------------------------
445
446    #[test]
447    fn test_should_compact_min_vectors_guard() {
448        let policy = CompactionPolicy::default();
449        // Only 500 vectors (below the 1000 minimum), 50 % deleted
450        let s = stats(500, 250, 10 * MB);
451        assert!(!policy.should_compact(&s));
452    }
453
454    // ------------------------------------------------------------------
455    // 6. min_vectors guard — exactly at the boundary (1000) should compact
456    // ------------------------------------------------------------------
457
458    #[test]
459    fn test_should_compact_min_vectors_boundary() {
460        let policy = CompactionPolicy::default();
461        // Exactly 1000 vectors, 20 % deleted (above 10 % threshold)
462        let s = stats(1_000, 200, 50 * MB);
463        assert!(policy.should_compact(&s));
464    }
465
466    // ------------------------------------------------------------------
467    // 7. deleted_ratio calculation
468    // ------------------------------------------------------------------
469
470    #[test]
471    fn test_deleted_ratio_calculation() {
472        let s = stats(10_000, 2_500, 100 * MB);
473        let ratio = s.deleted_ratio();
474        assert!(
475            (ratio - 0.25).abs() < f64::EPSILON,
476            "expected 0.25, got {ratio}"
477        );
478    }
479
480    // ------------------------------------------------------------------
481    // 8. deleted_ratio returns 0.0 for empty index
482    // ------------------------------------------------------------------
483
484    #[test]
485    fn test_deleted_ratio_empty_index() {
486        let s = stats(0, 0, 0);
487        assert_eq!(s.deleted_ratio(), 0.0);
488    }
489
490    // ------------------------------------------------------------------
491    // 9. live_vectors helper
492    // ------------------------------------------------------------------
493
494    #[test]
495    fn test_live_vectors() {
496        let s = stats(10_000, 3_000, 100 * MB);
497        assert_eq!(s.live_vectors(), 7_000);
498    }
499
500    // ------------------------------------------------------------------
501    // 10. analyze returns Some when compaction is needed
502    // ------------------------------------------------------------------
503
504    #[test]
505    fn test_analyze_returns_some_when_needed() {
506        let compactor = default_compactor();
507        let s = stats(10_000, 2_000, 100 * MB); // 20 % deleted → above 10 % threshold
508        assert!(compactor.analyze(&s).is_some());
509    }
510
511    // ------------------------------------------------------------------
512    // 11. analyze returns None when index is clean
513    // ------------------------------------------------------------------
514
515    #[test]
516    fn test_analyze_returns_none_when_clean() {
517        let compactor = default_compactor();
518        let s = stats(10_000, 500, 100 * MB); // 5 % deleted, 100 MiB — healthy
519        assert!(compactor.analyze(&s).is_none());
520    }
521
522    // ------------------------------------------------------------------
523    // 12. estimate_priority — Low when below all thresholds
524    // ------------------------------------------------------------------
525
526    #[test]
527    fn test_estimate_priority_low() {
528        let compactor = default_compactor();
529        let s = stats(10_000, 100, 50 * MB); // 1 % deleted, 50 MiB
530        assert_eq!(compactor.estimate_priority(&s), CompactionPriority::Low);
531    }
532
533    // ------------------------------------------------------------------
534    // 13. estimate_priority — Normal when slightly over deleted threshold
535    // ------------------------------------------------------------------
536
537    #[test]
538    fn test_estimate_priority_normal() {
539        let compactor = default_compactor();
540        // 15 % deleted: above 10 % (normal) but below 25 % (high)
541        let s = stats(10_000, 1_500, 100 * MB);
542        assert_eq!(compactor.estimate_priority(&s), CompactionPriority::Normal);
543    }
544
545    // ------------------------------------------------------------------
546    // 14. estimate_priority — High when deleted_ratio > 0.25
547    // ------------------------------------------------------------------
548
549    #[test]
550    fn test_estimate_priority_high() {
551        let compactor = default_compactor();
552        // 30 % deleted
553        let s = stats(10_000, 3_000, 100 * MB);
554        assert_eq!(compactor.estimate_priority(&s), CompactionPriority::High);
555    }
556
557    // ------------------------------------------------------------------
558    // 15. estimate_priority — Critical when deleted_ratio > 0.5
559    // ------------------------------------------------------------------
560
561    #[test]
562    fn test_estimate_priority_critical_high_ratio() {
563        let compactor = default_compactor();
564        // 60 % deleted
565        let s = stats(10_000, 6_000, 100 * MB);
566        assert_eq!(
567            compactor.estimate_priority(&s),
568            CompactionPriority::Critical
569        );
570    }
571
572    // ------------------------------------------------------------------
573    // 16. estimate_priority — Critical when bytes > 2× limit
574    // ------------------------------------------------------------------
575
576    #[test]
577    fn test_estimate_priority_critical_size() {
578        let compactor = default_compactor();
579        // 0 deleted, but > 1024 MiB (2 × 512 MiB default limit)
580        let s = stats(10_000, 0, 1_100 * MB);
581        assert_eq!(
582            compactor.estimate_priority(&s),
583            CompactionPriority::Critical
584        );
585    }
586
587    // ------------------------------------------------------------------
588    // 17. CompactionPriority ordering (Critical > High > Normal > Low)
589    // ------------------------------------------------------------------
590
591    #[test]
592    fn test_compaction_priority_ordering() {
593        assert!(CompactionPriority::Critical > CompactionPriority::High);
594        assert!(CompactionPriority::High > CompactionPriority::Normal);
595        assert!(CompactionPriority::Normal > CompactionPriority::Low);
596        assert!(CompactionPriority::Critical > CompactionPriority::Low);
597    }
598
599    // ------------------------------------------------------------------
600    // 18. Stats increment on each call to analyze
601    // ------------------------------------------------------------------
602
603    #[test]
604    fn test_stats_increment_on_analyze() {
605        let compactor = default_compactor();
606
607        // First call — below thresholds → skipped
608        let clean = stats(10_000, 500, 100 * MB);
609        compactor.analyze(&clean);
610
611        // Second call — above threshold → compaction needed
612        let dirty = stats(10_000, 2_000, 100 * MB);
613        compactor.analyze(&dirty);
614
615        let snap = compactor.stats.snapshot();
616        assert_eq!(snap.total_analyzed, 2);
617        assert_eq!(snap.total_compaction_needed, 1);
618        assert_eq!(snap.total_skipped, 1);
619    }
620
621    // ------------------------------------------------------------------
622    // 19. estimate_bytes_saved formula: deleted_ratio × index_bytes
623    // ------------------------------------------------------------------
624
625    #[test]
626    fn test_estimate_bytes_saved_formula() {
627        let compactor = default_compactor();
628        // 25 % deleted, 400 MiB index → expect ~100 MiB saved
629        let s = stats(10_000, 2_500, 400 * MB);
630        let saved = compactor.estimate_bytes_saved(&s);
631        let expected = (0.25_f64 * (400 * MB) as f64) as u64;
632        assert_eq!(saved, expected);
633    }
634
635    // ------------------------------------------------------------------
636    // 20. CompactionPlan fields are correct
637    // ------------------------------------------------------------------
638
639    #[test]
640    fn test_compaction_plan_fields() {
641        let compactor = default_compactor();
642        let s = stats(10_000, 2_000, 200 * MB); // 20 % deleted
643
644        let plan = compactor.analyze(&s).expect("should produce a plan");
645        assert_eq!(plan.estimated_vectors_after, 8_000);
646        assert!(plan.estimated_bytes_saved > 0);
647        assert_eq!(plan.priority, CompactionPriority::Normal);
648        match plan.reason {
649            CompactionReason::HighDeletedRatio { ratio } => {
650                assert!((ratio - 0.2).abs() < f64::EPSILON);
651            }
652            other => panic!("unexpected reason: {other:?}"),
653        }
654    }
655
656    // ------------------------------------------------------------------
657    // 21. Custom policy — lower thresholds
658    // ------------------------------------------------------------------
659
660    #[test]
661    fn test_custom_policy_lower_thresholds() {
662        let policy = CompactionPolicy {
663            max_deleted_ratio: 0.05,
664            max_index_bytes: 50 * MB,
665            min_vectors_for_compaction: 100,
666        };
667        let compactor = IndexCompactor::new(policy);
668
669        // 8 % deleted (> 5 % custom threshold) — should trigger
670        let s = stats(1_000, 80, 10 * MB);
671        assert!(compactor.analyze(&s).is_some());
672    }
673
674    // ------------------------------------------------------------------
675    // 22. analyze on tiny index below min_vectors returns None
676    // ------------------------------------------------------------------
677
678    #[test]
679    fn test_analyze_tiny_index_skipped() {
680        let compactor = default_compactor();
681        // 900 vectors with 50 % deleted — but below the 1000-vector minimum
682        let s = stats(900, 450, 50 * MB);
683        assert!(compactor.analyze(&s).is_none());
684    }
685
686    // ------------------------------------------------------------------
687    // 23. Reason is SizeExceeded when only size is breached
688    // ------------------------------------------------------------------
689
690    #[test]
691    fn test_reason_size_exceeded() {
692        let compactor = default_compactor();
693        // 0 % deleted, 600 MiB (> 512 MiB limit)
694        let s = stats(10_000, 0, 600 * MB);
695        let plan = compactor.analyze(&s).expect("should produce a plan");
696        match plan.reason {
697            CompactionReason::SizeExceeded {
698                current_bytes,
699                limit_bytes,
700            } => {
701                assert_eq!(current_bytes, 600 * MB);
702                assert_eq!(limit_bytes, 512 * MB);
703            }
704            other => panic!("unexpected reason: {other:?}"),
705        }
706    }
707
708    // ------------------------------------------------------------------
709    // 24. Multiple analyses accumulate stats correctly
710    // ------------------------------------------------------------------
711
712    #[test]
713    fn test_stats_multiple_analyses() {
714        let compactor = default_compactor();
715
716        let clean = stats(10_000, 100, 50 * MB);
717        let dirty = stats(10_000, 5_000, 200 * MB);
718
719        for _ in 0..5 {
720            compactor.analyze(&clean);
721        }
722        for _ in 0..3 {
723            compactor.analyze(&dirty);
724        }
725
726        let snap = compactor.stats.snapshot();
727        assert_eq!(snap.total_analyzed, 8);
728        assert_eq!(snap.total_skipped, 5);
729        assert_eq!(snap.total_compaction_needed, 3);
730    }
731}