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}