Skip to main content

reddb_server/storage/query/planner/
histogram.rs

1//! Equi-depth histograms and most-common-value lists for the planner.
2//!
3//! Mirrors PostgreSQL's `pg_statistic` histogram + MCV machinery
4//! (`src/backend/utils/adt/selfuncs.c::histogram_selectivity` and
5//! `var_eq_const`). Lets the cost estimator replace the uniform
6//! `0.3 / 0.01` heuristics with bucket-arithmetic for ranges and
7//! frequency-lookup for equality on skewed columns.
8//!
9//! The data structures are pure Rust and column-type-agnostic: a small
10//! [`ColumnValue`] enum represents the comparable subset of values
11//! (`Int`, `Float`, `Text`) — enough for the columns the planner cares
12//! about, with room to extend later. The `equi_depth_from_sample`
13//! builder constructs histograms from a pre-sampled `Vec<ColumnValue>`
14//! by sorting and partitioning.
15//!
16//! Both structures are **opt-in** through the `StatsProvider` trait
17//! (default returns `None`), so adding histograms is strictly additive
18//! — call sites that don't supply one keep using the existing
19//! heuristic path with no surprises.
20//!
21//! See `src/storage/query/planner/README.md` § Invariant 4.
22
23use std::cmp::Ordering;
24
25/// Comparable column value used by histogram bucket arithmetic and MCV
26/// frequency lookup.
27///
28/// Intentionally narrow: real reddb columns can be any AST `Value`,
29/// but only a few types are useful for selectivity estimation. Other
30/// values simply don't get histograms — `filter_selectivity` falls
31/// back to the heuristic path.
32#[derive(Debug, Clone)]
33pub enum ColumnValue {
34    Int(i64),
35    Float(f64),
36    Text(String),
37}
38
39impl ColumnValue {
40    fn cmp_inner(&self, other: &ColumnValue) -> Ordering {
41        match (self, other) {
42            (ColumnValue::Int(a), ColumnValue::Int(b)) => a.cmp(b),
43            (ColumnValue::Float(a), ColumnValue::Float(b)) => {
44                a.partial_cmp(b).unwrap_or(Ordering::Equal)
45            }
46            (ColumnValue::Text(a), ColumnValue::Text(b)) => a.cmp(b),
47            // Cross-type comparisons fall back to a stable but
48            // semantically meaningless order (Int < Float < Text). The
49            // planner never mixes types in a single histogram so this
50            // is safe in practice.
51            (ColumnValue::Int(_), _) => Ordering::Less,
52            (ColumnValue::Float(_), ColumnValue::Int(_)) => Ordering::Greater,
53            (ColumnValue::Float(_), _) => Ordering::Less,
54            (ColumnValue::Text(_), _) => Ordering::Greater,
55        }
56    }
57}
58
59impl PartialEq for ColumnValue {
60    fn eq(&self, other: &Self) -> bool {
61        self.cmp_inner(other) == Ordering::Equal
62    }
63}
64
65impl Eq for ColumnValue {}
66
67impl std::hash::Hash for ColumnValue {
68    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
69        std::mem::discriminant(self).hash(state);
70        match self {
71            ColumnValue::Int(v) => v.hash(state),
72            ColumnValue::Float(v) => v.to_bits().hash(state),
73            ColumnValue::Text(v) => v.hash(state),
74        }
75    }
76}
77
78impl PartialOrd for ColumnValue {
79    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
80        Some(std::cmp::Ord::cmp(self, other))
81    }
82}
83
84impl Ord for ColumnValue {
85    fn cmp(&self, other: &Self) -> Ordering {
86        self.cmp_inner(other)
87    }
88}
89
90/// One equi-depth bucket: every bucket holds *roughly* the same
91/// number of rows, so bucket count along the `min..max` interval
92/// estimates the row count in any range query subset.
93#[derive(Debug, Clone)]
94pub struct Bucket {
95    pub min: ColumnValue,
96    pub max: ColumnValue,
97    pub count: u64,
98}
99
100/// Equi-depth histogram over a single column.
101///
102/// Buckets are non-overlapping and sorted by `min`. Each bucket holds
103/// roughly `total_count / bucket_count` rows. Range selectivity is
104/// computed by counting buckets fully inside the query interval and
105/// approximating the partial buckets at the edges.
106#[derive(Debug, Clone)]
107pub struct Histogram {
108    pub buckets: Vec<Bucket>,
109    pub total_count: u64,
110}
111
112impl Histogram {
113    /// Build an equi-depth histogram from an in-memory sample.
114    ///
115    /// `bucket_count` is clamped between 1 and `values.len()`. The
116    /// caller is responsible for sampling — passing the full table
117    /// works but is expensive on large columns.
118    pub fn equi_depth_from_sample(mut values: Vec<ColumnValue>, bucket_count: usize) -> Self {
119        if values.is_empty() {
120            return Self {
121                buckets: Vec::new(),
122                total_count: 0,
123            };
124        }
125        let bucket_count = bucket_count.clamp(1, values.len());
126        values.sort();
127        let total = values.len();
128        let per_bucket = total / bucket_count;
129        let mut remainder = total % bucket_count;
130        let mut buckets = Vec::with_capacity(bucket_count);
131        let mut idx = 0;
132        for _ in 0..bucket_count {
133            let take = if remainder > 0 {
134                remainder -= 1;
135                per_bucket + 1
136            } else {
137                per_bucket
138            };
139            if take == 0 {
140                break;
141            }
142            let end = (idx + take).min(values.len());
143            let min = values[idx].clone();
144            let max = values[end - 1].clone();
145            buckets.push(Bucket {
146                min,
147                max,
148                count: take as u64,
149            });
150            idx = end;
151        }
152        Self {
153            buckets,
154            total_count: total as u64,
155        }
156    }
157
158    /// Estimate the fraction of rows whose value falls in
159    /// `[lo, hi]` (both bounds inclusive when present).
160    ///
161    /// `None` for either bound means "open" — `[lo..]` for upper-open,
162    /// `[..hi]` for lower-open, and `None`/`None` returns `1.0`
163    /// (every row qualifies).
164    ///
165    /// Returns a value clamped to `[0.0, 1.0]`.
166    pub fn range_selectivity(&self, lo: Option<&ColumnValue>, hi: Option<&ColumnValue>) -> f64 {
167        if self.total_count == 0 || self.buckets.is_empty() {
168            return 1.0;
169        }
170        let mut matched: f64 = 0.0;
171        for bucket in &self.buckets {
172            let bucket_size = bucket.count as f64;
173            // Determine the overlap of [bucket.min, bucket.max] with
174            // [lo, hi]. Both intervals are inclusive on each side.
175            let bucket_below_query = hi.is_some() && bucket.min > *hi.unwrap();
176            let bucket_above_query = lo.is_some() && bucket.max < *lo.unwrap();
177            if bucket_below_query || bucket_above_query {
178                continue;
179            }
180            // Bucket fully inside the query?
181            let fully_inside_low = lo.is_none() || bucket.min >= *lo.unwrap();
182            let fully_inside_high = hi.is_none() || bucket.max <= *hi.unwrap();
183            if fully_inside_low && fully_inside_high {
184                matched += bucket_size;
185                continue;
186            }
187            // Partial overlap. Use a simple linear-interpolation fraction
188            // assuming uniform distribution within the bucket. We
189            // approximate the fraction as 0.5 since reddb columns can
190            // be non-numeric (text), where linear interpolation isn't
191            // defined. This matches postgres' fallback for non-numeric
192            // histograms.
193            matched += bucket_size * 0.5;
194        }
195        let result = matched / self.total_count as f64;
196        result.clamp(0.0, 1.0)
197    }
198
199    /// Number of buckets in the histogram.
200    pub fn bucket_count(&self) -> usize {
201        self.buckets.len()
202    }
203}
204
205/// Most-common-values list for a column.
206///
207/// Each entry is `(value, frequency)` where frequency is in `[0, 1]`
208/// — the fraction of total rows holding that exact value. Use for
209/// equality selectivity on skewed columns where one or two values
210/// dominate.
211#[derive(Debug, Clone, Default)]
212pub struct MostCommonValues {
213    pub values: Vec<(ColumnValue, f64)>,
214}
215
216impl MostCommonValues {
217    /// Construct from a `(value, frequency)` slice. Frequencies are
218    /// expected to already be in `[0, 1]`; the constructor sorts by
219    /// frequency descending so callers can `.values[0]` to get the
220    /// hottest key.
221    pub fn new(mut entries: Vec<(ColumnValue, f64)>) -> Self {
222        entries.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(Ordering::Equal));
223        Self { values: entries }
224    }
225
226    /// Lookup `value` in the MCV list and return its frequency, or
227    /// `None` if it isn't tracked.
228    pub fn frequency_of(&self, value: &ColumnValue) -> Option<f64> {
229        self.values
230            .iter()
231            .find(|(v, _)| v == value)
232            .map(|(_, f)| *f)
233    }
234
235    /// Sum of all tracked frequencies (always `<= 1.0` if constructed
236    /// from a valid sample).
237    pub fn total_frequency(&self) -> f64 {
238        self.values.iter().map(|(_, f)| *f).sum()
239    }
240
241    /// Number of MCVs tracked.
242    pub fn len(&self) -> usize {
243        self.values.len()
244    }
245
246    /// Whether the MCV list is empty.
247    pub fn is_empty(&self) -> bool {
248        self.values.is_empty()
249    }
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255
256    fn ints(vals: &[i64]) -> Vec<ColumnValue> {
257        vals.iter().map(|&i| ColumnValue::Int(i)).collect()
258    }
259
260    #[test]
261    fn empty_sample_produces_empty_histogram() {
262        let h = Histogram::equi_depth_from_sample(vec![], 4);
263        assert_eq!(h.bucket_count(), 0);
264        assert_eq!(h.total_count, 0);
265        // Empty histogram is treated as "no information" → returns 1.0.
266        assert_eq!(h.range_selectivity(None, None), 1.0);
267    }
268
269    #[test]
270    fn equi_depth_buckets_are_roughly_equal_count() {
271        let sample: Vec<ColumnValue> = (0..100i64).map(ColumnValue::Int).collect();
272        let h = Histogram::equi_depth_from_sample(sample, 10);
273        assert_eq!(h.bucket_count(), 10);
274        assert_eq!(h.total_count, 100);
275        for bucket in &h.buckets {
276            assert_eq!(bucket.count, 10);
277        }
278    }
279
280    #[test]
281    fn equi_depth_distributes_remainder() {
282        // 13 values into 4 buckets → some buckets get an extra.
283        let sample: Vec<ColumnValue> = (0..13i64).map(ColumnValue::Int).collect();
284        let h = Histogram::equi_depth_from_sample(sample, 4);
285        assert_eq!(h.bucket_count(), 4);
286        let total: u64 = h.buckets.iter().map(|b| b.count).sum();
287        assert_eq!(total, 13);
288        // First buckets get the remainder (4, 4, 4, ... wait 13/4 = 3 r 1)
289        // → first bucket gets 4, rest get 3.
290        let counts: Vec<u64> = h.buckets.iter().map(|b| b.count).collect();
291        assert_eq!(counts.iter().sum::<u64>(), 13);
292        assert!(counts.iter().all(|&c| c >= 3 && c <= 4));
293    }
294
295    #[test]
296    fn range_selectivity_full_table_is_one() {
297        let h = Histogram::equi_depth_from_sample(ints(&[1, 2, 3, 4, 5]), 5);
298        // No bounds → entire table.
299        assert_eq!(h.range_selectivity(None, None), 1.0);
300    }
301
302    #[test]
303    fn range_selectivity_below_min_is_zero() {
304        let h = Histogram::equi_depth_from_sample(ints(&[10, 20, 30, 40]), 4);
305        let zero = ColumnValue::Int(0);
306        let five = ColumnValue::Int(5);
307        let s = h.range_selectivity(Some(&zero), Some(&five));
308        assert_eq!(s, 0.0);
309    }
310
311    #[test]
312    fn range_selectivity_above_max_is_zero() {
313        let h = Histogram::equi_depth_from_sample(ints(&[10, 20, 30, 40]), 4);
314        let hi = ColumnValue::Int(100);
315        let lo = ColumnValue::Int(50);
316        let s = h.range_selectivity(Some(&lo), Some(&hi));
317        assert_eq!(s, 0.0);
318    }
319
320    #[test]
321    fn histogram_beats_uniform_on_skewed_range() {
322        // 80% of values in [0, 9], 20% in [10, 1000].
323        let mut sample: Vec<ColumnValue> = Vec::new();
324        for i in 0..80 {
325            sample.push(ColumnValue::Int(i % 10));
326        }
327        for i in 0..20 {
328            sample.push(ColumnValue::Int(10 + i * 50));
329        }
330        let h = Histogram::equi_depth_from_sample(sample, 10);
331        // Query: value <= 9. Histogram should report ~0.8 (the heavy
332        // half), which is far better than the heuristic 0.3.
333        let nine = ColumnValue::Int(9);
334        let s = h.range_selectivity(None, Some(&nine));
335        assert!(s > 0.5, "histogram selectivity {} should exceed 0.5", s);
336        assert!(s <= 1.0);
337    }
338
339    #[test]
340    fn range_selectivity_clamped_to_unit_interval() {
341        let h = Histogram::equi_depth_from_sample(ints(&[1, 2, 3]), 3);
342        // Spurious bounds: completely unmatchable.
343        let lo = ColumnValue::Int(99);
344        let hi = ColumnValue::Int(100);
345        let s = h.range_selectivity(Some(&lo), Some(&hi));
346        assert!((0.0..=1.0).contains(&s));
347        assert_eq!(s, 0.0);
348    }
349
350    // ---------- MostCommonValues -----------------------------------
351
352    #[test]
353    fn mcv_frequency_lookup() {
354        let mcv = MostCommonValues::new(vec![
355            (ColumnValue::Int(7), 0.5),
356            (ColumnValue::Int(42), 0.3),
357            (ColumnValue::Int(99), 0.05),
358        ]);
359        assert_eq!(mcv.frequency_of(&ColumnValue::Int(7)), Some(0.5));
360        assert_eq!(mcv.frequency_of(&ColumnValue::Int(42)), Some(0.3));
361        assert!(mcv.frequency_of(&ColumnValue::Int(0)).is_none());
362    }
363
364    #[test]
365    fn mcv_total_frequency_sums_correctly() {
366        let mcv = MostCommonValues::new(vec![
367            (ColumnValue::Int(1), 0.4),
368            (ColumnValue::Int(2), 0.3),
369            (ColumnValue::Int(3), 0.2),
370        ]);
371        let total = mcv.total_frequency();
372        assert!((total - 0.9).abs() < 1e-9);
373    }
374
375    #[test]
376    fn mcv_sorts_by_frequency_descending() {
377        let mcv = MostCommonValues::new(vec![
378            (ColumnValue::Int(1), 0.1),
379            (ColumnValue::Int(2), 0.5),
380            (ColumnValue::Int(3), 0.2),
381        ]);
382        assert_eq!(mcv.values[0].1, 0.5);
383        assert_eq!(mcv.values[1].1, 0.2);
384        assert_eq!(mcv.values[2].1, 0.1);
385    }
386
387    #[test]
388    fn mcv_beats_uniform_on_skewed_eq() {
389        // One value (the boss) takes 50% of the table.
390        let mcv = MostCommonValues::new(vec![
391            (ColumnValue::Text("boss".to_string()), 0.5),
392            (ColumnValue::Text("alice".to_string()), 0.05),
393            (ColumnValue::Text("bob".to_string()), 0.05),
394        ]);
395        let boss = ColumnValue::Text("boss".to_string());
396        let freq = mcv.frequency_of(&boss).unwrap();
397        // Equality on the boss row: 0.5, vs heuristic 0.01 → 50× better.
398        assert_eq!(freq, 0.5);
399        assert!(freq > 0.01);
400    }
401
402    #[test]
403    fn mcv_empty_when_no_values() {
404        let mcv = MostCommonValues::new(vec![]);
405        assert!(mcv.is_empty());
406        assert_eq!(mcv.len(), 0);
407        assert_eq!(mcv.total_frequency(), 0.0);
408        assert!(mcv.frequency_of(&ColumnValue::Int(1)).is_none());
409    }
410}