Skip to main content

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