use super::{AxisLookup, KnotArray, assert_valid_knots, sealed};
const fn bucket_of(coordinate: u16, first: u16, last: u16, b: usize) -> usize {
let span = (last - first) as u32;
let offset = (coordinate - first) as u32;
(offset * (b as u32) / (span + 1)) as usize
}
const fn bucket_start(bucket: usize, first: u16, last: u16, b: usize) -> u16 {
let span = (last - first) as u32;
let width = span + 1;
let numerator = (bucket as u32) * width + (b as u32) - 1;
let start = first as u32 + numerator / (b as u32);
if start > last as u32 {
last
} else {
start as u16
}
}
const fn assert_valid_bucket_dimensions<const N: usize, const B: usize>(knots: &[u16; N]) {
assert_valid_knots(knots);
assert!(
N <= 65_536,
"a bucketed axis declares at most 65_536 knots, so every index fits a u16"
);
assert!(B >= 1, "a bucket index must declare at least one bucket");
assert!(
B <= 65_536,
"a bucket index declares at most 65_536 buckets"
);
}
const fn assert_valid_bucket_index<const N: usize, const B: usize>(
knots: &[u16; N],
index: &[u16; B],
) {
assert_valid_bucket_dimensions::<N, B>(knots);
let first = knots[0];
let last = knots[N - 1];
let mut knot = 0;
let mut bucket = 0;
while bucket < B {
let start = bucket_start(bucket, first, last, B);
while knot + 1 < N && knots[knot + 1] <= start {
knot += 1;
}
assert!(
index[bucket] as usize == knot,
"the bucket index does not match its knots; build it with bucket_index"
);
bucket += 1;
}
}
#[must_use]
pub const fn bucket_index<const N: usize, const B: usize>(knots: &[u16; N]) -> [u16; B] {
assert_valid_bucket_dimensions::<N, B>(knots);
let first = knots[0];
let last = knots[N - 1];
let mut index = [0u16; B];
let mut knot = 0;
let mut bucket = 0;
while bucket < B {
let start = bucket_start(bucket, first, last, B);
while knot + 1 < N && knots[knot + 1] <= start {
knot += 1;
}
index[bucket] = knot as u16;
bucket += 1;
}
index
}
#[must_use]
pub const fn max_local_comparisons<const N: usize, const B: usize>(
knots: &[u16; N],
index: &[u16; B],
) -> u32 {
assert_valid_bucket_index(knots, index);
let mut worst = 0;
let mut bucket = 0;
while bucket < B {
let start = index[bucket] as u32;
let end = if bucket + 1 < B {
index[bucket + 1] as u32
} else {
(N - 1) as u32
};
let cost = end - start;
if cost > worst {
worst = cost;
}
bucket += 1;
}
worst
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct BucketedAxis<const N: usize, const B: usize> {
knots: &'static [u16; N],
index: &'static [u16; B],
}
impl<const N: usize, const B: usize> BucketedAxis<N, B> {
#[must_use]
pub const fn new(knots: &'static [u16; N], index: &'static [u16; B]) -> Self {
assert_valid_bucket_index(knots, index);
Self { knots, index }
}
#[must_use]
pub const fn knots(&self) -> &'static [u16; N] {
self.knots
}
#[must_use]
pub const fn index(&self) -> &'static [u16; B] {
self.index
}
#[must_use]
pub const fn max_local_comparisons(&self) -> u32 {
max_local_comparisons(self.knots, self.index)
}
}
impl<const N: usize, const B: usize> sealed::Sealed<N> for BucketedAxis<N, B> {
#[inline(always)]
fn search_in_domain(&self, coordinate: u16) -> (usize, u32) {
let first = self.knots[0];
let last = self.knots[N - 1];
debug_assert!(
first <= coordinate && coordinate <= last,
"the sealed search is only called on an in-domain coordinate"
);
let bucket = bucket_of(coordinate, first, last, B);
debug_assert!(bucket < B, "the partition must not name a missing bucket");
let mut index = self.index[bucket] as usize;
let end = if bucket + 1 < B {
self.index[bucket + 1] as usize
} else {
N - 1
};
let local_bound = (end - index) as u32;
let mut comparisons = 0;
while index < end {
comparisons += 1;
if self.knots[index + 1] > coordinate {
break;
}
index += 1;
}
debug_assert!(
comparisons <= local_bound,
"the local scan must stay inside its selected bucket"
);
debug_assert!(
self.knots[index] <= coordinate,
"the located knot must not sit above the coordinate"
);
(index, comparisons)
}
}
impl<const N: usize, const B: usize> KnotArray<N> for BucketedAxis<N, B> {
fn knots(&self) -> &'static [u16; N] {
self.knots
}
}
impl<const N: usize, const B: usize> AxisLookup<N> for BucketedAxis<N, B> {
const KNOT_BYTES: usize = 2 * N;
const INDEX_BYTES: usize = 2 * B;
const MAX_SEARCH_COMPARISONS: u32 = (N - 1) as u32;
fn first(&self) -> u16 {
self.knots[0]
}
fn last(&self) -> u16 {
self.knots[N - 1]
}
fn knot(&self, index: usize) -> u16 {
self.knots[index]
}
}
#[cfg(test)]
mod tests {
use super::{BucketedAxis, bucket_index, max_local_comparisons};
use crate::axis::{AxisLookup, BinaryAxis, KnotArray, probes};
static CLUSTERED: [u16; 6] = [0, 1, 2, 3, 400, 1_000];
static CLUSTERED_2: [u16; 2] = bucket_index(&CLUSTERED);
static CLUSTERED_4: [u16; 4] = bucket_index(&CLUSTERED);
static CLUSTERED_8: [u16; 8] = bucket_index(&CLUSTERED);
static CLUSTERED_16: [u16; 16] = bucket_index(&CLUSTERED);
static SPREAD: [u16; 9] = [3, 4, 5, 1_000, 40_000, 65_000, 65_500, 65_530, 65_535];
static SPREAD_4: [u16; 4] = bucket_index(&SPREAD);
static SPREAD_8: [u16; 8] = bucket_index(&SPREAD);
static SPREAD_16: [u16; 16] = bucket_index(&SPREAD);
static TINY: [u16; 2] = [7, 9];
static TINY_1: [u16; 1] = bucket_index(&TINY);
static CROWDED: [u16; 2] = [65_533, 65_535];
static CROWDED_8: [u16; 8] = bucket_index(&CROWDED);
const CLUSTERED_AXIS: BucketedAxis<6, 8> = BucketedAxis::new(&CLUSTERED, &CLUSTERED_8);
const SPREAD_AXIS: BucketedAxis<9, 8> = BucketedAxis::new(&SPREAD, &SPREAD_8);
const TINY_AXIS: BucketedAxis<2, 1> = BucketedAxis::new(&TINY, &TINY_1);
#[test]
fn the_generated_index_names_the_knot_at_or_below_each_bucket_start() {
assert_eq!(CLUSTERED_4, [0, 3, 4, 4]);
assert_eq!(TINY_1, [0]);
for index in [&CLUSTERED_8[..], &SPREAD_16[..]] {
assert!(index.windows(2).all(|w| w[0] <= w[1]));
}
}
#[test]
fn a_bucketed_search_locates_the_same_index_as_the_binary_search() {
macro_rules! agrees_with_binary {
($axis:expr, $knots:expr, $stride:expr) => {
let axis = $axis;
let binary = BinaryAxis::new($knots);
for coordinate in probes($knots, $stride) {
assert_eq!(
axis.search(coordinate).0,
binary.search(coordinate).0,
"at {coordinate}"
);
}
};
}
agrees_with_binary!(CLUSTERED_AXIS, &CLUSTERED, 1);
agrees_with_binary!(SPREAD_AXIS, &SPREAD, 97);
agrees_with_binary!(TINY_AXIS, &TINY, 1);
agrees_with_binary!(BucketedAxis::new(&CLUSTERED, &CLUSTERED_2), &CLUSTERED, 1);
agrees_with_binary!(BucketedAxis::new(&CLUSTERED, &CLUSTERED_4), &CLUSTERED, 1);
agrees_with_binary!(BucketedAxis::new(&CLUSTERED, &CLUSTERED_16), &CLUSTERED, 1);
agrees_with_binary!(BucketedAxis::new(&SPREAD, &SPREAD_4), &SPREAD, 97);
agrees_with_binary!(BucketedAxis::new(&SPREAD, &SPREAD_16), &SPREAD, 97);
}
#[test]
fn raising_the_bucket_count_never_worsens_the_local_bound() {
let clustered = [
max_local_comparisons(&CLUSTERED, &CLUSTERED_2),
max_local_comparisons(&CLUSTERED, &CLUSTERED_4),
max_local_comparisons(&CLUSTERED, &CLUSTERED_8),
max_local_comparisons(&CLUSTERED, &CLUSTERED_16),
];
assert!(
clustered.windows(2).all(|w| w[1] <= w[0]),
"nested bucket counts worsened the bound: {clustered:?}"
);
let spread = [
max_local_comparisons(&SPREAD, &SPREAD_4),
max_local_comparisons(&SPREAD, &SPREAD_8),
max_local_comparisons(&SPREAD, &SPREAD_16),
];
assert!(
spread.windows(2).all(|w| w[1] <= w[0]),
"nested bucket counts worsened the bound: {spread:?}"
);
assert!(clustered[3] < clustered[0]);
}
#[test]
fn the_local_scan_stays_inside_the_exact_bound() {
for axis in [CLUSTERED_AXIS] {
let bound = axis.max_local_comparisons();
for coordinate in axis.first()..=axis.last() {
assert!(axis.search(coordinate).1 <= bound, "at {coordinate}");
}
}
let bound = SPREAD_AXIS.max_local_comparisons();
let mut coordinate = SPREAD_AXIS.first();
while coordinate < SPREAD_AXIS.last() {
assert!(SPREAD_AXIS.search(coordinate).1 <= bound, "at {coordinate}");
coordinate = coordinate.saturating_add(101);
}
assert_eq!(max_local_comparisons(&TINY, &TINY_1), 1);
}
#[test]
fn more_buckets_than_coordinates_stays_inside_u16_and_still_locates() {
let axis = BucketedAxis::new(&CROWDED, &CROWDED_8);
let binary = BinaryAxis::new(&CROWDED);
assert_eq!(CROWDED_8, [0, 0, 0, 1, 1, 1, 1, 1]);
for coordinate in 65_533u16..=65_535 {
assert_eq!(
axis.search(coordinate).0,
binary.search(coordinate).0,
"at {coordinate}"
);
}
assert_eq!(max_local_comparisons(&CROWDED, &CROWDED_8), 1);
}
#[test]
fn a_bucket_index_costs_exactly_two_bytes_per_bucket() {
assert_eq!(<BucketedAxis<6, 8>>::KNOT_BYTES, 12);
assert_eq!(<BucketedAxis<6, 8>>::INDEX_BYTES, 16);
assert_eq!(<BucketedAxis<9, 16>>::INDEX_BYTES, 32);
assert_eq!(<BucketedAxis<2, 1>>::INDEX_BYTES, 2);
}
#[test]
fn the_tables_are_referenced_and_never_copied() {
assert!(core::ptr::eq(CLUSTERED_AXIS.knots(), &CLUSTERED));
assert!(core::ptr::eq(KnotArray::knots(&CLUSTERED_AXIS), &CLUSTERED));
assert!(core::ptr::eq(CLUSTERED_AXIS.index(), &CLUSTERED_8));
}
#[test]
#[should_panic(expected = "the bucket index does not match its knots")]
fn an_index_that_does_not_match_its_knots_is_rejected() {
static WRONG: [u16; 4] = [0, 0, 0, 0];
let _ = BucketedAxis::new(&CLUSTERED, &WRONG);
}
#[test]
#[should_panic(expected = "the bucket index does not match its knots")]
fn an_index_built_for_different_knots_is_rejected() {
static OTHER: [u16; 6] = [0, 200, 400, 600, 800, 1_000];
static OTHER_INDEX: [u16; 4] = bucket_index(&OTHER);
let _ = BucketedAxis::new(&CLUSTERED, &OTHER_INDEX);
}
#[test]
#[should_panic(expected = "an axis must declare at least two knots")]
fn a_one_knot_axis_is_rejected() {
static ONE: [u16; 1] = [3];
static ONE_INDEX: [u16; 1] = [0];
let _ = BucketedAxis::new(&ONE, &ONE_INDEX);
}
#[test]
#[should_panic(expected = "a bucket index must declare at least one bucket")]
fn an_empty_bucket_index_is_rejected() {
static NONE: [u16; 0] = [];
let _ = BucketedAxis::new(&CLUSTERED, &NONE);
}
}