Skip to main content

hermes_core/merge/
mod.rs

1//! Merge policies for background segment merging
2//!
3//! Merge policies determine when and which segments should be merged together.
4//! The default is a tiered/log-layered policy that groups segments by size tiers.
5
6use std::fmt::Debug;
7
8#[cfg(feature = "native")]
9mod segment_manager;
10#[cfg(feature = "native")]
11pub use segment_manager::SegmentManager;
12
13/// Information about a segment for merge decisions
14#[derive(Debug, Clone)]
15pub struct SegmentInfo {
16    /// Segment ID (hex string)
17    pub id: String,
18    /// Number of documents in the segment
19    pub num_docs: u32,
20}
21
22/// A merge operation specifying which segments to merge
23#[derive(Debug, Clone)]
24pub struct MergeCandidate {
25    /// Segment IDs to merge together
26    pub segment_ids: Vec<String>,
27}
28
29/// Trait for merge policies
30///
31/// Implementations decide when segments should be merged and which ones.
32pub trait MergePolicy: Send + Sync + Debug {
33    /// Given the current segments, return all eligible merge candidates.
34    /// Multiple candidates can run concurrently as long as they don't share segments.
35    fn find_merges(&self, segments: &[SegmentInfo]) -> Vec<MergeCandidate>;
36
37    /// Clone the policy into a boxed trait object
38    fn clone_box(&self) -> Box<dyn MergePolicy>;
39
40    /// Maximum number of documents a single segment should contain.
41    /// Returns `None` for no limit (force_merge merges everything into one).
42    fn max_segment_docs(&self) -> Option<u32> {
43        None
44    }
45}
46
47impl Clone for Box<dyn MergePolicy> {
48    fn clone(&self) -> Self {
49        self.clone_box()
50    }
51}
52
53/// No-op merge policy - never merges automatically
54#[derive(Debug, Clone, Default)]
55pub struct NoMergePolicy;
56
57impl MergePolicy for NoMergePolicy {
58    fn find_merges(&self, _segments: &[SegmentInfo]) -> Vec<MergeCandidate> {
59        Vec::new()
60    }
61
62    fn clone_box(&self) -> Box<dyn MergePolicy> {
63        Box::new(self.clone())
64    }
65}
66
67/// Tiered/Log-layered merge policy
68///
69/// Groups segments into tiers based on document count. Segments in the same tier
70/// are merged when there are enough of them. This creates a logarithmic structure
71/// where larger segments are merged less frequently.
72///
73/// Tiers are defined by powers of `tier_factor`:
74/// - Tier 0: 0 to tier_floor docs
75/// - Tier 1: tier_floor to tier_floor * tier_factor docs
76/// - Tier 2: tier_floor * tier_factor to tier_floor * tier_factor^2 docs
77/// - etc.
78///
79/// For large-scale indexes (10M-1B docs), use [`TieredMergePolicy::large_scale()`] or
80/// [`TieredMergePolicy::bulk_indexing()`] presets which enable budget-aware triggering,
81/// scored candidate selection, and oversized segment exclusion.
82#[derive(Debug, Clone)]
83pub struct TieredMergePolicy {
84    /// Minimum number of segments in a tier before merging (default: 10)
85    pub segments_per_tier: usize,
86    /// Maximum number of segments to merge at once (default: 10).
87    /// Should be close to segments_per_tier to prevent giant merges.
88    pub max_merge_at_once: usize,
89    /// Factor between tier sizes (default: 10.0)
90    pub tier_factor: f64,
91    /// Minimum segment size (docs) to consider for tiering (default: 1000)
92    pub tier_floor: u32,
93    /// Maximum total docs to merge at once (default: 5_000_000)
94    pub max_merged_docs: u32,
95
96    /// Floor size for scoring — tiny segments are treated as this size when
97    /// computing merge scores. Prevents degenerate scores from near-empty segments.
98    /// (default: 1000)
99    pub floor_segment_docs: u32,
100    /// Exclude segments larger than `max_merged_docs * oversized_threshold` from
101    /// merge candidates. Prevents rewriting already-large segments. (default: 0.5)
102    pub oversized_threshold: f64,
103    /// Merge output must be >= `(1 + min_growth_ratio) * largest_input` docs.
104    /// Rejects merges that rewrite a large segment just to absorb tiny ones.
105    /// Set to 0.0 to disable. (default: 0.0)
106    pub min_growth_ratio: f64,
107    /// Only merge when segment count exceeds the ideal budget
108    /// (`num_tiers * segments_per_tier`). Prevents unnecessary merges when
109    /// the index is already well-structured. (default: false)
110    pub budget_trigger: bool,
111    /// Use Lucene-style skew scoring to pick the most balanced merge candidate
112    /// instead of greedily taking the first valid group. (default: false)
113    pub scored_selection: bool,
114    /// Absolute maximum number of documents in a single segment.
115    /// Respected by both automatic merging and `force_merge`. (default: 10_000_000)
116    pub max_segment_docs: u32,
117}
118
119impl Default for TieredMergePolicy {
120    fn default() -> Self {
121        Self {
122            segments_per_tier: 10,
123            max_merge_at_once: 10,
124            tier_factor: 10.0,
125            tier_floor: 1000,
126            max_merged_docs: 5_000_000,
127            floor_segment_docs: 1000,
128            oversized_threshold: 0.5,
129            min_growth_ratio: 0.0,
130            budget_trigger: false,
131            scored_selection: false,
132            max_segment_docs: 10_000_000,
133        }
134    }
135}
136
137impl TieredMergePolicy {
138    /// Create a new tiered merge policy with default settings
139    pub fn new() -> Self {
140        Self::default()
141    }
142
143    /// Create an aggressive merge policy that merges more frequently
144    ///
145    /// - Merges when 3 segments in same tier (vs 10 default)
146    /// - Lower tier floor (500 docs vs 1000)
147    /// - Good for reducing segment count quickly
148    pub fn aggressive() -> Self {
149        Self {
150            segments_per_tier: 3,
151            max_merge_at_once: 10,
152            tier_factor: 10.0,
153            tier_floor: 500,
154            max_merged_docs: 10_000_000,
155            max_segment_docs: 10_000_000,
156            ..Default::default()
157        }
158    }
159
160    /// Large-scale merge policy for indexes with 100M-1B documents.
161    ///
162    /// Enables budget-aware triggering and scored candidate selection to avoid
163    /// unnecessary IO on already well-structured indexes. Oversized segments
164    /// (>10M docs) are excluded from merge candidates.
165    ///
166    /// Good for live read/write workloads at scale.
167    pub fn large_scale() -> Self {
168        Self {
169            segments_per_tier: 10,
170            max_merge_at_once: 10,
171            tier_factor: 10.0,
172            tier_floor: 50_000,
173            max_merged_docs: 20_000_000,
174            floor_segment_docs: 50_000,
175            oversized_threshold: 0.5,
176            min_growth_ratio: 0.5,
177            budget_trigger: true,
178            scored_selection: true,
179            max_segment_docs: 20_000_000,
180        }
181    }
182
183    /// Bulk-indexing merge policy for high-throughput initial loads.
184    ///
185    /// Uses larger merge batches and higher thresholds to maximize throughput.
186    /// Call `force_merge()` after the bulk load is complete.
187    pub fn bulk_indexing() -> Self {
188        Self {
189            segments_per_tier: 20,
190            max_merge_at_once: 20,
191            tier_factor: 10.0,
192            tier_floor: 100_000,
193            max_merged_docs: 50_000_000,
194            floor_segment_docs: 100_000,
195            oversized_threshold: 0.5,
196            min_growth_ratio: 0.75,
197            budget_trigger: true,
198            scored_selection: true,
199            max_segment_docs: 50_000_000,
200        }
201    }
202}
203
204impl TieredMergePolicy {
205    /// Effective maximum docs for a merged segment, considering both
206    /// `max_merged_docs` and `max_segment_docs`.
207    fn effective_max_docs(&self) -> u32 {
208        self.max_merged_docs.min(self.max_segment_docs)
209    }
210
211    /// Compute the ideal segment count for the given total document count.
212    /// Based on Lucene's budget model: segments arrange in tiers of `tier_factor`
213    /// width, with up to `segments_per_tier` segments per tier.
214    fn compute_ideal_segment_count(&self, total_docs: u64) -> usize {
215        if total_docs == 0 {
216            return 0;
217        }
218        let floor = self.floor_segment_docs.max(1) as f64;
219        // Number of tiers needed to cover total_docs
220        let num_tiers = ((total_docs as f64 / floor).max(1.0))
221            .log(self.tier_factor)
222            .ceil() as usize;
223        let num_tiers = num_tiers.max(1);
224        num_tiers * self.segments_per_tier
225    }
226
227    /// Score a merge group. Lower score = better (more balanced) merge.
228    /// Uses Lucene-style skew scoring: `skew * size_factor`.
229    /// - skew = largest_floored / total_floored (1/N for perfectly balanced, 1.0 for singleton)
230    /// - size_factor = total_floored^0.05 (mild preference for larger merges)
231    fn score_candidate(&self, group: &[usize], sorted: &[&SegmentInfo]) -> f64 {
232        let floor = self.floor_segment_docs.max(1) as f64;
233        let mut total_floored = 0.0f64;
234        let mut largest_floored = 0.0f64;
235        for &idx in group {
236            let floored = (sorted[idx].num_docs as f64).max(floor);
237            total_floored += floored;
238            if floored > largest_floored {
239                largest_floored = floored;
240            }
241        }
242        if total_floored == 0.0 {
243            return f64::MAX;
244        }
245        let skew = largest_floored / total_floored;
246        skew * total_floored.powf(0.05)
247    }
248
249    /// Check whether a merge group passes the minimum growth ratio.
250    /// Returns true if the output (total docs) is at least `(1 + ratio) * largest_input`.
251    fn passes_min_growth(&self, group: &[usize], sorted: &[&SegmentInfo]) -> bool {
252        if self.min_growth_ratio <= 0.0 || group.len() < 2 {
253            return true;
254        }
255        let largest = group
256            .iter()
257            .map(|&i| sorted[i].num_docs as u64)
258            .max()
259            .unwrap_or(0);
260        let total: u64 = group.iter().map(|&i| sorted[i].num_docs as u64).sum();
261        total as f64 >= (1.0 + self.min_growth_ratio) * largest as f64
262    }
263
264    /// Greedy merge selection — the original algorithm with min_growth_ratio added.
265    fn find_merges_greedy(&self, sorted: &[&SegmentInfo]) -> Vec<MergeCandidate> {
266        let mut candidates = Vec::new();
267        let mut used = vec![false; sorted.len()];
268        let max_ratio = self.tier_factor as u64;
269        let effective_max = self.effective_max_docs() as u64;
270
271        let mut start = 0;
272        loop {
273            while start < sorted.len() && used[start] {
274                start += 1;
275            }
276            if start >= sorted.len() {
277                break;
278            }
279
280            let mut group = vec![start];
281            let mut total_docs: u64 = sorted[start].num_docs as u64;
282
283            for j in (start + 1)..sorted.len() {
284                if used[j] {
285                    continue;
286                }
287                if group.len() >= self.max_merge_at_once {
288                    break;
289                }
290                let next_docs = sorted[j].num_docs as u64;
291                if total_docs + next_docs > effective_max {
292                    break;
293                }
294                if next_docs > total_docs.max(1) * max_ratio {
295                    break;
296                }
297                group.push(j);
298                total_docs += next_docs;
299            }
300
301            if group.len() >= self.segments_per_tier
302                && group.len() >= 2
303                && self.passes_min_growth(&group, sorted)
304            {
305                for &i in &group {
306                    used[i] = true;
307                }
308                candidates.push(MergeCandidate {
309                    segment_ids: group.iter().map(|&i| sorted[i].id.clone()).collect(),
310                });
311            }
312
313            start += 1;
314        }
315
316        candidates
317    }
318
319    /// Scored merge selection — evaluates all possible groups and picks the
320    /// most balanced ones using skew scoring.
321    fn find_merges_scored(&self, sorted: &[&SegmentInfo]) -> Vec<MergeCandidate> {
322        let max_ratio = self.tier_factor as u64;
323        let effective_max = self.effective_max_docs() as u64;
324
325        // Build all valid merge groups with their scores
326        let mut scored_groups: Vec<(f64, Vec<usize>)> = Vec::new();
327
328        for start in 0..sorted.len() {
329            let mut group = vec![start];
330            let mut total_docs: u64 = sorted[start].num_docs as u64;
331
332            for j in (start + 1)..sorted.len() {
333                if group.len() >= self.max_merge_at_once {
334                    break;
335                }
336                let next_docs = sorted[j].num_docs as u64;
337                if total_docs + next_docs > effective_max {
338                    break;
339                }
340                if next_docs > total_docs.max(1) * max_ratio {
341                    break;
342                }
343                group.push(j);
344                total_docs += next_docs;
345
346                // Record every valid group (>= segments_per_tier)
347                if group.len() >= self.segments_per_tier
348                    && group.len() >= 2
349                    && self.passes_min_growth(&group, sorted)
350                {
351                    let score = self.score_candidate(&group, sorted);
352                    scored_groups.push((score, group.clone()));
353                }
354            }
355        }
356
357        // Sort by score ascending (best first)
358        scored_groups.sort_by(|a, b| a.0.total_cmp(&b.0));
359
360        // Greedily select non-overlapping candidates
361        let mut used = vec![false; sorted.len()];
362        let mut candidates = Vec::new();
363
364        for (_score, group) in scored_groups {
365            if group.iter().any(|&i| used[i]) {
366                continue;
367            }
368            for &i in &group {
369                used[i] = true;
370            }
371            candidates.push(MergeCandidate {
372                segment_ids: group.iter().map(|&i| sorted[i].id.clone()).collect(),
373            });
374        }
375
376        candidates
377    }
378}
379
380impl MergePolicy for TieredMergePolicy {
381    fn find_merges(&self, segments: &[SegmentInfo]) -> Vec<MergeCandidate> {
382        if segments.len() < 2 {
383            return Vec::new();
384        }
385
386        // Phase 1: Filter oversized segments
387        let effective_max = self.effective_max_docs();
388        let oversized_limit = (effective_max as f64 * self.oversized_threshold) as u64;
389        let eligible: Vec<&SegmentInfo> = segments
390            .iter()
391            .filter(|s| (s.num_docs as u64) <= oversized_limit || oversized_limit == 0)
392            .collect();
393
394        if eligible.len() < 2 {
395            return Vec::new();
396        }
397
398        // Phase 2: Budget check — skip merging if segment count is healthy
399        if self.budget_trigger {
400            let total_docs: u64 = segments.iter().map(|s| s.num_docs as u64).sum();
401            let ideal = self.compute_ideal_segment_count(total_docs);
402            if eligible.len() <= ideal {
403                return Vec::new();
404            }
405        }
406
407        // Sort eligible segments by size ascending
408        let mut sorted = eligible;
409        sorted.sort_by_key(|s| s.num_docs);
410
411        // Phase 3: Select merge candidates
412        if self.scored_selection {
413            self.find_merges_scored(&sorted)
414        } else {
415            self.find_merges_greedy(&sorted)
416        }
417    }
418
419    fn clone_box(&self) -> Box<dyn MergePolicy> {
420        Box::new(self.clone())
421    }
422
423    fn max_segment_docs(&self) -> Option<u32> {
424        Some(self.max_segment_docs)
425    }
426}
427
428#[cfg(test)]
429mod tests {
430    use super::*;
431
432    /// Compute tier for a segment (used only in tests to verify tier math)
433    fn compute_tier(policy: &TieredMergePolicy, num_docs: u32) -> usize {
434        if num_docs <= policy.tier_floor {
435            return 0;
436        }
437        let ratio = num_docs as f64 / policy.tier_floor as f64;
438        (ratio.log(policy.tier_factor).floor() as usize) + 1
439    }
440
441    #[test]
442    fn test_tiered_policy_compute_tier() {
443        let policy = TieredMergePolicy::default();
444
445        // Tier 0: <= 1000 docs (tier_floor)
446        assert_eq!(compute_tier(&policy, 500), 0);
447        assert_eq!(compute_tier(&policy, 1000), 0);
448
449        // Tier 1: 1001 - 9999 docs (ratio < 10)
450        assert_eq!(compute_tier(&policy, 1001), 1);
451        assert_eq!(compute_tier(&policy, 5000), 1);
452        assert_eq!(compute_tier(&policy, 9999), 1);
453
454        // Tier 2: 10000 - 99999 docs (ratio 10-100)
455        assert_eq!(compute_tier(&policy, 10000), 2);
456        assert_eq!(compute_tier(&policy, 50000), 2);
457
458        // Tier 3: 100000+ docs
459        assert_eq!(compute_tier(&policy, 100000), 3);
460    }
461
462    #[test]
463    fn test_tiered_policy_no_merge_few_segments() {
464        let policy = TieredMergePolicy::default();
465
466        let segments = vec![
467            SegmentInfo {
468                id: "a".into(),
469                num_docs: 100,
470            },
471            SegmentInfo {
472                id: "b".into(),
473                num_docs: 200,
474            },
475        ];
476
477        assert!(policy.find_merges(&segments).is_empty());
478    }
479
480    #[test]
481    fn test_tiered_policy_merge_same_size() {
482        let policy = TieredMergePolicy {
483            segments_per_tier: 3,
484            ..Default::default()
485        };
486
487        // 5 small segments — all similar size, should merge into one group
488        let segments: Vec<_> = (0..5)
489            .map(|i| SegmentInfo {
490                id: format!("seg_{}", i),
491                num_docs: 100 + i * 10,
492            })
493            .collect();
494
495        let candidates = policy.find_merges(&segments);
496        assert_eq!(candidates.len(), 1);
497        assert_eq!(candidates[0].segment_ids.len(), 5);
498    }
499
500    #[test]
501    fn test_tiered_policy_cross_tier_promotion() {
502        let policy = TieredMergePolicy {
503            segments_per_tier: 3,
504            tier_factor: 10.0,
505            tier_floor: 1000,
506            max_merge_at_once: 20,
507            max_merged_docs: 5_000_000,
508            ..Default::default()
509        };
510
511        // 4 small (tier 0) + 3 medium (tier 1) — should merge ALL into one group
512        // because the small segments accumulate and the medium ones pass the ratio check
513        let mut segments: Vec<_> = (0..4)
514            .map(|i| SegmentInfo {
515                id: format!("small_{}", i),
516                num_docs: 100 + i * 10,
517            })
518            .collect();
519        for i in 0..3 {
520            segments.push(SegmentInfo {
521                id: format!("medium_{}", i),
522                num_docs: 2000 + i * 500,
523            });
524        }
525
526        let candidates = policy.find_merges(&segments);
527        assert_eq!(
528            candidates.len(),
529            1,
530            "should merge all into one cross-tier group"
531        );
532        assert_eq!(
533            candidates[0].segment_ids.len(),
534            7,
535            "all 7 segments should be in the merge"
536        );
537    }
538
539    #[test]
540    fn test_tiered_policy_ratio_guard_separates_groups() {
541        let policy = TieredMergePolicy {
542            segments_per_tier: 3,
543            tier_factor: 10.0,
544            tier_floor: 100,
545            max_merge_at_once: 20,
546            max_merged_docs: 5_000_000,
547            ..Default::default()
548        };
549
550        // 4 tiny (10 docs) + 4 large (100_000 docs)
551        // Ratio guard should prevent merging tiny with large:
552        // group total after 4 tiny = 40, effective = max(40, 100) = 100
553        // next segment is 100_000 > 100 * 10 = 1000 → blocked
554        // So tiny segments (4) form one group, large segments (4) form another.
555        let mut segments: Vec<_> = (0..4)
556            .map(|i| SegmentInfo {
557                id: format!("tiny_{}", i),
558                num_docs: 10,
559            })
560            .collect();
561        for i in 0..4 {
562            segments.push(SegmentInfo {
563                id: format!("large_{}", i),
564                num_docs: 100_000 + i * 100,
565            });
566        }
567
568        let candidates = policy.find_merges(&segments);
569        assert_eq!(candidates.len(), 2, "should produce two separate groups");
570
571        // First group: the 4 tiny segments
572        assert_eq!(candidates[0].segment_ids.len(), 4);
573        assert!(candidates[0].segment_ids[0].starts_with("tiny_"));
574
575        // Second group: the 4 large segments
576        assert_eq!(candidates[1].segment_ids.len(), 4);
577        assert!(candidates[1].segment_ids[0].starts_with("large_"));
578    }
579
580    #[test]
581    fn test_tiered_policy_small_segments_skip_to_large_group() {
582        let policy = TieredMergePolicy {
583            segments_per_tier: 3,
584            tier_factor: 10.0,
585            tier_floor: 1000,
586            max_merge_at_once: 10,
587            max_merged_docs: 5_000_000,
588            ..Default::default()
589        };
590
591        // 2 tiny segments (can't form a group) + 5 medium segments (can)
592        // The tiny segments should be skipped, and the medium ones should merge.
593        let mut segments = vec![
594            SegmentInfo {
595                id: "tiny_0".into(),
596                num_docs: 10,
597            },
598            SegmentInfo {
599                id: "tiny_1".into(),
600                num_docs: 20,
601            },
602        ];
603        for i in 0..5 {
604            segments.push(SegmentInfo {
605                id: format!("medium_{}", i),
606                num_docs: 5000 + i * 100,
607            });
608        }
609
610        let candidates = policy.find_merges(&segments);
611        assert!(
612            !candidates.is_empty(),
613            "should find a merge even though tiny segments can't form a group"
614        );
615        // The medium segments should be merged (possibly with the tiny ones bridging in)
616        let total_segs: usize = candidates.iter().map(|c| c.segment_ids.len()).sum();
617        assert!(
618            total_segs >= 5,
619            "should merge at least the 5 medium segments"
620        );
621    }
622
623    #[test]
624    fn test_tiered_policy_respects_max_merged_docs() {
625        let policy = TieredMergePolicy {
626            segments_per_tier: 3,
627            max_merge_at_once: 100,
628            tier_factor: 10.0,
629            tier_floor: 1000,
630            max_merged_docs: 500,
631            ..Default::default()
632        };
633
634        // 10 segments of 100 docs each — total would be 1000 but max_merged_docs=500
635        let segments: Vec<_> = (0..10)
636            .map(|i| SegmentInfo {
637                id: format!("seg_{}", i),
638                num_docs: 100,
639            })
640            .collect();
641
642        let candidates = policy.find_merges(&segments);
643        for c in &candidates {
644            let total: u64 = c
645                .segment_ids
646                .iter()
647                .map(|id| segments.iter().find(|s| s.id == *id).unwrap().num_docs as u64)
648                .sum();
649            assert!(
650                total <= 500,
651                "merge total {} exceeds max_merged_docs 500",
652                total
653            );
654        }
655    }
656
657    #[test]
658    fn test_tiered_policy_large_segment_not_remerged_with_small() {
659        // Simulates the user scenario: after merging, we have one large segment
660        // and a few new small segments from recent commits. The large segment
661        // should NOT be re-merged — only the small ones should merge together
662        // once there are enough of them.
663        let policy = TieredMergePolicy::default(); // segments_per_tier=10
664
665        // 1 large segment (from previous merge) + 5 new small segments
666        let mut segments = vec![SegmentInfo {
667            id: "large_merged".into(),
668            num_docs: 50_000,
669        }];
670        for i in 0..5 {
671            segments.push(SegmentInfo {
672                id: format!("new_{}", i),
673                num_docs: 500,
674            });
675        }
676
677        // Should NOT merge: only 5 small segments (< segments_per_tier=10),
678        // and the large segment is too big to join their group.
679        let candidates = policy.find_merges(&segments);
680        assert!(
681            candidates.is_empty(),
682            "should not re-merge large segment with 5 small ones: {:?}",
683            candidates
684        );
685
686        // Now add 5 more small segments (total 10) — those should merge together,
687        // but the large segment should still be excluded.
688        for i in 5..10 {
689            segments.push(SegmentInfo {
690                id: format!("new_{}", i),
691                num_docs: 500,
692            });
693        }
694
695        let candidates = policy.find_merges(&segments);
696        assert_eq!(candidates.len(), 1, "should merge the 10 small segments");
697        assert!(
698            !candidates[0].segment_ids.contains(&"large_merged".into()),
699            "large segment must NOT be in the merge group"
700        );
701        assert_eq!(
702            candidates[0].segment_ids.len(),
703            10,
704            "all 10 small segments should be merged"
705        );
706    }
707
708    #[test]
709    fn test_no_merge_policy() {
710        let policy = NoMergePolicy;
711
712        let segments = vec![
713            SegmentInfo {
714                id: "a".into(),
715                num_docs: 100,
716            },
717            SegmentInfo {
718                id: "b".into(),
719                num_docs: 200,
720            },
721        ];
722
723        assert!(policy.find_merges(&segments).is_empty());
724    }
725
726    #[test]
727    fn test_oversized_exclusion() {
728        // max_merged_docs=1M, oversized_threshold=0.5 → exclude segments > 500K
729        let policy = TieredMergePolicy {
730            segments_per_tier: 3,
731            max_merged_docs: 1_000_000,
732            oversized_threshold: 0.5,
733            ..Default::default()
734        };
735
736        // 4 small segments + 2 oversized segments (600K each)
737        let mut segments: Vec<_> = (0..4)
738            .map(|i| SegmentInfo {
739                id: format!("small_{}", i),
740                num_docs: 1000,
741            })
742            .collect();
743        segments.push(SegmentInfo {
744            id: "oversized_0".into(),
745            num_docs: 600_000,
746        });
747        segments.push(SegmentInfo {
748            id: "oversized_1".into(),
749            num_docs: 700_000,
750        });
751
752        let candidates = policy.find_merges(&segments);
753        // Oversized segments must not appear in any merge candidate
754        for c in &candidates {
755            assert!(
756                !c.segment_ids.contains(&"oversized_0".into()),
757                "oversized_0 should be excluded"
758            );
759            assert!(
760                !c.segment_ids.contains(&"oversized_1".into()),
761                "oversized_1 should be excluded"
762            );
763        }
764    }
765
766    #[test]
767    fn test_budget_trigger_prevents_unnecessary_merge() {
768        let policy = TieredMergePolicy {
769            segments_per_tier: 10,
770            tier_factor: 10.0,
771            tier_floor: 1000,
772            floor_segment_docs: 1000,
773            budget_trigger: true,
774            ..Default::default()
775        };
776
777        // 5 segments of 10K docs each = 50K total
778        // Budget: ceil(log10(50K/1000)) = ceil(log10(50)) = 2 tiers → 2*10 = 20 ideal segments
779        // 5 segments < 20 ideal → should NOT merge
780        let segments: Vec<_> = (0..5)
781            .map(|i| SegmentInfo {
782                id: format!("seg_{}", i),
783                num_docs: 10_000,
784            })
785            .collect();
786
787        let candidates = policy.find_merges(&segments);
788        assert!(
789            candidates.is_empty(),
790            "should not merge when under budget: {:?}",
791            candidates
792        );
793    }
794
795    #[test]
796    fn test_budget_trigger_allows_merge_when_over_budget() {
797        let policy = TieredMergePolicy {
798            segments_per_tier: 3,
799            tier_factor: 10.0,
800            tier_floor: 1000,
801            floor_segment_docs: 1000,
802            budget_trigger: true,
803            ..Default::default()
804        };
805
806        // 10 segments of 1000 docs = 10K total
807        // Budget: ceil(log10(10K/1000)) = 1 tier → 1*3 = 3 ideal segments
808        // 10 segments > 3 ideal → should merge
809        let segments: Vec<_> = (0..10)
810            .map(|i| SegmentInfo {
811                id: format!("seg_{}", i),
812                num_docs: 1000,
813            })
814            .collect();
815
816        let candidates = policy.find_merges(&segments);
817        assert!(!candidates.is_empty(), "should merge when over budget");
818    }
819
820    #[test]
821    fn test_min_growth_ratio_rejects_wasteful_merge() {
822        let policy = TieredMergePolicy {
823            segments_per_tier: 3,
824            min_growth_ratio: 0.5,
825            max_merge_at_once: 10,
826            ..Default::default()
827        };
828
829        // 1 large segment (100K) + 3 tiny segments (10 docs each)
830        // Total = 100_030, largest = 100_000
831        // Growth check: 100_030 >= 1.5 * 100_000 = 150_000? NO → reject
832        let mut segments = vec![SegmentInfo {
833            id: "big".into(),
834            num_docs: 100_000,
835        }];
836        for i in 0..3 {
837            segments.push(SegmentInfo {
838                id: format!("tiny_{}", i),
839                num_docs: 10,
840            });
841        }
842
843        let candidates = policy.find_merges(&segments);
844        // The 3 tiny segments alone can form a group, but the big one shouldn't
845        // be merged with them. Let's verify no candidate includes "big".
846        for c in &candidates {
847            if c.segment_ids.contains(&"big".into()) {
848                let total: u64 = c
849                    .segment_ids
850                    .iter()
851                    .map(|id| segments.iter().find(|s| s.id == *id).unwrap().num_docs as u64)
852                    .sum();
853                let largest: u64 = c
854                    .segment_ids
855                    .iter()
856                    .map(|id| segments.iter().find(|s| s.id == *id).unwrap().num_docs as u64)
857                    .max()
858                    .unwrap();
859                assert!(
860                    total as f64 >= 1.5 * largest as f64,
861                    "merge with 'big' segment violates min_growth_ratio: total={}, largest={}",
862                    total,
863                    largest
864                );
865            }
866        }
867    }
868
869    #[test]
870    fn test_scored_selection_prefers_balanced_merge() {
871        let policy = TieredMergePolicy {
872            segments_per_tier: 3,
873            max_merge_at_once: 5,
874            scored_selection: true,
875            ..Default::default()
876        };
877
878        // Group A: 3 balanced segments (1000, 1100, 1200)
879        // Group B: 3 unbalanced segments (100, 100, 5000) — placed so greedy would pick them first
880        let segments = vec![
881            SegmentInfo {
882                id: "unbal_0".into(),
883                num_docs: 100,
884            },
885            SegmentInfo {
886                id: "unbal_1".into(),
887                num_docs: 100,
888            },
889            SegmentInfo {
890                id: "bal_0".into(),
891                num_docs: 1000,
892            },
893            SegmentInfo {
894                id: "bal_1".into(),
895                num_docs: 1100,
896            },
897            SegmentInfo {
898                id: "bal_2".into(),
899                num_docs: 1200,
900            },
901            SegmentInfo {
902                id: "unbal_2".into(),
903                num_docs: 5000,
904            },
905        ];
906
907        let candidates = policy.find_merges(&segments);
908        assert!(!candidates.is_empty(), "should find at least one merge");
909
910        // The first candidate (best score) should be the balanced group
911        let first = &candidates[0];
912        let has_balanced = first.segment_ids.iter().any(|id| id.starts_with("bal_"));
913        assert!(
914            has_balanced,
915            "scored selection should prefer balanced group, got: {:?}",
916            first.segment_ids
917        );
918    }
919
920    #[test]
921    fn test_large_scale_preset_values() {
922        let p = TieredMergePolicy::large_scale();
923        assert_eq!(p.tier_floor, 50_000);
924        assert_eq!(p.max_merged_docs, 20_000_000);
925        assert_eq!(p.max_segment_docs, 20_000_000);
926        assert_eq!(p.floor_segment_docs, 50_000);
927        assert!(p.budget_trigger);
928        assert!(p.scored_selection);
929        assert_eq!(p.segments_per_tier, 10);
930        assert!((p.min_growth_ratio - 0.5).abs() < f64::EPSILON);
931        assert!((p.oversized_threshold - 0.5).abs() < f64::EPSILON);
932    }
933
934    #[test]
935    fn test_bulk_indexing_preset_values() {
936        let p = TieredMergePolicy::bulk_indexing();
937        assert_eq!(p.segments_per_tier, 20);
938        assert_eq!(p.max_merge_at_once, 20);
939        assert_eq!(p.tier_floor, 100_000);
940        assert_eq!(p.max_merged_docs, 50_000_000);
941        assert_eq!(p.max_segment_docs, 50_000_000);
942        assert_eq!(p.floor_segment_docs, 100_000);
943        assert!(p.budget_trigger);
944        assert!(p.scored_selection);
945        assert!((p.min_growth_ratio - 0.75).abs() < f64::EPSILON);
946    }
947
948    #[test]
949    fn test_default_max_segment_docs() {
950        let p = TieredMergePolicy::default();
951        assert_eq!(p.max_segment_docs, 10_000_000);
952        assert_eq!(p.max_segment_docs().unwrap(), 10_000_000);
953    }
954
955    #[test]
956    fn test_max_segment_docs_caps_merge_output() {
957        // max_merged_docs=50M but max_segment_docs=5M → effective max is 5M
958        let policy = TieredMergePolicy {
959            segments_per_tier: 3,
960            max_merge_at_once: 100,
961            max_merged_docs: 50_000_000,
962            max_segment_docs: 5_000_000,
963            ..Default::default()
964        };
965
966        // 10 segments of 1M each — total would be 10M but max_segment_docs=5M
967        let segments: Vec<_> = (0..10)
968            .map(|i| SegmentInfo {
969                id: format!("seg_{}", i),
970                num_docs: 1_000_000,
971            })
972            .collect();
973
974        let candidates = policy.find_merges(&segments);
975        for c in &candidates {
976            let total: u64 = c
977                .segment_ids
978                .iter()
979                .map(|id| segments.iter().find(|s| s.id == *id).unwrap().num_docs as u64)
980                .sum();
981            assert!(
982                total <= 5_000_000,
983                "merge total {} exceeds max_segment_docs 5M",
984                total
985            );
986        }
987    }
988}