Skip to main content

ph_surfaces/axis/
bucketed.rs

1//! The bucketed strategy: stored knots plus a static bucket index that narrows
2//! an irregular axis to a bounded local range before scanning it.
3
4use super::{AxisLookup, KnotArray, assert_valid_knots, sealed};
5
6/// Returns the bucket `coordinate` falls into on an axis spanning
7/// `first ..= last`, partitioned into `B` buckets.
8///
9/// The partition is `((coordinate - first) * B) / (span + 1)`, which puts bucket
10/// boundaries at `first + ceil(b * (span + 1) / B)`. Every intermediate fits a
11/// `u32`: `span + 1` is at most `65_536`, `B` is at most `65_536`, and the
12/// product `(coordinate - first) * B` is at most `65_535 * 65_536`, which is
13/// `u32::MAX - 65_535`.
14///
15/// # Preconditions
16///
17/// `first <= coordinate <= last` and `1 <= B <= 65_536`, both established by
18/// construction. The result is then always below `B`.
19const fn bucket_of(coordinate: u16, first: u16, last: u16, b: usize) -> usize {
20    let span = (last - first) as u32;
21    let offset = (coordinate - first) as u32;
22
23    (offset * (b as u32) / (span + 1)) as usize
24}
25
26/// Returns the first coordinate belonging to bucket `bucket`:
27/// `first + ceil(bucket * (span + 1) / B)`.
28///
29/// This is the inverse of [`bucket_of`] at a boundary, and it is what makes the
30/// partition *nested*: the boundaries for `B` buckets are exactly the
31/// even-numbered boundaries for `2*B` buckets, so doubling `B` splits buckets
32/// instead of moving them.
33///
34/// More buckets than the axis has coordinates is allowed and harmless: the
35/// surplus buckets start past the last knot, no in-domain coordinate can select
36/// one, and they are reported as starting at the last knot so that the
37/// arithmetic stays inside `u16`.
38///
39/// # Preconditions
40///
41/// `bucket < B <= 65_536`, so `bucket * (span + 1) + B - 1` stays inside `u32`.
42const fn bucket_start(bucket: usize, first: u16, last: u16, b: usize) -> u16 {
43    let span = (last - first) as u32;
44    let width = span + 1;
45    let numerator = (bucket as u32) * width + (b as u32) - 1;
46    let start = first as u32 + numerator / (b as u32);
47
48    if start > last as u32 {
49        last
50    } else {
51        start as u16
52    }
53}
54
55/// Validates the dimensions shared by bucket-index generation and use.
56const fn assert_valid_bucket_dimensions<const N: usize, const B: usize>(knots: &[u16; N]) {
57    assert_valid_knots(knots);
58    assert!(
59        N <= 65_536,
60        "a bucketed axis declares at most 65_536 knots, so every index fits a u16"
61    );
62    assert!(B >= 1, "a bucket index must declare at least one bucket");
63    assert!(
64        B <= 65_536,
65        "a bucket index declares at most 65_536 buckets"
66    );
67}
68
69/// Validates that `index` is the exact bucket table derived from `knots`.
70const fn assert_valid_bucket_index<const N: usize, const B: usize>(
71    knots: &[u16; N],
72    index: &[u16; B],
73) {
74    assert_valid_bucket_dimensions::<N, B>(knots);
75
76    let first = knots[0];
77    let last = knots[N - 1];
78
79    // Bucket starts are non-decreasing, so one knot cursor validates the whole
80    // table in O(B + N) rather than restarting an O(N) scan for every bucket.
81    let mut knot = 0;
82    let mut bucket = 0;
83    while bucket < B {
84        let start = bucket_start(bucket, first, last, B);
85        while knot + 1 < N && knots[knot + 1] <= start {
86            knot += 1;
87        }
88
89        assert!(
90            index[bucket] as usize == knot,
91            "the bucket index does not match its knots; build it with bucket_index"
92        );
93        bucket += 1;
94    }
95}
96
97/// Builds the bucket index for `knots`, for use as a `static`.
98///
99/// Entry `b` is the index of the greatest knot at or below the first coordinate
100/// of bucket `b`, which is exactly what [`BucketedAxis::new`] validates and what
101/// [`BucketedAxis`] starts its local scan from. The whole table is computed at
102/// compile time: there is no runtime construction and nothing to allocate.
103///
104/// # Panics
105///
106/// Panics unless `N >= 2` and `N <= 65_536`, the knots are strictly increasing,
107/// `B >= 1`, and `B <= 65_536`. In a `static` or `const` definition that panic
108/// is a compile error.
109///
110/// # Examples
111///
112/// ```
113/// use ph_surfaces::{BucketedAxis, bucket_index};
114///
115/// static KNOTS: [u16; 6] = [0, 1, 2, 3, 400, 1_000];
116/// static INDEX: [u16; 4] = bucket_index(&KNOTS);
117/// static AXIS: BucketedAxis<6, 4> = BucketedAxis::new(&KNOTS, &INDEX);
118///
119/// // Buckets start at 0, 251, 501 and 751; the knots at or below those are
120/// // 3 (index 3), 400 (index 4), 400 and 400.
121/// assert_eq!(INDEX, [0, 3, 4, 4]);
122/// ```
123#[must_use]
124pub const fn bucket_index<const N: usize, const B: usize>(knots: &[u16; N]) -> [u16; B] {
125    assert_valid_bucket_dimensions::<N, B>(knots);
126
127    let first = knots[0];
128    let last = knots[N - 1];
129
130    let mut index = [0u16; B];
131    let mut knot = 0;
132    let mut bucket = 0;
133
134    while bucket < B {
135        let start = bucket_start(bucket, first, last, B);
136        while knot + 1 < N && knots[knot + 1] <= start {
137            knot += 1;
138        }
139
140        index[bucket] = knot as u16;
141        bucket += 1;
142    }
143
144    index
145}
146
147/// Returns the exact worst-case local scan for one bucketed axis: the most knot
148/// comparisons any in-domain search can perform after the bucket read.
149///
150/// Bucket `b` starts its scan at `index[b]` and cannot pass `index[b + 1]` (or
151/// the last knot, for the final bucket). The scan compares once per knot it
152/// steps towards, so its cost is exactly that difference. This function reports
153/// the largest such cost over every bucket.
154///
155/// It is a function rather than an associated constant because the answer
156/// depends on where the knots actually fall, not only on `N` and `B`.
157/// [`AxisLookup::MAX_SEARCH_COMPARISONS`] states the structural bound that holds
158/// for any knots; this states the exact one for these knots.
159///
160/// Because the partition is nested, raising `B` to a multiple of itself can only
161/// split buckets, never move a boundary, so this figure never increases when `B`
162/// grows that way.
163///
164/// # Panics
165///
166/// Panics unless `N >= 2` and `N <= 65_536`, the knots are strictly increasing,
167/// `1 <= B <= 65_536`, and every entry of `index` equals the entry
168/// [`bucket_index`] derives for the same knots.
169///
170/// # Examples
171///
172/// ```
173/// use ph_surfaces::{bucket_index, max_local_comparisons};
174///
175/// // Four knots clustered at the bottom and one far away.
176/// static KNOTS: [u16; 5] = [0, 1, 2, 3, 1_000];
177/// static COARSE: [u16; 2] = bucket_index(&KNOTS);
178/// static FINE: [u16; 8] = bucket_index(&KNOTS);
179///
180/// // A finer index cannot make the local scan longer.
181/// assert!(max_local_comparisons(&KNOTS, &FINE) <= max_local_comparisons(&KNOTS, &COARSE));
182/// ```
183///
184/// An invalid table is rejected during constant evaluation rather than
185/// producing a profile-dependent bound at runtime:
186///
187/// ```compile_fail
188/// use ph_surfaces::max_local_comparisons;
189///
190/// const KNOTS: [u16; 2] = [0, 10];
191/// const DESCENDING: [u16; 2] = [1, 0];
192/// const INVALID_BOUND: u32 = max_local_comparisons(&KNOTS, &DESCENDING);
193/// ```
194#[must_use]
195pub const fn max_local_comparisons<const N: usize, const B: usize>(
196    knots: &[u16; N],
197    index: &[u16; B],
198) -> u32 {
199    assert_valid_bucket_index(knots, index);
200
201    let mut worst = 0;
202    let mut bucket = 0;
203
204    while bucket < B {
205        let start = index[bucket] as u32;
206        let end = if bucket + 1 < B {
207            index[bucket + 1] as u32
208        } else {
209            (N - 1) as u32
210        };
211
212        // `end >= start` because the exact index validation above establishes
213        // the same non-decreasing table as `BucketedAxis::new`.
214        let cost = end - start;
215        if cost > worst {
216            worst = cost;
217        }
218
219        bucket += 1;
220    }
221
222    worst
223}
224
225/// An axis of `N` stored knots with a static index of `B` buckets.
226///
227/// The index turns an irregular axis into a bounded local problem: one division
228/// selects a bucket, the bucket names the first knot that can hold the answer,
229/// and a short scan finishes the job. It is the strategy to reach for when an
230/// axis is long and unevenly spaced and a few index bytes are worth a smaller
231/// search bound; [`UniformAxis`](crate::UniformAxis) is better when the spacing
232/// is regular, and [`BinaryAxis`](crate::BinaryAxis) when no extra bytes are
233/// wanted.
234///
235/// The index is built at compile time by [`bucket_index`] and re-derived by
236/// [`BucketedAxis::new`], so a stale or hand-written table fails to compile.
237/// Nothing is constructed, cached, or mutated at runtime.
238///
239/// # Cost
240///
241/// `2*N` stored knot bytes plus `2*B` index bytes. After the endpoint checks,
242/// the strategy reads one bucket and then performs at most
243/// [`max_local_comparisons`] knot comparisons — a figure exact for these knots,
244/// which never grows when `B` is raised to a multiple of itself.
245///
246/// # Examples
247///
248/// ```
249/// use ph_surfaces::{BilinearSurface, BinaryAxis, BucketedAxis, bucket_index, max_local_comparisons};
250///
251/// // Tightly clustered at the bottom, then a long tail.
252/// static X: [u16; 6] = [0, 1, 2, 3, 400, 1_000];
253/// static X_INDEX: [u16; 8] = bucket_index(&X);
254/// static Y: [u16; 2] = [0, 10];
255/// static VALUES: [[i32; 6]; 2] = [[0, 1, 2, 3, 400, 1_000], [10, 11, 12, 13, 410, 1_010]];
256///
257/// static SURFACE: BilinearSurface<6, 2, BucketedAxis<6, 8>, BinaryAxis<2>> =
258///     BilinearSurface::from_axes(BucketedAxis::new(&X, &X_INDEX), BinaryAxis::new(&Y), &VALUES);
259///
260/// // The same answers as the default binary surface over the same tables.
261/// static DEFAULT: BilinearSurface<6, 2> = BilinearSurface::new(&X, &Y, &VALUES);
262/// assert_eq!(SURFACE.evaluate(700, 5), DEFAULT.evaluate(700, 5));
263///
264/// // Three comparisons at worst, on an axis a plain scan could take five to
265/// // walk: the index paid two bytes a bucket to bound the walk.
266/// assert_eq!(max_local_comparisons(&X, &X_INDEX), 3);
267/// ```
268///
269/// A bucket table that does not match its knots does not compile:
270///
271/// ```compile_fail
272/// use ph_surfaces::BucketedAxis;
273///
274/// static X: [u16; 6] = [0, 1, 2, 3, 400, 1_000];
275/// static WRONG: [u16; 4] = [0, 0, 0, 0];
276/// static AXIS: BucketedAxis<6, 4> = BucketedAxis::new(&X, &WRONG);
277/// ```
278#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
279pub struct BucketedAxis<const N: usize, const B: usize> {
280    knots: &'static [u16; N],
281    index: &'static [u16; B],
282}
283
284impl<const N: usize, const B: usize> BucketedAxis<N, B> {
285    /// Declares a bucketed axis over static knots and a static bucket index.
286    ///
287    /// # Panics
288    ///
289    /// Panics unless the axis declares at least two strictly increasing knots,
290    /// `N <= 65_536` so every knot index is representable in the table,
291    /// `1 <= B <= 65_536`, and every entry of `index` equals the entry
292    /// [`bucket_index`] derives for the same knots. In a constant or static
293    /// definition that panic is a compile error, so an axis cannot reach runtime
294    /// with an index that disagrees with its knots.
295    #[must_use]
296    pub const fn new(knots: &'static [u16; N], index: &'static [u16; B]) -> Self {
297        assert_valid_bucket_index(knots, index);
298
299        Self { knots, index }
300    }
301
302    /// Returns the declared knots.
303    ///
304    /// The same array as [`KnotArray::knots`], available in a constant context.
305    #[must_use]
306    pub const fn knots(&self) -> &'static [u16; N] {
307        self.knots
308    }
309
310    /// Returns the declared bucket index.
311    #[must_use]
312    pub const fn index(&self) -> &'static [u16; B] {
313        self.index
314    }
315
316    /// Returns the exact worst-case local scan for this axis, in knot
317    /// comparisons: [`max_local_comparisons`] over its knots and index.
318    #[must_use]
319    pub const fn max_local_comparisons(&self) -> u32 {
320        max_local_comparisons(self.knots, self.index)
321    }
322}
323
324impl<const N: usize, const B: usize> sealed::Sealed<N> for BucketedAxis<N, B> {
325    #[inline(always)]
326    fn search_in_domain(&self, coordinate: u16) -> (usize, u32) {
327        let first = self.knots[0];
328        let last = self.knots[N - 1];
329
330        debug_assert!(
331            first <= coordinate && coordinate <= last,
332            "the sealed search is only called on an in-domain coordinate"
333        );
334
335        let bucket = bucket_of(coordinate, first, last, B);
336        debug_assert!(bucket < B, "the partition must not name a missing bucket");
337
338        // The bucket names where the answer starts; the next bucket names where
339        // it must have been found. The coordinate is below the next bucket's
340        // first coordinate, so its knot cannot be above that bucket's entry.
341        let mut index = self.index[bucket] as usize;
342        let end = if bucket + 1 < B {
343            self.index[bucket + 1] as usize
344        } else {
345            N - 1
346        };
347        let local_bound = (end - index) as u32;
348        let mut comparisons = 0;
349
350        while index < end {
351            comparisons += 1;
352            if self.knots[index + 1] > coordinate {
353                break;
354            }
355            index += 1;
356        }
357
358        debug_assert!(
359            comparisons <= local_bound,
360            "the local scan must stay inside its selected bucket"
361        );
362        debug_assert!(
363            self.knots[index] <= coordinate,
364            "the located knot must not sit above the coordinate"
365        );
366
367        (index, comparisons)
368    }
369}
370
371impl<const N: usize, const B: usize> KnotArray<N> for BucketedAxis<N, B> {
372    fn knots(&self) -> &'static [u16; N] {
373        self.knots
374    }
375}
376
377impl<const N: usize, const B: usize> AxisLookup<N> for BucketedAxis<N, B> {
378    const KNOT_BYTES: usize = 2 * N;
379    const INDEX_BYTES: usize = 2 * B;
380    // The structural bound, true for any knots: a bucket can hold the whole
381    // axis, and then the local scan is the whole scan. The exact bound for a
382    // particular axis is `BucketedAxis::max_local_comparisons`, which is what
383    // the index is bought for.
384    const MAX_SEARCH_COMPARISONS: u32 = (N - 1) as u32;
385
386    fn first(&self) -> u16 {
387        self.knots[0]
388    }
389
390    fn last(&self) -> u16 {
391        self.knots[N - 1]
392    }
393
394    fn knot(&self, index: usize) -> u16 {
395        self.knots[index]
396    }
397}
398
399#[cfg(test)]
400mod tests {
401    use super::{BucketedAxis, bucket_index, max_local_comparisons};
402    use crate::axis::{AxisLookup, BinaryAxis, KnotArray, probes};
403
404    // Clustered at the bottom with a long tail: the shape a bucket index is for.
405    static CLUSTERED: [u16; 6] = [0, 1, 2, 3, 400, 1_000];
406    static CLUSTERED_2: [u16; 2] = bucket_index(&CLUSTERED);
407    static CLUSTERED_4: [u16; 4] = bucket_index(&CLUSTERED);
408    static CLUSTERED_8: [u16; 8] = bucket_index(&CLUSTERED);
409    static CLUSTERED_16: [u16; 16] = bucket_index(&CLUSTERED);
410
411    // Two clusters at opposite ends of the full `u16` span.
412    static SPREAD: [u16; 9] = [3, 4, 5, 1_000, 40_000, 65_000, 65_500, 65_530, 65_535];
413    static SPREAD_4: [u16; 4] = bucket_index(&SPREAD);
414    static SPREAD_8: [u16; 8] = bucket_index(&SPREAD);
415    static SPREAD_16: [u16; 16] = bucket_index(&SPREAD);
416
417    // The minimum axis and the minimum index.
418    static TINY: [u16; 2] = [7, 9];
419    static TINY_1: [u16; 1] = bucket_index(&TINY);
420
421    // Far more buckets than the axis has coordinates, at the very top of the
422    // `u16` range: the surplus buckets would start past `u16::MAX` if the
423    // partition arithmetic did not stop at the last knot.
424    static CROWDED: [u16; 2] = [65_533, 65_535];
425    static CROWDED_8: [u16; 8] = bucket_index(&CROWDED);
426
427    const CLUSTERED_AXIS: BucketedAxis<6, 8> = BucketedAxis::new(&CLUSTERED, &CLUSTERED_8);
428    const SPREAD_AXIS: BucketedAxis<9, 8> = BucketedAxis::new(&SPREAD, &SPREAD_8);
429    const TINY_AXIS: BucketedAxis<2, 1> = BucketedAxis::new(&TINY, &TINY_1);
430
431    #[test]
432    fn the_generated_index_names_the_knot_at_or_below_each_bucket_start() {
433        // Buckets start at 0, 251, 501, 751; the greatest knot at or below each
434        // is 3 (index 3), 400 (index 4), 400, 400.
435        assert_eq!(CLUSTERED_4, [0, 3, 4, 4]);
436        // With one bucket the index can only name the first knot.
437        assert_eq!(TINY_1, [0]);
438        // Non-decreasing on every fixture, which is what makes the local range
439        // well formed.
440        for index in [&CLUSTERED_8[..], &SPREAD_16[..]] {
441            assert!(index.windows(2).all(|w| w[0] <= w[1]));
442        }
443    }
444
445    #[test]
446    fn a_bucketed_search_locates_the_same_index_as_the_binary_search() {
447        macro_rules! agrees_with_binary {
448            ($axis:expr, $knots:expr, $stride:expr) => {
449                let axis = $axis;
450                let binary = BinaryAxis::new($knots);
451
452                for coordinate in probes($knots, $stride) {
453                    assert_eq!(
454                        axis.search(coordinate).0,
455                        binary.search(coordinate).0,
456                        "at {coordinate}"
457                    );
458                }
459            };
460        }
461
462        agrees_with_binary!(CLUSTERED_AXIS, &CLUSTERED, 1);
463        agrees_with_binary!(SPREAD_AXIS, &SPREAD, 97);
464        agrees_with_binary!(TINY_AXIS, &TINY, 1);
465
466        // Every bucket count on the same knots, so the partition itself is
467        // exercised rather than one convenient value of `B`.
468        agrees_with_binary!(BucketedAxis::new(&CLUSTERED, &CLUSTERED_2), &CLUSTERED, 1);
469        agrees_with_binary!(BucketedAxis::new(&CLUSTERED, &CLUSTERED_4), &CLUSTERED, 1);
470        agrees_with_binary!(BucketedAxis::new(&CLUSTERED, &CLUSTERED_16), &CLUSTERED, 1);
471        agrees_with_binary!(BucketedAxis::new(&SPREAD, &SPREAD_4), &SPREAD, 97);
472        agrees_with_binary!(BucketedAxis::new(&SPREAD, &SPREAD_16), &SPREAD, 97);
473    }
474
475    #[test]
476    fn raising_the_bucket_count_never_worsens_the_local_bound() {
477        let clustered = [
478            max_local_comparisons(&CLUSTERED, &CLUSTERED_2),
479            max_local_comparisons(&CLUSTERED, &CLUSTERED_4),
480            max_local_comparisons(&CLUSTERED, &CLUSTERED_8),
481            max_local_comparisons(&CLUSTERED, &CLUSTERED_16),
482        ];
483        assert!(
484            clustered.windows(2).all(|w| w[1] <= w[0]),
485            "nested bucket counts worsened the bound: {clustered:?}"
486        );
487
488        let spread = [
489            max_local_comparisons(&SPREAD, &SPREAD_4),
490            max_local_comparisons(&SPREAD, &SPREAD_8),
491            max_local_comparisons(&SPREAD, &SPREAD_16),
492        ];
493        assert!(
494            spread.windows(2).all(|w| w[1] <= w[0]),
495            "nested bucket counts worsened the bound: {spread:?}"
496        );
497
498        // And the index actually buys something: the coarsest table on the
499        // clustered axis is worse than the finest.
500        assert!(clustered[3] < clustered[0]);
501    }
502
503    #[test]
504    fn the_local_scan_stays_inside_the_exact_bound() {
505        for axis in [CLUSTERED_AXIS] {
506            let bound = axis.max_local_comparisons();
507            for coordinate in axis.first()..=axis.last() {
508                assert!(axis.search(coordinate).1 <= bound, "at {coordinate}");
509            }
510        }
511
512        let bound = SPREAD_AXIS.max_local_comparisons();
513        let mut coordinate = SPREAD_AXIS.first();
514        while coordinate < SPREAD_AXIS.last() {
515            assert!(SPREAD_AXIS.search(coordinate).1 <= bound, "at {coordinate}");
516            coordinate = coordinate.saturating_add(101);
517        }
518
519        // A one-bucket index degenerates to a scan of the whole axis, and says
520        // so rather than pretending otherwise.
521        assert_eq!(max_local_comparisons(&TINY, &TINY_1), 1);
522    }
523
524    #[test]
525    fn more_buckets_than_coordinates_stays_inside_u16_and_still_locates() {
526        // Declaring this index at all is the arithmetic evidence: every bucket
527        // start is computed during const evaluation, and one past `u16::MAX`
528        // would fail the build rather than reach this assertion.
529        let axis = BucketedAxis::new(&CROWDED, &CROWDED_8);
530        let binary = BinaryAxis::new(&CROWDED);
531
532        assert_eq!(CROWDED_8, [0, 0, 0, 1, 1, 1, 1, 1]);
533        for coordinate in 65_533u16..=65_535 {
534            assert_eq!(
535                axis.search(coordinate).0,
536                binary.search(coordinate).0,
537                "at {coordinate}"
538            );
539        }
540        assert_eq!(max_local_comparisons(&CROWDED, &CROWDED_8), 1);
541    }
542
543    #[test]
544    fn a_bucket_index_costs_exactly_two_bytes_per_bucket() {
545        assert_eq!(<BucketedAxis<6, 8>>::KNOT_BYTES, 12);
546        assert_eq!(<BucketedAxis<6, 8>>::INDEX_BYTES, 16);
547        assert_eq!(<BucketedAxis<9, 16>>::INDEX_BYTES, 32);
548        assert_eq!(<BucketedAxis<2, 1>>::INDEX_BYTES, 2);
549    }
550
551    #[test]
552    fn the_tables_are_referenced_and_never_copied() {
553        assert!(core::ptr::eq(CLUSTERED_AXIS.knots(), &CLUSTERED));
554        assert!(core::ptr::eq(KnotArray::knots(&CLUSTERED_AXIS), &CLUSTERED));
555        assert!(core::ptr::eq(CLUSTERED_AXIS.index(), &CLUSTERED_8));
556    }
557
558    #[test]
559    #[should_panic(expected = "the bucket index does not match its knots")]
560    fn an_index_that_does_not_match_its_knots_is_rejected() {
561        static WRONG: [u16; 4] = [0, 0, 0, 0];
562
563        let _ = BucketedAxis::new(&CLUSTERED, &WRONG);
564    }
565
566    #[test]
567    #[should_panic(expected = "the bucket index does not match its knots")]
568    fn an_index_built_for_different_knots_is_rejected() {
569        static OTHER: [u16; 6] = [0, 200, 400, 600, 800, 1_000];
570        static OTHER_INDEX: [u16; 4] = bucket_index(&OTHER);
571
572        // The table is internally valid, but not for these knots.
573        let _ = BucketedAxis::new(&CLUSTERED, &OTHER_INDEX);
574    }
575
576    #[test]
577    #[should_panic(expected = "an axis must declare at least two knots")]
578    fn a_one_knot_axis_is_rejected() {
579        static ONE: [u16; 1] = [3];
580        static ONE_INDEX: [u16; 1] = [0];
581
582        let _ = BucketedAxis::new(&ONE, &ONE_INDEX);
583    }
584
585    #[test]
586    #[should_panic(expected = "a bucket index must declare at least one bucket")]
587    fn an_empty_bucket_index_is_rejected() {
588        static NONE: [u16; 0] = [];
589
590        let _ = BucketedAxis::new(&CLUSTERED, &NONE);
591    }
592}