Skip to main content

linkrs_query/optimizer/stats/
histogram.rs

1//! ValueHistogram Statistics Module
2//!
3//! Use an equi-depth histogram to record the distribution of attribute values.
4//! Each histogram contains a fixed number of bins, and each bin records the same number of tuples.
5
6use linkrs_core::value::Value;
7use std::time::Instant;
8
9/// ValueHistogram bins
10#[derive(Debug, Clone)]
11pub struct ValueHistogramBucket {
12    /// Upper bound of the bucket (inclusive)
13    pub upper_bound: Value,
14    /// Number of tuples in the bucket
15    pub count: u64,
16    /// Number of Different Values (NDV)
17    pub distinct_values: u64,
18}
19
20impl ValueHistogramBucket {
21    /// Create new histogram buckets.
22    pub fn new(upper_bound: Value, count: u64, distinct_values: u64) -> Self {
23        Self {
24            upper_bound,
25            count,
26            distinct_values: distinct_values.max(1),
27        }
28    }
29}
30
31/// Range condition types
32#[derive(Debug, Clone)]
33pub enum RangeCondition {
34    /// less than
35    Lt(Value),
36    /// Less than or equal to
37    Le(Value),
38    /// greater than
39    Gt(Value),
40    /// greater than or equal to
41    Ge(Value),
42    /// Range [low, high)
43    Range { low: Value, high: Value },
44}
45
46/// Isobathic histogram
47#[derive(Debug, Clone)]
48pub struct ValueHistogram {
49    /// Bucket list (sorted by upper bound)
50    buckets: Vec<ValueHistogramBucket>,
51    /// Proportion of null values
52    null_fraction: f64,
53    /// Total number of different values
54    total_distinct_values: u64,
55    /// Total number of records
56    total_count: u64,
57    /// Last update time
58    last_updated: Instant,
59}
60
61impl ValueHistogram {
62    /// Create an empty histogram.
63    pub fn empty() -> Self {
64        Self {
65            buckets: Vec::new(),
66            null_fraction: 0.0,
67            total_distinct_values: 0,
68            total_count: 0,
69            last_updated: Instant::now(),
70        }
71    }
72
73    /// Constructing an isobathic histogram from the sampled data
74    ///
75    /// # Parameters
76    /// “samples”: A list of sampled values
77    /// `num_buckets`: The number of buckets
78    /// `total_count`: The total number of records (used to calculate the proportion of null values).
79    ///
80    /// # Description
81    /// An isochore plot ensures that each bin contains approximately the same number of samples.
82    pub fn from_samples(samples: Vec<Value>, num_buckets: usize, total_count: u64) -> Self {
83        if samples.is_empty() || num_buckets == 0 {
84            return Self::empty();
85        }
86
87        let mut sorted_samples = samples;
88        // Separate null values from non-null values
89        let null_count = sorted_samples.iter().filter(|v| v.is_null()).count();
90        sorted_samples.retain(|v| !v.is_null());
91
92        if sorted_samples.is_empty() {
93            return Self {
94                buckets: Vec::new(),
95                null_fraction: null_count as f64 / total_count.max(1) as f64,
96                total_distinct_values: 0,
97                total_count,
98                last_updated: Instant::now(),
99            };
100        }
101
102        // Sort by value
103        sorted_samples.sort_by(compare_values);
104
105        // Calculating the number of different values
106        let distinct_values = calculate_distinct_values(&sorted_samples);
107
108        // Constructing an isohyetic histogram
109        let non_null_count = sorted_samples.len();
110        let bucket_size = non_null_count / num_buckets;
111        let remainder = non_null_count % num_buckets;
112
113        let mut buckets = Vec::with_capacity(num_buckets);
114        let mut start_idx = 0;
115
116        for i in 0..num_buckets {
117            let current_bucket_size = bucket_size + if i < remainder { 1 } else { 0 };
118            if current_bucket_size == 0 {
119                continue;
120            }
121
122            let end_idx = (start_idx + current_bucket_size).min(sorted_samples.len());
123            let bucket_samples = &sorted_samples[start_idx..end_idx];
124
125            if let Some(upper_bound) = bucket_samples.last().cloned() {
126                let count = bucket_samples.len() as u64;
127                let bucket_distinct = calculate_distinct_values(bucket_samples);
128                buckets.push(ValueHistogramBucket::new(
129                    upper_bound,
130                    count,
131                    bucket_distinct,
132                ));
133            }
134
135            start_idx = end_idx;
136        }
137
138        Self {
139            buckets,
140            null_fraction: null_count as f64 / total_count.max(1) as f64,
141            total_distinct_values: distinct_values,
142            total_count,
143            last_updated: Instant::now(),
144        }
145    }
146
147    /// Estimated equivalence query selectivity
148    ///
149    /// # Description
150    /// Find the bucket that contains that value, using the assumption of a uniform distribution within the bucket: 1 / the number of unique values (NDV) in the bucket.
151    pub fn estimate_equality_selectivity(&self, value: &Value) -> f64 {
152        if value.is_null() {
153            return self.null_fraction;
154        }
155
156        if self.buckets.is_empty() {
157            return 0.1; // Default selection option
158        }
159
160        // Find the corresponding bucket.
161        match self.find_bucket(value) {
162            Some(bucket_idx) => {
163                let bucket = &self.buckets[bucket_idx];
164                // The assumption of uniform distribution within the barrel
165                let bucket_selectivity = 1.0 / bucket.distinct_values.max(1) as f64;
166                // Consider the proportion of the barrel in the overall structure.
167                let bucket_ratio = bucket.count as f64 / self.total_count.max(1) as f64;
168                bucket_selectivity * bucket_ratio
169            }
170            None => {
171                // The value exceeds the range of the histogram; therefore, the minimum selective estimation method is used.
172                1.0 / self.total_distinct_values.max(1) as f64
173            }
174        }
175    }
176
177    /// Selective query within the estimated range
178    pub fn estimate_range_selectivity(&self, range: &RangeCondition) -> f64 {
179        match range {
180            RangeCondition::Lt(value) | RangeCondition::Le(value) => {
181                self.estimate_less_than_selectivity(value)
182            }
183            RangeCondition::Gt(value) | RangeCondition::Ge(value) => {
184                1.0 - self.estimate_less_than_selectivity(value)
185            }
186            RangeCondition::Range { low, high } => {
187                let high_selectivity = self.estimate_less_than_selectivity(high);
188                let low_selectivity = self.estimate_less_than_selectivity(low);
189                (high_selectivity - low_selectivity).max(0.0)
190            }
191        }
192    }
193
194    /// Estimated selectivity that is less than the specified condition
195    fn estimate_less_than_selectivity(&self, value: &Value) -> f64 {
196        if self.buckets.is_empty() {
197            return 0.333; // Default range selection
198        }
199
200        let mut selectivity = 0.0;
201        let mut found = false;
202
203        for bucket in &self.buckets {
204            if compare_values(value, &bucket.upper_bound) != std::cmp::Ordering::Greater {
205                // The value is within the current bucket range.
206                // Assuming the contents of the barrel are evenly distributed, estimate the proportion of items that are smaller than the given value.
207                let bucket_ratio = bucket.count as f64 / self.total_count.max(1) as f64;
208                selectivity += bucket_ratio * 0.5; // Simplified estimate: Take half of the bucket.
209                found = true;
210                break;
211            } else {
212                // The entire bucket contains less than the value specified.
213                selectivity += bucket.count as f64 / self.total_count.max(1) as f64;
214            }
215        }
216
217        if !found {
218            // The value is greater than the upper limit of all buckets.
219            selectivity = 1.0 - self.null_fraction;
220        }
221
222        selectivity.min(1.0 - self.null_fraction)
223    }
224
225    /// Find the bucket index where the value is located.
226    fn find_bucket(&self, value: &Value) -> Option<usize> {
227        for (idx, bucket) in self.buckets.iter().enumerate() {
228            if compare_values(value, &bucket.upper_bound) != std::cmp::Ordering::Greater {
229                return Some(idx);
230            }
231        }
232        None
233    }
234
235    /// Obtain the number of buckets
236    pub fn bucket_count(&self) -> usize {
237        self.buckets.len()
238    }
239
240    /// Obtain the proportion of null values
241    pub fn null_fraction(&self) -> f64 {
242        self.null_fraction
243    }
244
245    /// Get the total number of records.
246    pub fn total_count(&self) -> u64 {
247        self.total_count
248    }
249
250    /// Obtaining different numbers of values
251    pub fn distinct_values(&self) -> u64 {
252        self.total_distinct_values
253    }
254
255    /// Get the last update time.
256    pub fn last_updated(&self) -> Instant {
257        self.last_updated
258    }
259}
260
261/// Compare two values with snapshot-compatible numeric semantics:
262/// exact `i64` for integer kinds, `f64` when a float is involved,
263/// lexicographic for strings, otherwise `Value` ordering.
264/// Numeric strings stay lexicographic.
265fn compare_values(a: &Value, b: &Value) -> std::cmp::Ordering {
266    use std::cmp::Ordering;
267
268    fn as_i64(value: &Value) -> Option<i64> {
269        match value {
270            Value::SmallInt(v) => Some(*v as i64),
271            Value::Int(v) => Some(*v as i64),
272            Value::BigInt(v) => Some(*v),
273            _ => None,
274        }
275    }
276    fn as_f64(value: &Value) -> Option<f64> {
277        match value {
278            Value::SmallInt(v) => Some(*v as f64),
279            Value::Int(v) => Some(*v as f64),
280            Value::BigInt(v) => Some(*v as f64),
281            Value::Float(v) => Some(*v as f64),
282            Value::Double(v) => Some(*v),
283            _ => None,
284        }
285    }
286    if let (Some(x), Some(y)) = (as_i64(a), as_i64(b)) {
287        return x.cmp(&y);
288    }
289    if let (Some(x), Some(y)) = (as_f64(a), as_f64(b)) {
290        return x.partial_cmp(&y).unwrap_or(Ordering::Equal);
291    }
292    match (a, b) {
293        (Value::SmallInt(a), Value::SmallInt(b)) => a.cmp(b),
294        (Value::Int(a), Value::Int(b)) => a.cmp(b),
295        (Value::BigInt(a), Value::BigInt(b)) => a.cmp(b),
296        (Value::Float(a), Value::Float(b)) => a.partial_cmp(b).unwrap_or(Ordering::Equal),
297        (Value::Double(a), Value::Double(b)) => a.partial_cmp(b).unwrap_or(Ordering::Equal),
298        (Value::String(a), Value::String(b)) => a.cmp(b),
299        (Value::Bool(a), Value::Bool(b)) => a.cmp(b),
300        _ => Value::cmp(a, b),
301    }
302}
303
304/// Calculating the number of different values
305fn calculate_distinct_values(values: &[Value]) -> u64 {
306    use std::collections::HashSet;
307
308    let mut seen = HashSet::new();
309    for value in values {
310        // Use a string representation of the value as the hash key.
311        let key = format!("{:?}", value);
312        seen.insert(key);
313    }
314    seen.len() as u64
315}
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320
321    #[test]
322    fn test_histogram_empty() {
323        let hist = ValueHistogram::empty();
324        assert_eq!(hist.bucket_count(), 0);
325        assert_eq!(hist.null_fraction(), 0.0);
326    }
327
328    #[test]
329    fn test_histogram_from_samples() {
330        let samples: Vec<Value> = (1..=100).map(Value::Int).collect();
331        let hist = ValueHistogram::from_samples(samples, 10, 100);
332
333        assert_eq!(hist.bucket_count(), 10);
334        assert_eq!(hist.total_count(), 100);
335    }
336
337    #[test]
338    fn test_estimate_equality_selectivity() {
339        let samples: Vec<Value> = (1..=100).map(Value::Int).collect();
340        let hist = ValueHistogram::from_samples(samples, 10, 100);
341
342        // For uniformly distributed data, the selectivity should be close to 1/100 = 0.01
343        let selectivity = hist.estimate_equality_selectivity(&Value::Int(50));
344        assert!(selectivity > 0.0 && selectivity < 0.1);
345    }
346
347    #[test]
348    fn test_estimate_range_selectivity() {
349        let samples: Vec<Value> = (1..=100).map(Value::Int).collect();
350        let hist = ValueHistogram::from_samples(samples, 10, 100);
351
352        // Estimates of selectivity less than 50 should be closer to 0.5
353        let range = RangeCondition::Lt(Value::Int(50));
354        let selectivity = hist.estimate_range_selectivity(&range);
355        assert!(selectivity > 0.3 && selectivity < 0.7);
356    }
357
358    #[test]
359    fn test_null_handling() {
360        let mut samples: Vec<Value> = (1..=90).map(Value::Int).collect();
361        // Add 10 empty values.
362        for _ in 0..10 {
363            samples.push(Value::Null(linkrs_core::value::NullType::Null));
364        }
365
366        let hist = ValueHistogram::from_samples(samples, 10, 100);
367        assert!((hist.null_fraction() - 0.1).abs() < 0.01);
368
369        let null_selectivity =
370            hist.estimate_equality_selectivity(&Value::Null(linkrs_core::value::NullType::Null));
371        assert!((null_selectivity - 0.1).abs() < 0.01);
372    }
373}