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}