Skip to main content

zeppelin_embed/tier/
policy.rs

1//! Pure per-segment tier decisions.
2
3use super::{SegmentTier, TierThresholds};
4use crate::graph::refine::{RefinementPass, RefinementPasses};
5
6/// Policy inputs derived from one segment and its currently published artifacts.
7#[derive(Clone, Copy, Debug, Eq, PartialEq)]
8pub struct SegmentStats {
9    /// Tier the segment actually has now.
10    pub tier: SegmentTier,
11    /// Dense row count.
12    pub row_count: u32,
13    /// Logical vector dimensions.
14    pub dimensions: u32,
15    /// Permanent quantization scheme identifier.
16    pub scheme: u16,
17}
18
19/// Store-wide inputs derived from manifest metadata without segment I/O.
20#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
21pub struct StoreStats {
22    /// Published-alias sealed segments that already carry a graph.
23    pub graph_segment_count: u32,
24    /// Total dense rows across those graph segments.
25    pub total_graph_rows: u64,
26    /// Dense rows of the largest single graph segment (manifest metadata only).
27    pub largest_graph_segment_rows: u64,
28    /// Published sealed segments that do not yet carry a graph.
29    pub scan_segment_count: u32,
30    /// Total dense rows across sealed scan segments.
31    pub total_scan_rows: u64,
32    /// Graph eligibility threshold for the scan-segment geometry.
33    pub scan_graph_min_rows: u32,
34}
35
36/// Graph segment count at which consolidation is always due.
37///
38/// Owner policy A4 (2026-08-22 amendment recorded in
39/// `tasks/19-vector-graph-tier.md`): multi-segment shared-bound search is a
40/// transition state, not the steady state.
41pub const CONSOLIDATE_MIN_SEGMENTS: u32 = 3;
42
43/// At exactly two graph segments, consolidate unless one segment already
44/// holds at least this many tenths of the total graph rows.
45pub const CONSOLIDATE_DOMINANT_TENTHS: u128 = 7;
46
47/// Pure store-level policy result for graph-segment consolidation.
48#[derive(Clone, Copy, Debug, Eq, PartialEq)]
49pub enum StorePlan {
50    /// Keep the published graph segment set.
51    Stay,
52    /// Merge every published graph segment into one new sealed graph segment.
53    Consolidate,
54    /// Merge sealed scan segments into one graph-eligible segment.
55    ConsolidateScan,
56}
57
58/// Pure per-graph decision for the next catalog-ordered refinement.
59#[derive(Clone, Copy, Debug, Eq, PartialEq)]
60pub enum RefinementPlan {
61    /// Every M9 pass is already recorded on the graph.
62    Stay,
63    /// Apply the first catalog-ranked pass not yet recorded.
64    Apply(RefinementPass),
65}
66
67/// Selects the first unstamped M9 pass in catalog order.
68#[must_use]
69pub fn decide_refinement(applied: RefinementPasses) -> RefinementPlan {
70    RefinementPass::ALL
71        .into_iter()
72        .find(|pass| !applied.contains(*pass))
73        .map_or(RefinementPlan::Stay, RefinementPlan::Apply)
74}
75
76/// Decides whether the store's sealed graph segments should merge into one.
77///
78/// Consolidation is due at [`CONSOLIDATE_MIN_SEGMENTS`] or more graph
79/// segments, or at exactly two when neither segment holds at least 70% of
80/// the total graph rows. The decision consults manifest metadata only.
81#[must_use]
82pub const fn decide_store(store: StoreStats) -> StorePlan {
83    if store.graph_segment_count >= CONSOLIDATE_MIN_SEGMENTS {
84        return StorePlan::Consolidate;
85    }
86    if store.graph_segment_count == 2 {
87        let largest = store.largest_graph_segment_rows as u128;
88        let total = store.total_graph_rows as u128;
89        if largest * 10 < total * CONSOLIDATE_DOMINANT_TENTHS {
90            return StorePlan::Consolidate;
91        }
92    }
93    if store.scan_segment_count >= 2
94        && store.scan_graph_min_rows != 0
95        && store.total_scan_rows >= store.scan_graph_min_rows as u64
96    {
97        return StorePlan::ConsolidateScan;
98    }
99    StorePlan::Stay
100}
101
102/// Pure policy result for one segment.
103#[derive(Clone, Copy, Debug, Eq, PartialEq)]
104pub enum TierPlan {
105    /// Keep using the segment's published tier.
106    Stay(SegmentTier),
107    /// Build and atomically publish the named one-way transition.
108    Transition {
109        /// Published tier before maintenance.
110        from: SegmentTier,
111        /// Tier earned after maintenance.
112        to: SegmentTier,
113    },
114}
115
116/// Decides one segment's desired tier without consulting clocks or performing I/O.
117#[must_use]
118pub const fn decide(segment: SegmentStats, _store: StoreStats) -> TierPlan {
119    let thresholds = super::thresholds::for_bucket(segment.dimensions, segment.scheme);
120    decide_with_thresholds(segment, thresholds)
121}
122
123pub(crate) const fn decide_with_thresholds(
124    segment: SegmentStats,
125    thresholds: TierThresholds,
126) -> TierPlan {
127    match segment.tier {
128        SegmentTier::ActiveScan => TierPlan::Stay(SegmentTier::ActiveScan),
129        SegmentTier::SealedGraph => TierPlan::Stay(SegmentTier::SealedGraph),
130        SegmentTier::SealedScan => {
131            if segment.row_count >= thresholds.graph_min_rows {
132                TierPlan::Transition {
133                    from: SegmentTier::SealedScan,
134                    to: SegmentTier::SealedGraph,
135                }
136            } else {
137                TierPlan::Stay(SegmentTier::SealedScan)
138            }
139        }
140    }
141}
142
143#[cfg(test)]
144mod tests {
145    use super::{SegmentStats, StorePlan, StoreStats, TierPlan, decide, decide_store};
146    use crate::tier::{PROVISIONAL_TIER_THRESHOLDS, SegmentTier};
147
148    #[test]
149    fn policy_decides_graph_above_the_threshold_and_scan_below() {
150        let threshold = PROVISIONAL_TIER_THRESHOLDS.graph_min_rows;
151        let below = SegmentStats {
152            tier: SegmentTier::SealedScan,
153            row_count: threshold - 1,
154            dimensions: 128,
155            scheme: 4,
156        };
157        let above = SegmentStats {
158            row_count: threshold,
159            ..below
160        };
161
162        assert_eq!(
163            decide(below, StoreStats::default()),
164            TierPlan::Stay(SegmentTier::SealedScan)
165        );
166        assert_eq!(
167            decide(above, StoreStats::default()),
168            TierPlan::Transition {
169                from: SegmentTier::SealedScan,
170                to: SegmentTier::SealedGraph,
171            }
172        );
173    }
174
175    #[test]
176    fn policy_never_demotes_a_segment_that_already_has_a_graph() {
177        let segment = SegmentStats {
178            tier: SegmentTier::SealedGraph,
179            row_count: PROVISIONAL_TIER_THRESHOLDS.graph_min_rows - 1,
180            dimensions: 128,
181            scheme: 4,
182        };
183
184        assert_eq!(
185            decide(segment, StoreStats::default()),
186            TierPlan::Stay(SegmentTier::SealedGraph)
187        );
188    }
189
190    #[test]
191    fn store_policy_consolidates_at_three_or_more_graph_segments() {
192        for (count, expected) in [
193            (0, StorePlan::Stay),
194            (1, StorePlan::Stay),
195            (3, StorePlan::Consolidate),
196            (4, StorePlan::Consolidate),
197            (100, StorePlan::Consolidate),
198        ] {
199            assert_eq!(
200                decide_store(StoreStats {
201                    graph_segment_count: count,
202                    total_graph_rows: 1_000_000,
203                    largest_graph_segment_rows: 999_999,
204                    ..StoreStats::default()
205                }),
206                expected,
207                "graph_segment_count={count}"
208            );
209        }
210    }
211
212    #[test]
213    fn store_policy_at_two_segments_consolidates_only_without_a_dominant_segment() {
214        // 50/50 split: no segment holds 70%, so the pair merges.
215        assert_eq!(
216            decide_store(StoreStats {
217                graph_segment_count: 2,
218                total_graph_rows: 1_000,
219                largest_graph_segment_rows: 500,
220                ..StoreStats::default()
221            }),
222            StorePlan::Consolidate
223        );
224        // 699/1000: still below the 70% dominance line.
225        assert_eq!(
226            decide_store(StoreStats {
227                graph_segment_count: 2,
228                total_graph_rows: 1_000,
229                largest_graph_segment_rows: 699,
230                ..StoreStats::default()
231            }),
232            StorePlan::Consolidate
233        );
234        // Exactly 70%: dominant, the pair stays.
235        assert_eq!(
236            decide_store(StoreStats {
237                graph_segment_count: 2,
238                total_graph_rows: 1_000,
239                largest_graph_segment_rows: 700,
240                ..StoreStats::default()
241            }),
242            StorePlan::Stay
243        );
244        // 95/5: a fresh small graph beside one consolidated graph stays.
245        assert_eq!(
246            decide_store(StoreStats {
247                graph_segment_count: 2,
248                total_graph_rows: 1_000,
249                largest_graph_segment_rows: 950,
250                ..StoreStats::default()
251            }),
252            StorePlan::Stay
253        );
254        // Row counts near u64::MAX must not overflow the tenths comparison.
255        assert_eq!(
256            decide_store(StoreStats {
257                graph_segment_count: 2,
258                total_graph_rows: u64::MAX,
259                largest_graph_segment_rows: u64::MAX / 2,
260                ..StoreStats::default()
261            }),
262            StorePlan::Consolidate
263        );
264    }
265
266    #[test]
267    fn store_policy_consolidates_scan_segments_only_after_graph_threshold() {
268        assert_eq!(
269            decide_store(StoreStats {
270                scan_segment_count: 7,
271                total_scan_rows: 28_672,
272                scan_graph_min_rows: 30_000,
273                ..StoreStats::default()
274            }),
275            StorePlan::Stay
276        );
277        assert_eq!(
278            decide_store(StoreStats {
279                scan_segment_count: 8,
280                total_scan_rows: 32_768,
281                scan_graph_min_rows: 30_000,
282                ..StoreStats::default()
283            }),
284            StorePlan::ConsolidateScan
285        );
286    }
287}