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}