Skip to main content

reddb_server/storage/index/
stats.rs

1//! Index statistics and kind enumeration surfaced by [`super::IndexBase`].
2//!
3//! Separated from the traits so the planner and diagnostics layers can depend
4//! on the types without pulling in the generic trait definitions.
5
6/// Enumeration of all index families RedDB understands. New structures add a
7/// variant here so the planner can match on them and pick cost models.
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
9pub enum IndexKind {
10    /// Placeholder used by `IndexStats::default()`.
11    #[default]
12    Unknown,
13    /// Ordered B-tree / B+ tree (range + point).
14    BTree,
15    /// Hash index (point-only, O(1)).
16    Hash,
17    /// Bitmap index (low-cardinality, set operations).
18    Bitmap,
19    /// Roaring bitmap compressed variant.
20    RoaringBitmap,
21    /// Inverted index for full-text / tokenised lookups.
22    Inverted,
23    /// HNSW approximate nearest-neighbour.
24    Hnsw,
25    /// IVF-Flat vector index.
26    IvfFlat,
27    /// Product-quantised vector index.
28    ProductQuantization,
29    /// Adjacency index for graph edges.
30    GraphAdjacency,
31    /// Cuckoo filter.
32    Cuckoo,
33    /// Zone map / min-max summary.
34    ZoneMap,
35    /// Cross-structure unified reference index.
36    UnifiedRef,
37    /// Count-min sketch heavy-hitters (top-k frequency estimate).
38    HeavyHitters,
39}
40
41impl IndexKind {
42    /// Does this kind support range queries out of the box?
43    pub fn supports_range(self) -> bool {
44        matches!(self, IndexKind::BTree | IndexKind::ZoneMap)
45    }
46
47    /// Does this kind support approximate / similarity queries?
48    pub fn supports_ann(self) -> bool {
49        matches!(
50            self,
51            IndexKind::Hnsw | IndexKind::IvfFlat | IndexKind::ProductQuantization
52        )
53    }
54}
55
56/// Per-index statistics used by the cost-based planner and diagnostics.
57/// All fields are best-effort; zero means "unknown".
58#[derive(Debug, Clone, Default)]
59pub struct IndexStats {
60    /// Total number of `(key, value)` pairs stored.
61    pub entries: usize,
62    /// Number of distinct keys. For hash/btree this equals the key set size.
63    pub distinct_keys: usize,
64    /// Approximate memory footprint in bytes (0 if not tracked).
65    pub approx_bytes: usize,
66    /// Family of index this originates from.
67    pub kind: IndexKind,
68    /// Whether a bloom filter is attached (enables fast negative lookups).
69    pub has_bloom: bool,
70    /// Physical correlation between index key order and heap row order.
71    /// 1.0 = perfectly correlated (monotonic insert, timeseries) → sequential I/O.
72    /// 0.0 = completely random → worst-case random I/O per row.
73    /// Default 0.0 (conservative). Used by Mackert-Lohman I/O cost formula.
74    pub index_correlation: f64,
75}
76
77impl IndexStats {
78    /// Rough selectivity estimate for an equality predicate. Returns the
79    /// expected fraction of rows matching a random key, clamped to `[0, 1]`.
80    ///
81    /// Used by the planner to pick between index probes and full scans.
82    pub fn point_selectivity(&self) -> f64 {
83        if self.distinct_keys == 0 {
84            return 1.0;
85        }
86        (1.0 / self.distinct_keys as f64).clamp(0.0, 1.0)
87    }
88
89    /// Average number of values per distinct key.
90    pub fn avg_values_per_key(&self) -> f64 {
91        if self.distinct_keys == 0 {
92            return 0.0;
93        }
94        self.entries as f64 / self.distinct_keys as f64
95    }
96
97    /// Estimate the I/O cost (in arbitrary page-cost units) of fetching
98    /// `result_rows` rows via this index from a `heap_pages`-page table.
99    ///
100    /// Uses the Mackert-Lohman (1986) formula — the same model PostgreSQL
101    /// uses in `cost_index` (`optimizer/path/costsize.c:545-700`):
102    ///
103    /// ```text
104    /// pages_fetched = ML(selectivity, heap_pages)
105    /// io_cost = lerp(random_io, seq_io, correlation²)
106    /// ```
107    ///
108    /// Constants match PG GUC defaults:
109    /// - `random_page_cost = 4.0`
110    /// - `seq_page_cost    = 1.0`
111    pub fn correlated_io_cost(&self, result_rows: f64, heap_pages: f64) -> f64 {
112        const SEQ_PAGE_COST: f64 = 1.0;
113        const RANDOM_PAGE_COST: f64 = 4.0;
114
115        if heap_pages <= 0.0 || result_rows <= 0.0 {
116            return 0.0;
117        }
118
119        // Mackert-Lohman: expected distinct pages fetched when picking
120        // `result_rows` rows at random from a `heap_pages`-page file.
121        // Approximation: min(result_rows, heap_pages) * (1 - e^(-result_rows/heap_pages))
122        // This is the standard finite-population coupon-collector formula.
123        let frac = result_rows / heap_pages;
124        let pages_fetched = heap_pages * (1.0 - (-frac).exp());
125        let pages_fetched = pages_fetched.min(heap_pages);
126
127        // Random I/O: every page fetch is a seek
128        let random_cost = RANDOM_PAGE_COST * pages_fetched;
129
130        // Sequential I/O: rows arrive in heap order (correlation ≈ 1)
131        let seq_cost = SEQ_PAGE_COST * pages_fetched;
132
133        // Blend: correlation² weights sequential vs random
134        let corr2 = self.index_correlation.powi(2).clamp(0.0, 1.0);
135        seq_cost * corr2 + random_cost * (1.0 - corr2)
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    #[test]
144    fn selectivity_scales_with_cardinality() {
145        let s = IndexStats {
146            entries: 100,
147            distinct_keys: 100,
148            approx_bytes: 0,
149            kind: IndexKind::Hash,
150            has_bloom: false,
151            index_correlation: 0.0,
152        };
153        assert_eq!(s.point_selectivity(), 0.01);
154        assert_eq!(s.avg_values_per_key(), 1.0);
155    }
156
157    #[test]
158    fn range_capability_flag() {
159        assert!(IndexKind::BTree.supports_range());
160        assert!(!IndexKind::Hash.supports_range());
161        assert!(IndexKind::Hnsw.supports_ann());
162    }
163
164    #[test]
165    fn empty_stats_do_not_divide_by_zero() {
166        let s = IndexStats::default();
167        assert_eq!(s.point_selectivity(), 1.0);
168        assert_eq!(s.avg_values_per_key(), 0.0);
169    }
170
171    #[test]
172    fn correlated_io_cheaper_than_random() {
173        let base = IndexStats {
174            entries: 10_000,
175            distinct_keys: 10_000,
176            approx_bytes: 0,
177            kind: IndexKind::BTree,
178            has_bloom: false,
179            index_correlation: 0.0,
180        };
181        let correlated = IndexStats {
182            index_correlation: 1.0,
183            ..base.clone()
184        };
185        let heap_pages = 1000.0;
186        let result_rows = 100.0;
187
188        let random_cost = base.correlated_io_cost(result_rows, heap_pages);
189        let seq_cost = correlated.correlated_io_cost(result_rows, heap_pages);
190        assert!(
191            seq_cost < random_cost,
192            "correlated (seq) should be cheaper than uncorrelated (random): {seq_cost} vs {random_cost}"
193        );
194    }
195}