Skip to main content

ph_surfaces/axis/
binary.rs

1//! The binary strategy: stored knots, no auxiliary index, exact logarithmic
2//! search. This is the crate's default and the general-purpose choice.
3
4use super::{AxisLookup, KnotArray, assert_valid_knots, probe_bound, sealed};
5
6/// An axis of `N` stored knots located by binary search.
7///
8/// This is the strategy a [`BilinearSurface`](crate::BilinearSurface) selects
9/// when its type does not say otherwise, and it is the right answer for almost
10/// every axis: it accepts arbitrary spacing, stores nothing beyond the knots
11/// themselves, and its search cost grows logarithmically.
12///
13/// # Cost
14///
15/// `2*N` stored bytes, no index, and exactly `ceil(log2(N))`
16/// strategy-specific knot comparisons after the endpoint checks — for a
17/// 1024-knot axis, ten comparisons where a scan would take up to 1024.
18///
19/// The count is exact rather than a bound, and it depends only on `N`: the same
20/// number of comparisons for an endpoint, for an exact interior knot, and for a
21/// point strictly inside a segment. There is no early exit on an exact match,
22/// deliberately, because it would trade a uniform and auditable step count for a
23/// data-dependent one.
24///
25/// # Examples
26///
27/// ```
28/// use ph_surfaces::{BilinearSurface, BinaryAxis};
29///
30/// static X: [u16; 4] = [0, 10, 90, 500];
31/// static Y: [u16; 2] = [0, 100];
32/// static VALUES: [[i32; 4]; 2] = [[0, 10, 90, 500], [100, 110, 190, 600]];
33///
34/// // Naming the strategy and letting it default are the same surface.
35/// static NAMED: BilinearSurface<4, 2, BinaryAxis<4>, BinaryAxis<2>> =
36///     BilinearSurface::from_axes(BinaryAxis::new(&X), BinaryAxis::new(&Y), &VALUES);
37/// static DEFAULTED: BilinearSurface<4, 2> = BilinearSurface::new(&X, &Y, &VALUES);
38///
39/// assert_eq!(NAMED.evaluate(50, 50), DEFAULTED.evaluate(50, 50));
40/// assert_eq!(NAMED, DEFAULTED);
41/// ```
42///
43/// An axis of fewer than two knots does not compile:
44///
45/// ```compile_fail
46/// use ph_surfaces::BinaryAxis;
47///
48/// static X: [u16; 1] = [7];
49/// static AXIS: BinaryAxis<1> = BinaryAxis::new(&X);
50/// ```
51///
52/// Nor does one whose knots are not strictly increasing:
53///
54/// ```compile_fail
55/// use ph_surfaces::BinaryAxis;
56///
57/// static X: [u16; 3] = [0, 100, 50];
58/// static AXIS: BinaryAxis<3> = BinaryAxis::new(&X);
59/// ```
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
61pub struct BinaryAxis<const N: usize> {
62    knots: &'static [u16; N],
63}
64
65impl<const N: usize> BinaryAxis<N> {
66    /// Declares a binary-searched axis over static knots.
67    ///
68    /// # Panics
69    ///
70    /// Panics unless the axis declares at least two strictly increasing knots.
71    /// In a constant or static definition that panic is a compile error, so an
72    /// invalid axis cannot be declared.
73    #[must_use]
74    pub const fn new(knots: &'static [u16; N]) -> Self {
75        assert_valid_knots(knots);
76
77        Self { knots }
78    }
79
80    /// Returns the declared knots.
81    ///
82    /// The same array as [`KnotArray::knots`], available in a constant context.
83    #[must_use]
84    pub const fn knots(&self) -> &'static [u16; N] {
85        self.knots
86    }
87}
88
89impl<const N: usize> sealed::Sealed<N> for BinaryAxis<N> {
90    #[inline(always)]
91    fn search_in_domain(&self, coordinate: u16) -> (usize, u32) {
92        debug_assert!(
93            self.knots[0] <= coordinate && coordinate <= self.knots[N - 1],
94            "the sealed search is only called on an in-domain coordinate"
95        );
96
97        let mut base = 0;
98        let mut size = N;
99        let mut probes = 0;
100
101        while size > 1 {
102            let half = size / 2;
103            let mid = base + half;
104
105            // The answer stays in `base ..= base + size - 1`: a knot at or below
106            // the coordinate moves the window's floor up to `mid`, and a knot
107            // above it leaves the floor alone. Either way the window shrinks.
108            if self.knots[mid] <= coordinate {
109                base = mid;
110            }
111
112            size -= half;
113            probes += 1;
114        }
115
116        debug_assert_eq!(
117            probes,
118            <Self as AxisLookup<N>>::MAX_SEARCH_COMPARISONS,
119            "the probe count must match the documented bound exactly"
120        );
121        debug_assert!(
122            self.knots[base] <= coordinate,
123            "the located knot must not sit above the coordinate"
124        );
125
126        (base, probes)
127    }
128}
129
130impl<const N: usize> KnotArray<N> for BinaryAxis<N> {
131    fn knots(&self) -> &'static [u16; N] {
132        self.knots
133    }
134}
135
136impl<const N: usize> AxisLookup<N> for BinaryAxis<N> {
137    const KNOT_BYTES: usize = 2 * N;
138    const INDEX_BYTES: usize = 0;
139    const MAX_SEARCH_COMPARISONS: u32 = probe_bound(N);
140
141    fn first(&self) -> u16 {
142        self.knots[0]
143    }
144
145    fn last(&self) -> u16 {
146        self.knots[N - 1]
147    }
148
149    fn knot(&self, index: usize) -> u16 {
150        self.knots[index]
151    }
152}
153
154#[cfg(test)]
155mod tests {
156    use super::BinaryAxis;
157    use crate::axis::{AxisLookup, KnotArray, probe_bound};
158
159    static X_MAIN: [u16; 5] = [10, 20, 30, 40, 50];
160    static X_SPARSE: [u16; 6] = [3, 4, 5, 1_000, 40_000, 65_000];
161    static X_FULL: [u16; 4] = [0, 1, 32_768, 65_535];
162    static X_TINY: [u16; 2] = [7, 9];
163
164    // A thousand knots, so a scan would cost up to 1024 comparisons where the
165    // search costs ten.
166    static X_BIG: [u16; 1024] = {
167        let mut axis = [0u16; 1024];
168        let mut i = 0;
169        let mut knot = 0u16;
170
171        while i < 1024 {
172            axis[i] = knot;
173            i += 1;
174
175            // Guarded so the final increment cannot leave `u16`: the last knot
176            // is 1023 * 64 == 65_472.
177            if i < 1024 {
178                knot += 64;
179            }
180        }
181
182        axis
183    };
184
185    const MAIN: BinaryAxis<5> = BinaryAxis::new(&X_MAIN);
186    const SPARSE: BinaryAxis<6> = BinaryAxis::new(&X_SPARSE);
187    const FULL: BinaryAxis<4> = BinaryAxis::new(&X_FULL);
188    const TINY: BinaryAxis<2> = BinaryAxis::new(&X_TINY);
189    const BIG: BinaryAxis<1024> = BinaryAxis::new(&X_BIG);
190
191    // An independent statement of `ceil(log2(len))`: it counts the halvings
192    // rather than reading a bit length, so agreement is evidence that the
193    // documented formula and the loop shape describe the same thing.
194    fn ceil_log2_reference(len: usize) -> u32 {
195        let mut remaining = len;
196        let mut steps = 0;
197
198        while remaining > 1 {
199            remaining = remaining.div_ceil(2);
200            steps += 1;
201        }
202
203        steps
204    }
205
206    #[test]
207    fn the_probe_bound_matches_the_documented_formula() {
208        for len in [2usize, 3, 4, 5, 6, 7, 8, 9, 16, 17, 1_024, 65_535, 65_536] {
209            assert_eq!(probe_bound(len), ceil_log2_reference(len), "length {len}");
210        }
211
212        assert_eq!(probe_bound(1_024), 10);
213        assert_eq!(<BinaryAxis<1024>>::MAX_SEARCH_COMPARISONS, 10);
214        assert_eq!(<BinaryAxis<5>>::MAX_SEARCH_COMPARISONS, 3);
215        assert_eq!(<BinaryAxis<2>>::MAX_SEARCH_COMPARISONS, 1);
216    }
217
218    #[test]
219    fn the_search_probe_count_is_exact_and_data_independent() {
220        macro_rules! exact_on {
221            ($axis:expr) => {
222                let axis = $axis;
223                let expected = axis.knots().len();
224                let expected = probe_bound(expected);
225
226                for &knot in axis.knots() {
227                    assert_eq!(axis.search(knot).1, expected, "at knot {knot}");
228
229                    if knot > axis.first() {
230                        assert_eq!(axis.search(knot - 1).1, expected);
231                    }
232                    if knot < axis.last() {
233                        assert_eq!(axis.search(knot + 1).1, expected);
234                    }
235                }
236            };
237        }
238
239        exact_on!(MAIN);
240        exact_on!(SPARSE);
241        exact_on!(FULL);
242        exact_on!(TINY);
243        exact_on!(BIG);
244
245        // A sweep across one axis: the count never moves off the bound, which is
246        // what "independent of the data" means.
247        let mut coordinate = 0u16;
248        while coordinate < 65_472 {
249            assert_eq!(BIG.search(coordinate).1, 10);
250            coordinate += 397;
251        }
252    }
253
254    #[test]
255    fn binary_lookup_costs_far_fewer_comparisons_than_a_scan() {
256        let (_, probes) = BIG.search(40_000);
257        let scan = u32::try_from(X_BIG.len()).expect("the axis length fits in u32");
258
259        assert_eq!(probes, 10);
260        assert!(
261            probes * 100 < scan,
262            "{probes} probes is not two orders of magnitude below {scan}"
263        );
264    }
265
266    #[test]
267    fn the_located_index_is_the_greatest_knot_at_or_below_the_coordinate() {
268        for (index, &knot) in X_SPARSE.iter().enumerate() {
269            assert_eq!(SPARSE.search(knot).0, index, "at knot {knot}");
270
271            if knot > SPARSE.first() {
272                assert_eq!(SPARSE.search(knot - 1).0, index - 1);
273            }
274        }
275
276        assert_eq!(SPARSE.search(999).0, 2);
277        assert_eq!(SPARSE.search(1_000).0, 3);
278        assert_eq!(SPARSE.search(39_999).0, 3);
279        assert_eq!(FULL.search(65_535).0, 3);
280        assert_eq!(TINY.search(8).0, 0);
281    }
282
283    #[test]
284    fn the_knot_array_is_referenced_and_never_copied() {
285        assert!(core::ptr::eq(MAIN.knots(), &X_MAIN));
286        assert!(core::ptr::eq(KnotArray::knots(&MAIN), &X_MAIN));
287        assert_eq!(<BinaryAxis<5>>::KNOT_BYTES, 10);
288        assert_eq!(<BinaryAxis<5>>::INDEX_BYTES, 0);
289    }
290
291    #[test]
292    #[should_panic(expected = "an axis must declare at least two knots")]
293    fn a_one_knot_axis_is_rejected() {
294        static ONE: [u16; 1] = [3];
295
296        let _ = BinaryAxis::new(&ONE);
297    }
298
299    #[test]
300    #[should_panic(expected = "axis knots must be strictly increasing")]
301    fn a_duplicated_knot_is_rejected() {
302        static DUPLICATE: [u16; 2] = [5, 5];
303
304        let _ = BinaryAxis::new(&DUPLICATE);
305    }
306
307    #[test]
308    #[should_panic(expected = "axis knots must be strictly increasing")]
309    fn a_descending_knot_is_rejected() {
310        static DESCENDING: [u16; 3] = [0, 100, 50];
311
312        let _ = BinaryAxis::new(&DESCENDING);
313    }
314}