Skip to main content

ph_surfaces/axis/
uniform.rs

1//! The uniform strategy: an origin, a step, and a count carried in the type, so
2//! the axis stores no knots at all and locates a coordinate by arithmetic.
3
4use super::{AxisLookup, sealed};
5
6/// An axis of `N` evenly spaced knots, described by `ORIGIN` and `STEP` rather
7/// than stored.
8///
9/// Knot `i` is `ORIGIN + i * STEP`, so the whole axis is `ORIGIN`, `STEP`, and
10/// `N` — all three in the type. The value is zero-sized: an evenly spaced axis
11/// costs no static bytes, adds nothing to the surface handle, and needs no knot
12/// array to walk or probe. Because the descriptor is a compile-time constant,
13/// the division that locates a cell is a division by a constant in every
14/// instantiation.
15///
16/// This is the one strategy that cannot describe an arbitrary axis. If the
17/// spacing is irregular, use [`BinaryAxis`](crate::BinaryAxis), or
18/// [`BucketedAxis`](crate::BucketedAxis) when a smaller search bound is worth
19/// index bytes.
20///
21/// # Cost
22///
23/// No stored bytes, no index, and no strategy-specific knot comparison after
24/// the endpoint checks: one subtraction and one division locate the cell
25/// regardless of `N`.
26///
27/// # Examples
28///
29/// ```
30/// use ph_surfaces::{BilinearSurface, BinaryAxis, UniformAxis};
31///
32/// // Five knots at 0, 25, 50, 75, 100 — declared, not stored.
33/// static Y: [u16; 2] = [0, 10];
34/// static VALUES: [[i32; 5]; 2] = [[0, 25, 50, 75, 100], [10, 35, 60, 85, 110]];
35///
36/// static SURFACE: BilinearSurface<5, 2, UniformAxis<5, 0, 25>, BinaryAxis<2>> =
37///     BilinearSurface::from_axes(UniformAxis::new(), BinaryAxis::new(&Y), &VALUES);
38///
39/// assert_eq!(SURFACE.x_knot(3), 75);
40/// assert_eq!(SURFACE.evaluate(60, 0), Ok(60));
41/// assert_eq!(SURFACE.evaluate(100, 10), Ok(110));
42/// ```
43///
44/// The same surface with the knots spelled out and located binarily returns the
45/// same values:
46///
47/// ```
48/// use ph_surfaces::{BilinearSurface, BinaryAxis, UniformAxis};
49///
50/// static X: [u16; 5] = [0, 25, 50, 75, 100];
51/// static Y: [u16; 2] = [0, 10];
52/// static VALUES: [[i32; 5]; 2] = [[0, 25, 50, 75, 100], [10, 35, 60, 85, 110]];
53///
54/// static UNIFORM: BilinearSurface<5, 2, UniformAxis<5, 0, 25>, BinaryAxis<2>> =
55///     BilinearSurface::from_axes(UniformAxis::new(), BinaryAxis::new(&Y), &VALUES);
56/// static STORED: BilinearSurface<5, 2> = BilinearSurface::new(&X, &Y, &VALUES);
57///
58/// for x in [0u16, 1, 37, 50, 99, 100] {
59///     assert_eq!(UNIFORM.evaluate(x, 5), STORED.evaluate(x, 5));
60/// }
61/// ```
62///
63/// A single-knot axis does not compile:
64///
65/// ```compile_fail
66/// use ph_surfaces::UniformAxis;
67///
68/// static AXIS: UniformAxis<1, 0, 25> = UniformAxis::new();
69/// ```
70///
71/// Nor does a zero step, which would declare `N` copies of one knot:
72///
73/// ```compile_fail
74/// use ph_surfaces::UniformAxis;
75///
76/// static AXIS: UniformAxis<5, 0, 0> = UniformAxis::new();
77/// ```
78///
79/// Nor does a descriptor whose last knot leaves `u16`:
80///
81/// ```compile_fail
82/// use ph_surfaces::UniformAxis;
83///
84/// static AXIS: UniformAxis<5, 60_000, 2_000> = UniformAxis::new();
85/// ```
86///
87/// The descriptor cannot bypass [`UniformAxis::new`]; its zero-sized field is
88/// private:
89///
90/// ```compile_fail
91/// use ph_surfaces::UniformAxis;
92///
93/// static AXIS: UniformAxis<5, 0, 25> = UniformAxis(());
94/// ```
95#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
96pub struct UniformAxis<const N: usize, const ORIGIN: u16, const STEP: u16>(());
97
98impl<const N: usize, const ORIGIN: u16, const STEP: u16> Default for UniformAxis<N, ORIGIN, STEP> {
99    /// Returns [`UniformAxis::new`], validating the descriptor.
100    ///
101    /// It is written out rather than derived on purpose: a derived `Default`
102    /// would hand back an axis whose descriptor had never been checked, and the
103    /// rest of the crate relies on every axis having been.
104    fn default() -> Self {
105        Self::new()
106    }
107}
108
109impl<const N: usize, const ORIGIN: u16, const STEP: u16> UniformAxis<N, ORIGIN, STEP> {
110    /// Declares an evenly spaced axis.
111    ///
112    /// # Panics
113    ///
114    /// Panics unless the descriptor names at least two strictly increasing knots
115    /// that are all representable: `N >= 2`, `STEP >= 1`, and
116    /// `N <= 65_536`, and `ORIGIN + (N - 1) * STEP <= u16::MAX`. In a constant
117    /// or static definition that panic is a compile error, so an unrepresentable
118    /// axis cannot be declared.
119    #[must_use]
120    pub const fn new() -> Self {
121        assert!(N >= 2, "an axis must declare at least two knots");
122        assert!(
123            N <= 65_536,
124            "a uniform u16 axis declares at most 65_536 knots"
125        );
126        assert!(
127            STEP >= 1,
128            "a uniform axis must declare a step of at least 1"
129        );
130        assert!(
131            (ORIGIN as usize) + (N - 1) * (STEP as usize) <= u16::MAX as usize,
132            "the last uniform knot must be representable in u16"
133        );
134
135        Self(())
136    }
137
138    /// Returns the first knot, `ORIGIN`.
139    ///
140    /// The same value as [`AxisLookup::first`], available in a constant context.
141    #[must_use]
142    pub const fn origin(&self) -> u16 {
143        ORIGIN
144    }
145
146    /// Returns the spacing between consecutive knots, `STEP`.
147    #[must_use]
148    pub const fn step(&self) -> u16 {
149        STEP
150    }
151
152    /// The descriptor arithmetic, in its one home: `ORIGIN + index * STEP`.
153    ///
154    /// Private and unchecked so the always-in-range callers (`last`, the
155    /// trait impl) do not carry the public accessor's assert -- an extra
156    /// panic path here is measurable in the emitted-instruction snapshot.
157    ///
158    /// Bounded above by the last knot, which `new` proved representable.
159    const fn nth(index: usize) -> u16 {
160        ((ORIGIN as u32) + (index as u32) * (STEP as u32)) as u16
161    }
162
163    /// Returns the knot at `index`, computed from the descriptor.
164    ///
165    /// The same value as [`AxisLookup::knot`], available in a constant
166    /// context.
167    ///
168    /// # Panics
169    ///
170    /// Panics if `index >= N`.
171    #[must_use]
172    pub const fn knot(&self, index: usize) -> u16 {
173        assert!(index < N, "knot index is outside the axis");
174
175        Self::nth(index)
176    }
177
178    /// Returns the last knot, `ORIGIN + (N - 1) * STEP`.
179    ///
180    /// The same value as [`AxisLookup::last`], available in a constant
181    /// context. `inline(always)` because the body folds to one constant; an
182    /// outlined copy would be all call overhead.
183    #[must_use]
184    #[inline(always)]
185    pub const fn last(&self) -> u16 {
186        Self::nth(N - 1)
187    }
188}
189
190impl<const N: usize, const ORIGIN: u16, const STEP: u16> sealed::Sealed<N>
191    for UniformAxis<N, ORIGIN, STEP>
192{
193    #[inline(always)]
194    fn search_in_domain(&self, coordinate: u16) -> (usize, u32) {
195        debug_assert!(
196            ORIGIN <= coordinate && coordinate <= <Self as AxisLookup<N>>::last(self),
197            "the sealed search is only called on an in-domain coordinate"
198        );
199
200        // The whole location: one subtraction and one division by a constant.
201        // No knot is read and no knot is compared, which is why the reported
202        // comparison count is zero rather than merely small.
203        let index = ((coordinate - ORIGIN) / STEP) as usize;
204
205        debug_assert!(index < N, "a located index must stay inside the axis");
206
207        (index, 0)
208    }
209}
210
211impl<const N: usize, const ORIGIN: u16, const STEP: u16> AxisLookup<N>
212    for UniformAxis<N, ORIGIN, STEP>
213{
214    const KNOT_BYTES: usize = 0;
215    const INDEX_BYTES: usize = 0;
216    const MAX_SEARCH_COMPARISONS: u32 = 0;
217
218    fn first(&self) -> u16 {
219        ORIGIN
220    }
221
222    // Both delegate to the const inherent methods above, which own the
223    // descriptor arithmetic; inherent methods win resolution, so these calls
224    // are not self-recursive. `inline(always)` keeps the delegation free:
225    // without it the wrappers survive as outlined 8-byte functions in the
226    // measurement objects, which the code-size snapshot counts.
227    #[inline(always)]
228    fn last(&self) -> u16 {
229        Self::last(self)
230    }
231
232    #[inline(always)]
233    fn knot(&self, index: usize) -> u16 {
234        Self::knot(self, index)
235    }
236}
237
238#[cfg(test)]
239mod tests {
240    use super::UniformAxis;
241    use crate::axis::{AxisLookup, BinaryAxis};
242    use core::mem::size_of;
243
244    const SMALL: UniformAxis<5, 0, 25> = UniformAxis::new();
245    const OFFSET: UniformAxis<9, 100, 50> = UniformAxis::new();
246    const UNIT: UniformAxis<2, 7, 1> = UniformAxis::new();
247    // The widest representable uniform axis: 0 and 65_535 in one step.
248    const WIDE: UniformAxis<2, 0, 65_535> = UniformAxis::new();
249    // The largest representable knot count: every u16 value at unit spacing.
250    const FULL_COUNT: UniformAxis<65_536, 0, 1> = UniformAxis::new();
251
252    static SMALL_KNOTS: [u16; 5] = [0, 25, 50, 75, 100];
253    static OFFSET_KNOTS: [u16; 9] = [100, 150, 200, 250, 300, 350, 400, 450, 500];
254    static WIDE_KNOTS: [u16; 2] = [0, 65_535];
255
256    #[test]
257    fn the_declared_knots_are_the_stored_knots_of_the_equivalent_axis() {
258        for (index, &knot) in SMALL_KNOTS.iter().enumerate() {
259            assert_eq!(SMALL.knot(index), knot);
260        }
261        for (index, &knot) in OFFSET_KNOTS.iter().enumerate() {
262            assert_eq!(OFFSET.knot(index), knot);
263        }
264
265        assert_eq!((SMALL.first(), SMALL.last()), (0, 100));
266        assert_eq!((OFFSET.first(), OFFSET.last()), (100, 500));
267        assert_eq!((UNIT.first(), UNIT.last()), (7, 8));
268        assert_eq!((WIDE.first(), WIDE.last()), (0, 65_535));
269        assert_eq!((FULL_COUNT.first(), FULL_COUNT.last()), (0, 65_535));
270        assert_eq!(FULL_COUNT.knot(65_535), 65_535);
271    }
272
273    #[test]
274    fn arithmetic_location_agrees_with_the_binary_search_of_the_same_axis() {
275        let binary = BinaryAxis::new(&SMALL_KNOTS);
276        for coordinate in 0u16..=100 {
277            assert_eq!(
278                SMALL.search(coordinate).0,
279                binary.search(coordinate).0,
280                "at {coordinate}"
281            );
282        }
283
284        let binary = BinaryAxis::new(&OFFSET_KNOTS);
285        for coordinate in 100u16..=500 {
286            assert_eq!(
287                OFFSET.search(coordinate).0,
288                binary.search(coordinate).0,
289                "at {coordinate}"
290            );
291        }
292
293        let binary = BinaryAxis::new(&WIDE_KNOTS);
294        for coordinate in [0u16, 1, 32_767, 32_768, 65_534, 65_535] {
295            assert_eq!(WIDE.search(coordinate).0, binary.search(coordinate).0);
296        }
297    }
298
299    #[test]
300    fn a_uniform_search_compares_no_knots_at_all() {
301        assert_eq!(<UniformAxis<9, 100, 50>>::MAX_SEARCH_COMPARISONS, 0);
302
303        for coordinate in 100u16..=500 {
304            assert_eq!(OFFSET.search(coordinate).1, 0);
305        }
306    }
307
308    #[test]
309    fn a_uniform_axis_stores_nothing() {
310        assert_eq!(<UniformAxis<9, 100, 50>>::KNOT_BYTES, 0);
311        assert_eq!(<UniformAxis<9, 100, 50>>::INDEX_BYTES, 0);
312        assert_eq!(size_of::<UniformAxis<9, 100, 50>>(), 0);
313        assert_eq!(size_of::<UniformAxis<65_536, 0, 1>>(), 0);
314    }
315
316    #[test]
317    fn the_descriptor_is_readable_without_a_knot_array() {
318        assert_eq!(OFFSET.origin(), 100);
319        assert_eq!(OFFSET.step(), 50);
320        assert_eq!(WIDE.step(), 65_535);
321    }
322
323    #[test]
324    #[should_panic(expected = "an axis must declare at least two knots")]
325    fn a_one_knot_descriptor_is_rejected() {
326        let _ = <UniformAxis<1, 0, 25>>::new();
327    }
328
329    #[test]
330    #[should_panic(expected = "a uniform axis must declare a step of at least 1")]
331    fn a_zero_step_is_rejected() {
332        let _ = <UniformAxis<5, 0, 0>>::new();
333    }
334
335    #[test]
336    #[should_panic(expected = "the last uniform knot must be representable in u16")]
337    fn a_descriptor_whose_last_knot_leaves_u16_is_rejected() {
338        let _ = <UniformAxis<5, 60_000, 2_000>>::new();
339    }
340
341    #[cfg(target_pointer_width = "64")]
342    #[test]
343    #[should_panic(expected = "a uniform u16 axis declares at most 65_536 knots")]
344    fn an_oversized_count_is_rejected_before_narrowing() {
345        let _ = <UniformAxis<{ (u32::MAX as usize) + 2 }, 0, 1>>::new();
346    }
347
348    #[test]
349    #[should_panic(expected = "knot index is outside the axis")]
350    fn a_knot_index_outside_the_axis_is_rejected() {
351        let _ = SMALL.knot(5);
352    }
353}