Skip to main content

irithyll_core/histogram/
quantile.rs

1//! Greenwald-Khanna streaming quantile sketch for bin edge computation.
2//!
3//! The GK sketch maintains a compact summary of a data stream that answers
4//! quantile queries with bounded error epsilon. It stores tuples (value, gap, delta)
5//! where `gap` is the minimum rank difference from the predecessor and `delta` is
6//! the maximum rank uncertainty.
7//!
8//! **Space complexity:** O((1/epsilon) * log(epsilon * N))
9//! **Per-insert time:** O(log(1/epsilon) + (1/epsilon)) amortized (binary search + compress)
10//! **Query time:** O(1/epsilon) (linear scan of summary)
11
12use alloc::boxed::Box;
13use alloc::vec;
14use alloc::vec::Vec;
15
16use crate::histogram::{BinEdges, BinningStrategy};
17
18/// A single entry in the GK summary.
19///
20/// Invariant: for any entry `t_i` (not the first), the true rank of `t_i.value`
21/// lies in `[rank_min, rank_min + t_i.delta]` where `rank_min` is the sum of
22/// all `gap` values from `t_0` through `t_i`.
23#[derive(Debug, Clone)]
24struct GKTuple {
25    value: f64,
26    /// g_i: minimum possible rank difference from the predecessor entry.
27    gap: u64,
28    /// delta_i: maximum uncertainty in the rank of this value.
29    delta: u64,
30}
31
32/// Greenwald-Khanna streaming quantile sketch.
33///
34/// Provides epsilon-approximate quantile queries on a data stream using
35/// O((1/epsilon) * log(epsilon * N)) space, where N is the number of observed
36/// values and epsilon is the error tolerance.
37///
38/// A query for quantile phi returns a value whose true rank r satisfies
39/// `|r - phi*N| <= epsilon * N`.
40///
41/// # Example
42///
43/// ```ignore
44/// let mut sketch = QuantileBinning::new();
45/// for v in 0..1000 {
46///     sketch.observe(v as f64);
47/// }
48/// let edges = sketch.compute_edges(4);
49/// // edges are approximately at the 25th, 50th, 75th percentiles
50/// ```
51#[derive(Debug, Clone)]
52pub struct QuantileBinning {
53    /// The GK summary: sorted tuples summarizing the rank distribution.
54    summary: Vec<GKTuple>,
55    /// Total number of values observed.
56    count: u64,
57    /// Error tolerance. A query for quantile phi returns a value whose
58    /// true rank r satisfies |r - phi*N| <= epsilon * N.
59    epsilon: f64,
60    /// Counter for triggering periodic compression.
61    inserts_since_compress: u64,
62}
63
64impl QuantileBinning {
65    /// Create a new `QuantileBinning` with the default epsilon of 0.01 (1% error).
66    pub fn new() -> Self {
67        Self::with_epsilon(0.01)
68    }
69
70    /// Create a new `QuantileBinning` with a custom error tolerance.
71    ///
72    /// # Panics
73    ///
74    /// Panics if `epsilon` is not in the range `(0.0, 1.0)`.
75    pub fn with_epsilon(epsilon: f64) -> Self {
76        assert!(
77            epsilon > 0.0 && epsilon < 1.0,
78            "epsilon must be in (0.0, 1.0), got {epsilon}"
79        );
80        Self {
81            summary: Vec::new(),
82            count: 0,
83            epsilon,
84            inserts_since_compress: 0,
85        }
86    }
87
88    /// The capacity threshold: floor(2 * epsilon * count).
89    /// This is the maximum allowed value for `gap + delta` in any summary entry
90    /// (except the first and last).
91    #[inline]
92    fn band_width(&self) -> u64 {
93        libm::floor(2.0 * self.epsilon * self.count as f64) as u64
94    }
95
96    /// How often to trigger compression: every floor(1 / (2 * epsilon)) inserts.
97    #[inline]
98    fn compress_interval(&self) -> u64 {
99        libm::floor(1.0 / (2.0 * self.epsilon)) as u64
100    }
101
102    /// Insert a value into the summary.
103    fn insert(&mut self, value: f64) {
104        self.count += 1;
105
106        if self.summary.is_empty() {
107            self.summary.push(GKTuple {
108                value,
109                gap: 1,
110                delta: 0,
111            });
112            self.inserts_since_compress += 1;
113            return;
114        }
115
116        // Binary search: find the first entry with value >= new value.
117        let pos = self.summary.partition_point(|t| t.value < value);
118
119        // Compute delta for the new tuple.
120        let delta = if pos == 0 || pos == self.summary.len() {
121            // Inserting at the very front or very back: no uncertainty.
122            0
123        } else {
124            // Interior insertion: delta = floor(2 * epsilon * count) - 1.
125            // We subtract 1 because count has already been incremented and the
126            // new tuple's gap is 1, so delta = band_width - 1 keeps the invariant.
127            self.band_width().saturating_sub(1)
128        };
129
130        self.summary.insert(
131            pos,
132            GKTuple {
133                value,
134                gap: 1,
135                delta,
136            },
137        );
138
139        self.inserts_since_compress += 1;
140
141        // Periodic compression to keep summary compact.
142        let interval = self.compress_interval().max(1);
143        if self.inserts_since_compress >= interval {
144            self.compress();
145            self.inserts_since_compress = 0;
146        }
147    }
148
149    /// Compress the summary by merging adjacent entries where the merge
150    /// would not violate the GK invariant.
151    ///
152    /// Scans backwards (highest to lowest index) merging entry `i` into
153    /// entry `i+1` when `summary[i].gap + summary[i+1].gap + summary[i+1].delta`
154    /// does not exceed `floor(2 * epsilon * count)`.
155    ///
156    /// The first and last entries are never removed.
157    fn compress(&mut self) {
158        if self.summary.len() < 3 {
159            return;
160        }
161
162        let threshold = self.band_width();
163
164        // Walk backwards from second-to-last to second entry.
165        // We check whether entry i can be merged into entry i+1.
166        let mut i = self.summary.len() - 2;
167        while i >= 1 {
168            let merge_cost =
169                self.summary[i].gap + self.summary[i + 1].gap + self.summary[i + 1].delta;
170
171            if merge_cost <= threshold {
172                // Absorb entry i into entry i+1: transfer its gap contribution.
173                let absorbed_gap = self.summary[i].gap;
174                self.summary[i + 1].gap += absorbed_gap;
175                self.summary.remove(i);
176            }
177
178            if i == 0 {
179                break;
180            }
181            i -= 1;
182        }
183    }
184
185    /// Query the approximate value at quantile `phi` in `[0.0, 1.0]`.
186    ///
187    /// Returns `None` if the summary is empty.
188    ///
189    /// The returned value has true rank r such that `|r - phi*N| <= epsilon * N`.
190    pub fn quantile(&self, phi: f64) -> Option<f64> {
191        if self.summary.is_empty() {
192            return None;
193        }
194
195        // Clamp phi to [0, 1].
196        let phi = phi.clamp(0.0, 1.0);
197
198        let target_rank = libm::ceil(phi * self.count as f64) as u64;
199        let tolerance = libm::floor(self.epsilon * self.count as f64) as u64;
200
201        // Walk through summary, tracking cumulative rank (sum of gaps).
202        let mut rank: u64 = 0;
203        let mut best_idx = 0;
204
205        for (i, tuple) in self.summary.iter().enumerate() {
206            rank += tuple.gap;
207
208            // Check whether this entry could satisfy the quantile query.
209            // We want the largest i where rank_i + delta_i < target_rank + tolerance,
210            // or if we've passed the target, return the best candidate seen so far.
211            if rank + tuple.delta > target_rank + tolerance {
212                // We've overshot: the previous valid entry is the answer.
213                return Some(self.summary[best_idx].value);
214            }
215
216            best_idx = i;
217        }
218
219        // If we walked all the way through, return the last entry.
220        Some(self.summary[best_idx].value)
221    }
222
223    /// Return the number of values observed so far.
224    #[inline]
225    pub fn count(&self) -> u64 {
226        self.count
227    }
228
229    /// Return the current number of tuples in the summary.
230    #[inline]
231    pub fn summary_len(&self) -> usize {
232        self.summary.len()
233    }
234}
235
236impl Default for QuantileBinning {
237    fn default() -> Self {
238        Self::new()
239    }
240}
241
242impl BinningStrategy for QuantileBinning {
243    /// Observe a single value from the stream.
244    fn observe(&mut self, value: f64) {
245        self.insert(value);
246    }
247
248    /// Compute bin edges by querying quantiles at evenly spaced positions.
249    ///
250    /// For `n_bins` bins, queries quantiles at `1/n_bins, 2/n_bins, ..., (n_bins-1)/n_bins`
251    /// and deduplicates adjacent equal edges. Returns at least one edge when data
252    /// has been observed.
253    fn compute_edges(&self, n_bins: usize) -> BinEdges {
254        if self.summary.is_empty() || n_bins <= 1 {
255            return BinEdges { edges: vec![] };
256        }
257
258        let mut edges = Vec::with_capacity(n_bins - 1);
259
260        for i in 1..n_bins {
261            let phi = i as f64 / n_bins as f64;
262            if let Some(val) = self.quantile(phi) {
263                // Deduplicate: skip if this edge equals the previous one.
264                if edges
265                    .last()
266                    .map_or(true, |&last: &f64| (val - last).abs() > f64::EPSILON)
267                {
268                    edges.push(val);
269                }
270            }
271        }
272
273        BinEdges { edges }
274    }
275
276    /// Reset the sketch to its initial empty state (preserving epsilon).
277    fn reset(&mut self) {
278        self.summary.clear();
279        self.count = 0;
280        self.inserts_since_compress = 0;
281    }
282
283    /// Create a fresh empty instance with the same epsilon.
284    fn clone_fresh(&self) -> Box<dyn BinningStrategy> {
285        Box::new(QuantileBinning::with_epsilon(self.epsilon))
286    }
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292
293    // ---------------------------------------------------------------
294    // Construction & defaults
295    // ---------------------------------------------------------------
296
297    #[test]
298    fn new_starts_empty() {
299        let q = QuantileBinning::new();
300        assert_eq!(q.count(), 0);
301        assert_eq!(q.summary_len(), 0);
302        assert!((q.epsilon - 0.01).abs() < f64::EPSILON);
303    }
304
305    #[test]
306    fn with_epsilon_stores_value() {
307        let q = QuantileBinning::with_epsilon(0.05);
308        assert!((q.epsilon - 0.05).abs() < f64::EPSILON);
309    }
310
311    #[test]
312    #[should_panic(expected = "epsilon must be in (0.0, 1.0)")]
313    fn with_epsilon_zero_panics() {
314        QuantileBinning::with_epsilon(0.0);
315    }
316
317    #[test]
318    #[should_panic(expected = "epsilon must be in (0.0, 1.0)")]
319    fn with_epsilon_one_panics() {
320        QuantileBinning::with_epsilon(1.0);
321    }
322
323    #[test]
324    #[should_panic(expected = "epsilon must be in (0.0, 1.0)")]
325    fn with_epsilon_negative_panics() {
326        QuantileBinning::with_epsilon(-0.1);
327    }
328
329    // ---------------------------------------------------------------
330    // Single value / edge cases
331    // ---------------------------------------------------------------
332
333    #[test]
334    fn single_value_quantile() {
335        let mut q = QuantileBinning::new();
336        q.observe(42.0);
337        assert_eq!(q.quantile(0.0), Some(42.0));
338        assert_eq!(q.quantile(0.5), Some(42.0));
339        assert_eq!(q.quantile(1.0), Some(42.0));
340    }
341
342    #[test]
343    fn empty_state_quantile_returns_none() {
344        let q = QuantileBinning::new();
345        assert_eq!(q.quantile(0.5), None);
346    }
347
348    #[test]
349    fn two_values() {
350        let mut q = QuantileBinning::new();
351        q.observe(10.0);
352        q.observe(20.0);
353        assert_eq!(q.count(), 2);
354
355        // Median should be one of the two values.
356        let med = q.quantile(0.5).unwrap();
357        assert!(med == 10.0 || med == 20.0);
358    }
359
360    // ---------------------------------------------------------------
361    // Sequential insertion: median accuracy
362    // ---------------------------------------------------------------
363
364    #[test]
365    fn sequential_1000_median_close_to_500() {
366        let mut q = QuantileBinning::new(); // epsilon = 0.01
367        for v in 0..1000 {
368            q.observe(v as f64);
369        }
370
371        let median = q.quantile(0.5).unwrap();
372        // True median is 500. With epsilon=0.01 and N=1000, error bound is 10.
373        assert!(
374            (median - 500.0).abs() <= 10.0,
375            "median = {median}, expected ~500.0 (within +/-10)"
376        );
377    }
378
379    #[test]
380    fn sequential_1000_quartiles() {
381        let mut q = QuantileBinning::new();
382        for v in 0..1000 {
383            q.observe(v as f64);
384        }
385
386        let tolerance = 0.01 * 1000.0; // = 10
387
388        let q25 = q.quantile(0.25).unwrap();
389        assert!(
390            (q25 - 250.0).abs() <= tolerance,
391            "q25 = {q25}, expected ~250 (tol {tolerance})"
392        );
393
394        let q75 = q.quantile(0.75).unwrap();
395        assert!(
396            (q75 - 750.0).abs() <= tolerance,
397            "q75 = {q75}, expected ~750 (tol {tolerance})"
398        );
399    }
400
401    // ---------------------------------------------------------------
402    // Reverse insertion order (worst case for some sketches)
403    // ---------------------------------------------------------------
404
405    #[test]
406    fn reverse_insertion_median() {
407        let mut q = QuantileBinning::new();
408        for v in (0..1000).rev() {
409            q.observe(v as f64);
410        }
411
412        let median = q.quantile(0.5).unwrap();
413        assert!(
414            (median - 500.0).abs() <= 10.0,
415            "median = {median}, expected ~500.0 (within +/-10)"
416        );
417    }
418
419    // ---------------------------------------------------------------
420    // Compression effectiveness
421    // ---------------------------------------------------------------
422
423    #[test]
424    fn compression_keeps_summary_bounded() {
425        let mut q = QuantileBinning::new(); // epsilon = 0.01
426        for v in 0..10_000 {
427            q.observe(v as f64);
428        }
429
430        // Theoretical bound: O((1/epsilon) * log(epsilon * N))
431        // = O(100 * log(100)) ~ O(100 * 4.6) ~ 460.
432        // In practice it should be well under 10000.
433        let len = q.summary_len();
434        assert!(
435            len < 2000,
436            "summary length {len} should be much less than 10000"
437        );
438        // Also verify it's significantly compressed.
439        assert!(
440            len < 10_000 / 3,
441            "summary length {len} should be at most ~1/3 of N"
442        );
443    }
444
445    #[test]
446    fn compression_large_epsilon() {
447        let mut q = QuantileBinning::with_epsilon(0.1);
448        for v in 0..10_000 {
449            q.observe(v as f64);
450        }
451
452        // Larger epsilon -> more aggressive compression -> smaller summary.
453        let len = q.summary_len();
454        assert!(
455            len < 500,
456            "with epsilon=0.1, summary length {len} should be < 500"
457        );
458    }
459
460    // ---------------------------------------------------------------
461    // compute_edges (BinningStrategy trait)
462    // ---------------------------------------------------------------
463
464    #[test]
465    fn compute_edges_4_bins_roughly_quartiles() {
466        let mut q = QuantileBinning::new();
467        for v in 0..1000 {
468            q.observe(v as f64);
469        }
470
471        let edges = q.compute_edges(4);
472        // Should have ~3 edges (quartile boundaries) after dedup.
473        assert!(!edges.edges.is_empty(), "should produce at least one edge");
474        assert!(
475            edges.edges.len() <= 3,
476            "4 bins -> at most 3 edges, got {}",
477            edges.edges.len()
478        );
479
480        let tolerance = 0.01 * 1000.0;
481
482        if edges.edges.len() == 3 {
483            assert!(
484                (edges.edges[0] - 250.0).abs() <= tolerance,
485                "first edge {} should be ~250",
486                edges.edges[0]
487            );
488            assert!(
489                (edges.edges[1] - 500.0).abs() <= tolerance,
490                "second edge {} should be ~500",
491                edges.edges[1]
492            );
493            assert!(
494                (edges.edges[2] - 750.0).abs() <= tolerance,
495                "third edge {} should be ~750",
496                edges.edges[2]
497            );
498        }
499    }
500
501    #[test]
502    fn compute_edges_sorted() {
503        let mut q = QuantileBinning::new();
504        for v in 0..500 {
505            q.observe(v as f64);
506        }
507
508        let edges = q.compute_edges(10);
509        for i in 1..edges.edges.len() {
510            assert!(
511                edges.edges[i] > edges.edges[i - 1],
512                "edges must be strictly increasing: edges[{}]={} <= edges[{}]={}",
513                i,
514                edges.edges[i],
515                i - 1,
516                edges.edges[i - 1]
517            );
518        }
519    }
520
521    #[test]
522    fn compute_edges_empty_sketch() {
523        let q = QuantileBinning::new();
524        let edges = q.compute_edges(4);
525        assert!(edges.edges.is_empty());
526    }
527
528    #[test]
529    fn compute_edges_single_bin() {
530        let mut q = QuantileBinning::new();
531        q.observe(1.0);
532        q.observe(2.0);
533        let edges = q.compute_edges(1);
534        assert!(edges.edges.is_empty(), "1 bin means 0 edges");
535    }
536
537    // ---------------------------------------------------------------
538    // Deduplication in compute_edges
539    // ---------------------------------------------------------------
540
541    #[test]
542    fn compute_edges_deduplicates_identical_values() {
543        let mut q = QuantileBinning::new();
544        // All identical values: every quantile query returns the same value.
545        for _ in 0..1000 {
546            q.observe(7.0);
547        }
548
549        let edges = q.compute_edges(4);
550        // Should be deduplicated to at most 1 unique edge.
551        assert!(
552            edges.edges.len() <= 1,
553            "all-same input should dedup to <=1 edge, got {}",
554            edges.edges.len()
555        );
556    }
557
558    #[test]
559    fn compute_edges_deduplicates_two_clusters() {
560        let mut q = QuantileBinning::new();
561        // Two clusters: 500 values of 1.0, then 500 values of 100.0.
562        for _ in 0..500 {
563            q.observe(1.0);
564        }
565        for _ in 0..500 {
566            q.observe(100.0);
567        }
568
569        let edges = q.compute_edges(10);
570        // With heavy dedup, we might get very few unique edges.
571        // The key test: no adjacent duplicates.
572        for i in 1..edges.edges.len() {
573            assert!(
574                (edges.edges[i] - edges.edges[i - 1]).abs() > f64::EPSILON,
575                "adjacent edges should not be equal"
576            );
577        }
578    }
579
580    // ---------------------------------------------------------------
581    // Error bound with epsilon = 0.05
582    // ---------------------------------------------------------------
583
584    #[test]
585    fn error_within_epsilon_005() {
586        let mut q = QuantileBinning::with_epsilon(0.05);
587        let n = 2000u64;
588        for v in 0..n {
589            q.observe(v as f64);
590        }
591
592        let tolerance = 0.05 * n as f64; // = 100
593
594        // Check several quantiles.
595        for &phi in &[0.1, 0.25, 0.5, 0.75, 0.9] {
596            let result = q.quantile(phi).unwrap();
597            let expected = phi * n as f64;
598            assert!(
599                (result - expected).abs() <= tolerance,
600                "phi={phi}: result={result}, expected~{expected}, tolerance={tolerance}"
601            );
602        }
603    }
604
605    #[test]
606    fn error_within_epsilon_001_large_n() {
607        let mut q = QuantileBinning::with_epsilon(0.01);
608        let n = 5000u64;
609        for v in 0..n {
610            q.observe(v as f64);
611        }
612
613        let tolerance = 0.01 * n as f64; // = 50
614
615        for &phi in &[0.1, 0.25, 0.5, 0.75, 0.9] {
616            let result = q.quantile(phi).unwrap();
617            let expected = phi * n as f64;
618            assert!(
619                (result - expected).abs() <= tolerance,
620                "phi={phi}: result={result}, expected~{expected}, tolerance={tolerance}"
621            );
622        }
623    }
624
625    // ---------------------------------------------------------------
626    // Extremes: quantile(0) and quantile(1)
627    // ---------------------------------------------------------------
628
629    #[test]
630    fn quantile_zero_returns_minimum() {
631        let mut q = QuantileBinning::new();
632        for v in 10..110 {
633            q.observe(v as f64);
634        }
635        let min_approx = q.quantile(0.0).unwrap();
636        // Should be the actual minimum or very close.
637        assert!(
638            (min_approx - 10.0).abs() <= 1.0,
639            "quantile(0.0) = {min_approx}, expected ~10.0"
640        );
641    }
642
643    #[test]
644    fn quantile_one_returns_maximum() {
645        let mut q = QuantileBinning::new();
646        for v in 10..110 {
647            q.observe(v as f64);
648        }
649        let max_approx = q.quantile(1.0).unwrap();
650        // Should be the actual maximum or very close.
651        assert!(
652            (max_approx - 109.0).abs() <= 1.0,
653            "quantile(1.0) = {max_approx}, expected ~109.0"
654        );
655    }
656
657    // ---------------------------------------------------------------
658    // reset / clone_fresh
659    // ---------------------------------------------------------------
660
661    #[test]
662    fn reset_clears_state() {
663        let mut q = QuantileBinning::new();
664        for v in 0..100 {
665            q.observe(v as f64);
666        }
667        assert_eq!(q.count(), 100);
668        assert!(q.summary_len() > 0);
669
670        q.reset();
671        assert_eq!(q.count(), 0);
672        assert_eq!(q.summary_len(), 0);
673        assert_eq!(q.quantile(0.5), None);
674    }
675
676    #[test]
677    fn clone_fresh_preserves_epsilon() {
678        let mut q = QuantileBinning::with_epsilon(0.05);
679        for v in 0..100 {
680            q.observe(v as f64);
681        }
682
683        let fresh = q.clone_fresh();
684        let edges = fresh.compute_edges(4);
685        assert!(edges.edges.is_empty(), "fresh instance should be empty");
686    }
687
688    // ---------------------------------------------------------------
689    // find_bin integration
690    // ---------------------------------------------------------------
691
692    #[test]
693    fn find_bin_with_quantile_edges() {
694        let mut q = QuantileBinning::new();
695        for v in 0..1000 {
696            q.observe(v as f64);
697        }
698        let edges = q.compute_edges(4);
699
700        // Values below the first edge go into bin 0.
701        assert_eq!(edges.find_bin(0.0), 0);
702
703        // Values above the last edge go into the last bin.
704        assert_eq!(edges.find_bin(999.0), edges.n_bins() - 1);
705    }
706
707    // ---------------------------------------------------------------
708    // Shuffled / random-ish input
709    // ---------------------------------------------------------------
710
711    #[test]
712    fn shuffled_input_median() {
713        let mut q = QuantileBinning::new();
714
715        // Simple deterministic pseudo-shuffle using a stride coprime to N.
716        let n = 1000u64;
717        let stride = 397; // coprime to 1000
718        let mut val = 0u64;
719        for _ in 0..n {
720            q.observe(val as f64);
721            val = (val + stride) % n;
722        }
723
724        let median = q.quantile(0.5).unwrap();
725        let tolerance = 0.01 * n as f64;
726        assert!(
727            (median - 500.0).abs() <= tolerance,
728            "shuffled median = {median}, expected ~500 (tol {tolerance})"
729        );
730    }
731
732    // ---------------------------------------------------------------
733    // Negative values
734    // ---------------------------------------------------------------
735
736    #[test]
737    fn negative_range_quantiles() {
738        let mut q = QuantileBinning::new();
739        for v in -500..500 {
740            q.observe(v as f64);
741        }
742
743        let median = q.quantile(0.5).unwrap();
744        let tolerance = 0.01 * 1000.0;
745        assert!(
746            median.abs() <= tolerance,
747            "median = {median}, expected ~0.0 (tol {tolerance})"
748        );
749    }
750
751    // ---------------------------------------------------------------
752    // Floating point values
753    // ---------------------------------------------------------------
754
755    #[test]
756    fn floating_point_values() {
757        let mut q = QuantileBinning::new();
758        for i in 0..1000 {
759            q.observe(i as f64 * 0.001); // 0.000, 0.001, ..., 0.999
760        }
761
762        let median = q.quantile(0.5).unwrap();
763        let tolerance = 0.01 * 1.0; // epsilon * range
764        assert!(
765            (median - 0.5).abs() <= tolerance,
766            "median = {median}, expected ~0.5 (tol {tolerance})"
767        );
768    }
769
770    // ---------------------------------------------------------------
771    // Many bins
772    // ---------------------------------------------------------------
773
774    #[test]
775    fn many_bins_fine_grained() {
776        // Use a small epsilon so the sketch has enough resolution for 100 bins.
777        // With epsilon=0.001 and N=10000, error bound is 10, so quantile queries
778        // spaced 100 apart (for 100 bins over range 10000) are well-separated.
779        let mut q = QuantileBinning::with_epsilon(0.001);
780        for v in 0..10_000 {
781            q.observe(v as f64);
782        }
783
784        let edges = q.compute_edges(100);
785        // Should produce close to 99 edges. With epsilon=0.001 and 10000 distinct
786        // values spaced 100 apart per bin, dedup should be minimal.
787        assert!(
788            edges.edges.len() >= 90,
789            "100 bins with 10000 distinct values (eps=0.001) should give ~99 edges, got {}",
790            edges.edges.len()
791        );
792
793        // All edges should be strictly increasing.
794        for i in 1..edges.edges.len() {
795            assert!(
796                edges.edges[i] > edges.edges[i - 1],
797                "edges must be strictly increasing"
798            );
799        }
800    }
801}