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}