Skip to main content

velesdb_core/collection/stats/
histogram.rs

1//! Histogram data structures for equi-depth column value distribution estimation.
2//!
3//! Provides [`Histogram`] and [`HistogramBucket`] used by the CBO to estimate
4//! predicate selectivity via binary search on bucket boundaries.
5
6// Reason: u64→f64 casts are intentional for selectivity ratio computation.
7// Values are bounded by collection size; precision loss is acceptable for statistics.
8#![allow(clippy::cast_precision_loss)]
9
10use serde::{Deserialize, Serialize};
11
12/// Returns the next representable `f64` above `val`.
13///
14/// Unlike `val + f64::EPSILON`, this works correctly for all magnitudes.
15/// `f64::EPSILON` is only the ULP at 1.0; for values ≥ 2.0, adding EPSILON
16/// is a no-op because EPSILON is smaller than the unit-in-last-place.
17///
18/// Uses IEEE 754 bit manipulation: incrementing (or decrementing for negative
19/// values) the integer representation of a float yields the next float.
20pub(crate) fn next_after(v: f64) -> f64 {
21    if v.is_nan() || v == f64::INFINITY {
22        return v;
23    }
24    if v == 0.0 {
25        return f64::from_bits(1);
26    }
27    let bits = v.to_bits();
28    let next_bits = if v > 0.0 { bits + 1 } else { bits - 1 };
29    f64::from_bits(next_bits)
30}
31
32/// A single bucket in an equi-depth histogram.
33///
34/// Represents a contiguous range `[lower_bound, upper_bound)` of column values
35/// with associated row count and distinct value count. Bucket boundaries use
36/// `f64` to unify Int, Float, and String (ordinal rank) columns.
37#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
38pub struct HistogramBucket {
39    /// Inclusive lower bound for the bucket.
40    pub lower_bound: f64,
41    /// Exclusive upper bound for the bucket.
42    pub upper_bound: f64,
43    /// Number of sampled rows in the bucket.
44    pub count: u64,
45    /// Number of distinct values in the bucket.
46    #[serde(default)]
47    pub distinct_count: u64,
48}
49
50/// Equi-depth histogram for column value distribution estimation.
51///
52/// Buckets are sorted by `lower_bound` and non-overlapping. The CBO uses
53/// binary search (`O(log B)`) on bucket boundaries for all selectivity lookups.
54#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
55pub struct Histogram {
56    /// Ordered, non-overlapping histogram buckets.
57    pub buckets: Vec<HistogramBucket>,
58    /// Total number of rows represented by this histogram (sum of all bucket counts).
59    #[serde(default)]
60    pub total_count: u64,
61    /// Cumulative number of incremental updates since last full ANALYZE.
62    #[serde(default)]
63    pub incremental_updates: u64,
64    /// Whether the histogram is considered stale (updates > 20% of total_count).
65    #[serde(default)]
66    pub stale: bool,
67}
68
69impl Histogram {
70    /// Finds the bucket index containing `value` via binary search.
71    ///
72    /// Returns the index of the bucket whose range `[lower_bound, upper_bound)`
73    /// contains `value`. Returns `None` if `value` is outside all bucket ranges.
74    ///
75    /// Complexity: O(log B) where B = number of buckets. No allocations.
76    #[must_use]
77    pub fn find_bucket(&self, value: f64) -> Option<usize> {
78        let buckets = &self.buckets;
79        if buckets.is_empty() {
80            return None;
81        }
82        // Binary search: find the rightmost bucket whose lower_bound <= value
83        let idx = buckets.partition_point(|b| b.lower_bound <= value);
84        if idx == 0 {
85            return None;
86        }
87        let candidate = idx - 1;
88        if value < buckets[candidate].upper_bound {
89            Some(candidate)
90        } else {
91            None
92        }
93    }
94
95    /// Estimates equality selectivity for a given value.
96    ///
97    /// If the value falls within a bucket with `distinct_count > 0`, returns
98    /// `bucket.count / (bucket.distinct_count × total_count)`.
99    /// If `distinct_count == 0` or value is outside all buckets, returns
100    /// `1 / total_count`. Returns `0.0` when `total_count == 0`.
101    /// Result is clamped to `[0.0, 1.0]`.
102    #[must_use]
103    pub fn estimate_eq_selectivity(&self, value: f64) -> f64 {
104        let total = self.bucket_sum();
105        if total == 0 {
106            return 0.0;
107        }
108        let sel = if let Some(idx) = self.find_bucket(value) {
109            let bucket = &self.buckets[idx];
110            if bucket.distinct_count > 0 {
111                bucket.count as f64 / (bucket.distinct_count as f64 * total as f64)
112            } else {
113                1.0 / total.max(1) as f64
114            }
115        } else {
116            1.0 / total.max(1) as f64
117        };
118        sel.clamp(0.0, 1.0)
119    }
120
121    /// Estimates less-than selectivity for a given value.
122    ///
123    /// Sums counts of all buckets fully below `value`, plus linear interpolation
124    /// of the partial bucket containing `value`. Divides by `total_count`.
125    /// Returns `0.0` if value ≤ first bucket lower bound, `1.0` if value ≥ last
126    /// bucket upper bound. Result is clamped to `[0.0, 1.0]`.
127    #[must_use]
128    pub fn estimate_lt_selectivity(&self, value: f64) -> f64 {
129        let total = self.bucket_sum();
130        if self.buckets.is_empty() || total == 0 {
131            return 0.0;
132        }
133        if value <= self.buckets[0].lower_bound {
134            return 0.0;
135        }
136        if value >= self.buckets[self.buckets.len() - 1].upper_bound {
137            return 1.0;
138        }
139        let count_below = accumulate_lt_count(&self.buckets, value);
140        (count_below / total as f64).clamp(0.0, 1.0)
141    }
142
143    /// Estimates range selectivity for `[low, high]`.
144    ///
145    /// Sums full buckets within the range plus interpolates boundary buckets.
146    /// Returns `0.0` if `low > high` or range is outside the histogram.
147    /// Returns `1.0` if range encompasses the entire histogram.
148    /// Result is clamped to `[0.0, 1.0]`.
149    #[must_use]
150    pub fn estimate_range_selectivity(&self, low: f64, high: f64) -> f64 {
151        if let Some(shortcut) = self.range_selectivity_shortcut(low, high) {
152            return shortcut;
153        }
154        let total = self.bucket_sum();
155        let mut count_in_range: f64 = 0.0;
156        for bucket in &self.buckets {
157            if let Some(fraction) = bucket_range_fraction(bucket, low, high) {
158                count_in_range += bucket.count as f64 * fraction;
159            }
160        }
161        (count_in_range / total as f64).clamp(0.0, 1.0)
162    }
163
164    /// Returns a short-circuit selectivity if the range can be resolved without
165    /// iterating buckets (empty histogram, out-of-bounds, or full coverage).
166    fn range_selectivity_shortcut(&self, low: f64, high: f64) -> Option<f64> {
167        if low > high || self.buckets.is_empty() || self.bucket_sum() == 0 {
168            return Some(0.0);
169        }
170        let first_lower = self.buckets[0].lower_bound;
171        let last_upper = self.buckets[self.buckets.len() - 1].upper_bound;
172        if low >= last_upper || high <= first_lower {
173            return Some(0.0);
174        }
175        if low <= first_lower && high >= last_upper {
176            return Some(1.0);
177        }
178        None
179    }
180
181    /// Increments the count of the bucket containing `value`.
182    ///
183    /// Finds the bucket via binary search and increments its count by 1.
184    /// Increments `incremental_updates` by 1. If `incremental_updates`
185    /// exceeds 20% of `total_count`, marks the histogram as stale.
186    /// No-op if `value` is outside all bucket ranges.
187    pub fn increment_bucket(&mut self, value: f64) {
188        if let Some(idx) = self.find_bucket(value) {
189            self.buckets[idx].count += 1;
190            self.incremental_updates += 1;
191            self.check_staleness();
192        }
193    }
194
195    /// Decrements the count of the bucket containing `value`, floored at zero.
196    ///
197    /// Finds the bucket via binary search and decrements its count by 1
198    /// (minimum 0). Increments `incremental_updates` by 1. Checks staleness.
199    /// No-op if `value` is outside all bucket ranges.
200    pub fn decrement_bucket(&mut self, value: f64) {
201        if let Some(idx) = self.find_bucket(value) {
202            self.buckets[idx].count = self.buckets[idx].count.saturating_sub(1);
203            self.incremental_updates += 1;
204            self.check_staleness();
205        }
206    }
207
208    /// Returns the sum of all bucket counts — the effective total for selectivity.
209    ///
210    /// `total_count` captures the ANALYZE-time snapshot and is used only for
211    /// staleness detection. After incremental updates the actual denominator
212    /// is the live sum of bucket counts, keeping estimates accurate.
213    fn bucket_sum(&self) -> u64 {
214        self.buckets.iter().map(|b| b.count).sum()
215    }
216
217    /// Checks if incremental updates exceed the 20% staleness threshold.
218    fn check_staleness(&mut self) {
219        if self.total_count > 0 && self.incremental_updates > self.total_count / 5 {
220            self.stale = true;
221        }
222    }
223}
224
225/// Accumulates the count of rows below `value` across sorted buckets.
226///
227/// For each bucket: if entirely below `value`, adds its full count;
228/// if partially overlapping, adds a linearly interpolated fraction;
229/// stops at the first bucket beyond `value`.
230fn accumulate_lt_count(buckets: &[HistogramBucket], value: f64) -> f64 {
231    let mut count_below: f64 = 0.0;
232    for bucket in buckets {
233        if bucket.upper_bound <= value {
234            count_below += bucket.count as f64;
235        } else if bucket.lower_bound < value {
236            let width = bucket.upper_bound - bucket.lower_bound;
237            if width > 0.0 {
238                count_below += bucket.count as f64 * ((value - bucket.lower_bound) / width);
239            }
240            break;
241        } else {
242            break;
243        }
244    }
245    count_below
246}
247
248/// Returns the fraction of `bucket` that overlaps the range `[low, high]`.
249///
250/// Returns `None` if the bucket is entirely outside the range or has zero width.
251/// Otherwise returns `Some((eff_high - eff_low) / width)` where the effective
252/// bounds are clamped to the bucket boundaries.
253fn bucket_range_fraction(bucket: &HistogramBucket, low: f64, high: f64) -> Option<f64> {
254    if bucket.upper_bound <= low || bucket.lower_bound >= high {
255        return None;
256    }
257    let width = bucket.upper_bound - bucket.lower_bound;
258    if width <= 0.0 {
259        return None;
260    }
261    let eff_low = low.max(bucket.lower_bound);
262    let eff_high = high.min(bucket.upper_bound);
263    Some((eff_high - eff_low) / width)
264}
265
266/// Default number of histogram buckets.
267const DEFAULT_NUM_BUCKETS: usize = 64;
268
269/// Builder for constructing equi-depth histograms from sampled column values.
270///
271/// Sorts the input values, splits them into approximately equal-sized buckets,
272/// and computes per-bucket distinct counts. No allocations occur after the
273/// initial sort — bucket construction operates on slices.
274pub(crate) struct HistogramBuilder {
275    /// Target number of buckets.
276    num_buckets: usize,
277}
278
279impl HistogramBuilder {
280    /// Creates a builder with the specified bucket count.
281    ///
282    /// If `num_buckets` is 0, defaults to 64.
283    #[must_use]
284    pub fn new(num_buckets: usize) -> Self {
285        Self {
286            num_buckets: if num_buckets == 0 {
287                DEFAULT_NUM_BUCKETS
288            } else {
289                num_buckets
290            },
291        }
292    }
293
294    /// Builds an equi-depth histogram from a mutable slice of `f64` values.
295    ///
296    /// NaN values are filtered out. Empty input produces an empty histogram.
297    /// Sets `total_count` to the number of non-NaN values processed.
298    #[must_use]
299    pub fn build(&self, values: &mut [f64]) -> Histogram {
300        let valid_len = partition_nan(values);
301        let valid = &mut values[..valid_len];
302        if valid.is_empty() {
303            return Histogram::default();
304        }
305        valid.sort_unstable_by(f64::total_cmp);
306        let distinct = count_distinct(valid);
307        let buckets = if distinct == 1 {
308            build_single_value_buckets(valid)
309        } else if distinct < self.num_buckets {
310            build_per_distinct_buckets(valid, distinct)
311        } else {
312            build_equidepth_buckets(valid, self.num_buckets)
313        };
314        Histogram {
315            buckets,
316            total_count: valid_len as u64,
317            incremental_updates: 0,
318            stale: false,
319        }
320    }
321}
322
323/// Partitions NaN values to the end, returns the count of non-NaN values.
324fn partition_nan(values: &mut [f64]) -> usize {
325    let mut valid = 0;
326    for i in 0..values.len() {
327        if !values[i].is_nan() {
328            values.swap(valid, i);
329            valid += 1;
330        }
331    }
332    valid
333}
334
335/// Counts distinct values in a sorted slice.
336#[allow(clippy::float_cmp)]
337fn count_distinct(sorted: &[f64]) -> usize {
338    if sorted.is_empty() {
339        return 0;
340    }
341    // Reason: exact equality is intentional — values come from the same sorted
342    // input, so bit-identical duplicates must be grouped together.
343    1 + sorted.windows(2).filter(|w| w[0] != w[1]).count()
344}
345
346/// Counts distinct values in a sorted sub-slice.
347fn slice_distinct_count(sorted: &[f64]) -> u64 {
348    count_distinct(sorted) as u64
349}
350
351/// Builds a single bucket for a column with exactly one distinct value.
352fn build_single_value_buckets(sorted: &[f64]) -> Vec<HistogramBucket> {
353    let val = sorted[0];
354    vec![HistogramBucket {
355        lower_bound: val,
356        upper_bound: next_after(val),
357        count: sorted.len() as u64,
358        distinct_count: 1,
359    }]
360}
361
362/// Builds one bucket per distinct value when distinct < num_buckets.
363#[allow(clippy::float_cmp)]
364fn build_per_distinct_buckets(sorted: &[f64], distinct: usize) -> Vec<HistogramBucket> {
365    let mut buckets = Vec::with_capacity(distinct);
366    let mut i = 0;
367    while i < sorted.len() {
368        let val = sorted[i];
369        let start = i;
370        // Reason: exact equality is intentional — grouping bit-identical values.
371        while i < sorted.len() && sorted[i] == val {
372            i += 1;
373        }
374        let next_bound = if i < sorted.len() {
375            sorted[i]
376        } else {
377            next_after(val)
378        };
379        buckets.push(HistogramBucket {
380            lower_bound: val,
381            upper_bound: next_bound,
382            count: (i - start) as u64,
383            distinct_count: 1,
384        });
385    }
386    buckets
387}
388
389/// Builds equi-depth buckets by splitting sorted values into equal-sized chunks.
390///
391/// After chunking, merges any zero-width buckets (`lower_bound == upper_bound`)
392/// into adjacent buckets. Zero-width buckets arise from duplicate-heavy data where
393/// a chunk boundary falls inside a run of identical values — their counts inflate
394/// `bucket_sum()` without contributing to any selectivity lookup.
395fn build_equidepth_buckets(sorted: &[f64], num_buckets: usize) -> Vec<HistogramBucket> {
396    let chunk_size = sorted.len().div_ceil(num_buckets);
397    let mut buckets = Vec::with_capacity(num_buckets);
398    // Track the running start offset of each chunk instead of re-summing existing
399    // bucket counts on every iteration (O(n) per call → O(n²) total without this).
400    let mut offset = 0usize;
401    for chunk in sorted.chunks(chunk_size) {
402        let lower = chunk[0];
403        let upper = upper_bound_for_chunk(chunk, sorted, offset);
404        offset += chunk.len();
405        buckets.push(HistogramBucket {
406            lower_bound: lower,
407            upper_bound: upper,
408            count: chunk.len() as u64,
409            distinct_count: slice_distinct_count(chunk),
410        });
411    }
412    merge_zero_width_buckets(buckets)
413}
414
415/// Merges zero-width buckets (`lower_bound == upper_bound`) into adjacent buckets.
416///
417/// Zero-width buckets are absorbed into the nearest **non-zero-width** neighbor.
418/// Leading zero-width buckets are merged forward into the first non-zero-width
419/// bucket; trailing ones are merged backward into the last non-zero-width bucket.
420///
421/// Row counts are summed into the absorbing bucket. Distinct counts use `max`
422/// rather than addition because the zero-width bucket's single distinct value
423/// is a subset of the absorbing bucket's value range (they share a boundary),
424/// so summing would double-count shared distinct values. If every bucket is
425/// zero-width (all values identical), the input is returned unchanged — this
426/// case is already handled by `build_single_value_buckets` upstream.
427#[allow(clippy::float_cmp)]
428pub(crate) fn merge_zero_width_buckets(buckets: Vec<HistogramBucket>) -> Vec<HistogramBucket> {
429    if buckets.is_empty() {
430        return buckets;
431    }
432    // Accumulator for leading/pending zero-width bucket counts.
433    let mut pending_count: u64 = 0;
434    let mut pending_distinct: u64 = 0;
435    let mut result: Vec<HistogramBucket> = Vec::with_capacity(buckets.len());
436    for bucket in buckets {
437        // Reason: exact equality is intentional — zero-width means the chunk
438        // contained only identical values whose upper bound equals the next
439        // chunk's lower bound.
440        if bucket.lower_bound == bucket.upper_bound {
441            pending_count += bucket.count;
442            pending_distinct = pending_distinct.max(bucket.distinct_count);
443        } else {
444            // Absorb any pending zero-width counts into this non-zero-width bucket.
445            let mut merged = bucket;
446            merged.count += pending_count;
447            // Use max to avoid double-counting: the zero-width bucket's distinct
448            // values are a subset of the absorbing bucket's range.
449            merged.distinct_count = merged.distinct_count.max(pending_distinct);
450            pending_count = 0;
451            pending_distinct = 0;
452            result.push(merged);
453        }
454    }
455    // Trailing zero-width buckets: merge into the last non-zero-width bucket.
456    if pending_count > 0 {
457        if let Some(last) = result.last_mut() {
458            last.count += pending_count;
459            last.distinct_count = last.distinct_count.max(pending_distinct);
460        }
461        // else: all buckets were zero-width — handled by build_single_value_buckets
462    }
463    result
464}
465
466/// Computes the upper bound for an equi-depth chunk.
467///
468/// `offset` is the index in `sorted` where this chunk starts.
469/// Uses the next chunk's first value if available, otherwise last value + epsilon.
470fn upper_bound_for_chunk(chunk: &[f64], sorted: &[f64], offset: usize) -> f64 {
471    let chunk_end_offset = offset + chunk.len();
472    if chunk_end_offset < sorted.len() {
473        sorted[chunk_end_offset]
474    } else {
475        next_after(chunk[chunk.len() - 1])
476    }
477}