Skip to main content

ph_surfaces/
surface.rs

1//! The validated static representation of a rectilinear bilinear surface.
2
3use crate::axis::{AxisLookup, BinaryAxis, BucketedAxis, LinearAxis, UniformAxis};
4use crate::boundary::BoundaryPolicy;
5
6/// A static rectilinear `u16 × u16 → i32` surface.
7///
8/// The handle references static tables and never owns or copies them. `NX` is
9/// the number of X knots and `NY` the number of Y knots.
10///
11/// # Orientation
12///
13/// The value grid is row-major with Y selecting the row and X selecting the
14/// column, so a value is addressed as `values[y][x]`. Because the grid type is
15/// `&'static [[i32; NX]; NY]`, a transposed grid is a type error when `NX != NY`,
16/// and there is no reachable dimension-mismatch outcome. For a square grid,
17/// the transposed shape has the same type, so preserving orientation remains
18/// the caller's responsibility.
19///
20/// # Lookup strategies
21///
22/// The last two type parameters select how each axis locates a coordinate, and
23/// they default to [`BinaryAxis`] — the general-purpose choice, and the only one
24/// [`BilinearSurface::new`] can produce. `BilinearSurface<NX, NY>` is therefore
25/// the binary-knotted surface.
26///
27/// A firmware that wants a different trade names it in the type and builds the
28/// surface with [`BilinearSurface::from_axes`]: [`LinearAxis`](crate::LinearAxis)
29/// for a tiny axis, [`UniformAxis`](crate::UniformAxis) for evenly spaced knots
30/// that then need not be stored at all, or
31/// [`BucketedAxis`](crate::BucketedAxis) to buy a smaller search bound on a long
32/// irregular axis with a few static index bytes. The two axes choose
33/// independently, and the choice is a type rather than a value: there is no
34/// runtime discriminant and no branch among strategies.
35///
36/// Choose [`LinearAxis`](crate::LinearAxis) for a tiny axis when the minimum
37/// auxiliary structure is what matters; [`BinaryAxis`] as the general default;
38/// [`UniformAxis`](crate::UniformAxis) when knots are evenly spaced, so the
39/// knot arrays can be dropped and location is constant work;
40/// [`BucketedAxis`](crate::BucketedAxis) for a long irregular axis when
41/// `2*B` extra index bytes buy a smaller local bound.
42///
43/// Whichever strategies a surface names, it locates the same cell, evaluates
44/// the same value, and reports the same errors. Only the stored bytes and the
45/// search work differ.
46///
47/// # Validation
48///
49/// [`BilinearSurface::new`] is a `const fn` that rejects fewer than two knots
50/// on either axis and any axis that is not strictly increasing. A definition
51/// that violates those invariants fails to compile. Every axis strategy
52/// validates itself the same way in its own `const fn` constructor, so a surface
53/// built with [`BilinearSurface::from_axes`] is validated before it exists.
54///
55/// # Storage
56///
57/// The referenced table element payload is exactly
58/// [`BilinearSurface::PAYLOAD_BYTES`]: the two axes'
59/// [`AxisLookup::KNOT_BYTES`](crate::AxisLookup::KNOT_BYTES) and
60/// [`AxisLookup::INDEX_BYTES`](crate::AxisLookup::INDEX_BYTES) plus
61/// [`BilinearSurface::VALUE_BYTES`] (`4*NX*NY`). For the default binary
62/// surface that equals `2*NX + 2*NY + 4*NX*NY` bytes. That figure excludes
63/// this handle, alignment, linker effects, code, stack, and binary or flash
64/// placement. It is not a total memory cost.
65///
66/// The handle always contains the value-grid reference and the four-byte
67/// boundary policy. Its remaining fields depend on the axis strategies:
68/// [`UniformAxis`] stores no reference, [`LinearAxis`] and [`BinaryAxis`] each
69/// store one knot-array reference, and [`BucketedAxis`] stores a knot-array and
70/// an index-array reference. The default binary/binary handle is therefore
71/// three thin references plus the policy and alignment padding. Its size is
72/// [`BilinearSurface::HANDLE_BYTES`], which is target-dependent.
73///
74/// # Equality
75///
76/// The derived [`PartialEq`] and [`Hash`] implementations compare and hash the
77/// referenced tables, not their addresses. Two handles over distinct but equal
78/// tables therefore compare equal, at a cost proportional to `NX * NY`.
79///
80/// # Examples
81///
82/// ```
83/// use ph_surfaces::{BilinearSurface, Boundary, BoundaryPolicy};
84///
85/// static X: [u16; 2] = [0, 100];
86/// static Y: [u16; 3] = [0, 100, 200];
87/// static VALUES: [[i32; 2]; 3] = [[0, 1], [2, 3], [4, 5]];
88///
89/// static SURFACE: BilinearSurface<2, 3> = BilinearSurface::new(&X, &Y, &VALUES)
90///     .with_policy(BoundaryPolicy::new().with_y_above(Boundary::Clamp));
91///
92/// assert_eq!(SURFACE.nx(), 2);
93/// assert_eq!(SURFACE.ny(), 3);
94/// assert_eq!(SURFACE.values()[2][1], 5);
95/// assert_eq!(SURFACE.policy().y_above(), Boundary::Clamp);
96/// assert_eq!(SURFACE.policy().y_below(), Boundary::Error);
97/// ```
98///
99/// A grid whose dimensions are swapped does not compile. Here the axes call
100/// for `[[i32; 2]; 3]` but the grid is `[[i32; 3]; 2]`:
101///
102/// ```compile_fail
103/// use ph_surfaces::BilinearSurface;
104///
105/// static X: [u16; 2] = [0, 100];
106/// static Y: [u16; 3] = [0, 100, 200];
107/// static VALUES: [[i32; 3]; 2] = [[0, 1, 2], [3, 4, 5]];
108///
109/// static SURFACE: BilinearSurface<2, 3> = BilinearSurface::new(&X, &Y, &VALUES);
110/// ```
111///
112/// The same declaration with the correct `[[i32; NX]; NY]` shape compiles:
113///
114/// ```
115/// use ph_surfaces::BilinearSurface;
116///
117/// static X: [u16; 2] = [0, 100];
118/// static Y: [u16; 3] = [0, 100, 200];
119/// static VALUES: [[i32; 2]; 3] = [[0, 1], [2, 3], [4, 5]];
120///
121/// static SURFACE: BilinearSurface<2, 3> = BilinearSurface::new(&X, &Y, &VALUES);
122/// assert_eq!(SURFACE.values()[2][1], 5);
123/// ```
124///
125/// An axis whose knot count disagrees with the value grid does not compile
126/// either, because the axis type carries that count:
127///
128/// ```compile_fail
129/// use ph_surfaces::{BilinearSurface, BinaryAxis};
130///
131/// static X: [u16; 3] = [0, 50, 100];
132/// static Y: [u16; 2] = [0, 100];
133/// static VALUES: [[i32; 2]; 2] = [[0, 1], [2, 3]];
134///
135/// static SURFACE: BilinearSurface<2, 2, BinaryAxis<3>, BinaryAxis<2>> =
136///     BilinearSurface::from_axes(BinaryAxis::new(&X), BinaryAxis::new(&Y), &VALUES);
137/// ```
138#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
139pub struct BilinearSurface<
140    const NX: usize,
141    const NY: usize,
142    X: AxisLookup<NX> = BinaryAxis<NX>,
143    Y: AxisLookup<NY> = BinaryAxis<NY>,
144> {
145    x: X,
146    y: Y,
147    values: &'static [[i32; NX]; NY],
148    policy: BoundaryPolicy,
149}
150
151impl<const NX: usize, const NY: usize> BilinearSurface<NX, NY, BinaryAxis<NX>, BinaryAxis<NY>> {
152    /// Declares a surface over static axes and a static row-major value grid,
153    /// with both axes located by binary search.
154    ///
155    /// This is the general-purpose constructor and the one to reach for unless
156    /// an axis has a reason to choose otherwise; see
157    /// [`BilinearSurface::from_axes`] for the surfaces that do.
158    ///
159    /// Every domain side defaults to [`Boundary::Error`](crate::Boundary::Error);
160    /// use [`BilinearSurface::with_policy`] to select clamping on any side.
161    ///
162    /// # Panics
163    ///
164    /// Panics unless both axes declare at least two knots and both axes are
165    /// strictly increasing. In a constant or static definition that panic is a
166    /// compile error, so an invalid surface cannot be defined.
167    ///
168    /// A single X knot is rejected:
169    ///
170    /// ```compile_fail
171    /// use ph_surfaces::BilinearSurface;
172    ///
173    /// static X: [u16; 1] = [0];
174    /// static Y: [u16; 2] = [0, 10];
175    /// static VALUES: [[i32; 1]; 2] = [[0], [1]];
176    ///
177    /// static SURFACE: BilinearSurface<1, 2> = BilinearSurface::new(&X, &Y, &VALUES);
178    /// ```
179    ///
180    /// An empty Y axis is rejected:
181    ///
182    /// ```compile_fail
183    /// use ph_surfaces::BilinearSurface;
184    ///
185    /// static X: [u16; 2] = [0, 10];
186    /// static Y: [u16; 0] = [];
187    /// static VALUES: [[i32; 2]; 0] = [];
188    ///
189    /// static SURFACE: BilinearSurface<2, 0> = BilinearSurface::new(&X, &Y, &VALUES);
190    /// ```
191    ///
192    /// A duplicated X knot is rejected:
193    ///
194    /// ```compile_fail
195    /// use ph_surfaces::BilinearSurface;
196    ///
197    /// static X: [u16; 2] = [5, 5];
198    /// static Y: [u16; 2] = [0, 10];
199    /// static VALUES: [[i32; 2]; 2] = [[0, 1], [2, 3]];
200    ///
201    /// static SURFACE: BilinearSurface<2, 2> = BilinearSurface::new(&X, &Y, &VALUES);
202    /// ```
203    ///
204    /// A descending X axis is rejected:
205    ///
206    /// ```compile_fail
207    /// use ph_surfaces::BilinearSurface;
208    ///
209    /// static X: [u16; 2] = [10, 0];
210    /// static Y: [u16; 2] = [0, 10];
211    /// static VALUES: [[i32; 2]; 2] = [[0, 1], [2, 3]];
212    ///
213    /// static SURFACE: BilinearSurface<2, 2> = BilinearSurface::new(&X, &Y, &VALUES);
214    /// ```
215    ///
216    /// A duplicated Y knot is rejected independently of the X axis:
217    ///
218    /// ```compile_fail
219    /// use ph_surfaces::BilinearSurface;
220    ///
221    /// static X: [u16; 2] = [0, 10];
222    /// static Y: [u16; 2] = [5, 5];
223    /// static VALUES: [[i32; 2]; 2] = [[0, 1], [2, 3]];
224    ///
225    /// static SURFACE: BilinearSurface<2, 2> = BilinearSurface::new(&X, &Y, &VALUES);
226    /// ```
227    ///
228    /// A descending Y axis is rejected independently of the X axis:
229    ///
230    /// ```compile_fail
231    /// use ph_surfaces::BilinearSurface;
232    ///
233    /// static X: [u16; 2] = [0, 10];
234    /// static Y: [u16; 2] = [10, 0];
235    /// static VALUES: [[i32; 2]; 2] = [[0, 1], [2, 3]];
236    ///
237    /// static SURFACE: BilinearSurface<2, 2> = BilinearSurface::new(&X, &Y, &VALUES);
238    /// ```
239    #[must_use]
240    pub const fn new(
241        x_axis: &'static [u16; NX],
242        y_axis: &'static [u16; NY],
243        values: &'static [[i32; NX]; NY],
244    ) -> Self {
245        // The axis constructors below enforce the same two rules, but they
246        // cannot say which axis failed: a `const fn` panic message is a literal,
247        // and the strategies are axis-neutral by design. These assertions exist
248        // so that the default construction path keeps naming the offending axis.
249        assert!(NX >= 2, "x axis must declare at least two knots");
250        assert!(NY >= 2, "y axis must declare at least two knots");
251
252        let mut i = 1;
253        while i < NX {
254            assert!(
255                x_axis[i - 1] < x_axis[i],
256                "x axis knots must be strictly increasing"
257            );
258            i += 1;
259        }
260
261        let mut i = 1;
262        while i < NY {
263            assert!(
264                y_axis[i - 1] < y_axis[i],
265                "y axis knots must be strictly increasing"
266            );
267            i += 1;
268        }
269
270        Self {
271            x: BinaryAxis::new(x_axis),
272            y: BinaryAxis::new(y_axis),
273            values,
274            policy: BoundaryPolicy::new(),
275        }
276    }
277}
278
279impl<const NX: usize, const NY: usize, X: AxisLookup<NX>, Y: AxisLookup<NY>>
280    BilinearSurface<NX, NY, X, Y>
281{
282    /// Bytes of the referenced value grid: `NX*NY` elements of `i32`.
283    ///
284    /// Exact and target-independent. It excludes the axis tables, the handle,
285    /// alignment, code, and stack, and it is not a total memory figure.
286    pub const VALUE_BYTES: usize = 4 * NX * NY;
287
288    /// Bytes of referenced table elements this surface names:
289    /// `X::KNOT_BYTES + X::INDEX_BYTES + Y::KNOT_BYTES + Y::INDEX_BYTES + VALUE_BYTES`.
290    ///
291    /// Exact and target-independent. For the default binary pairing it equals
292    /// `2*NX + 2*NY + 4*NX*NY`. It is only the referenced element payload: not
293    /// total RAM, flash, binary, or linker cost.
294    pub const PAYLOAD_BYTES: usize =
295        X::KNOT_BYTES + X::INDEX_BYTES + Y::KNOT_BYTES + Y::INDEX_BYTES + Self::VALUE_BYTES;
296
297    /// Size of this handle on the current target, including alignment padding.
298    ///
299    /// Target-dependent: it follows pointer width and the selected strategies'
300    /// fields (Uniform stores no axis reference, Linear/Binary one, Bucketed
301    /// two), plus the value-grid reference and the four-byte policy. It does
302    /// not grow with `NX` or `NY` for a fixed pairing, and it is not a flash
303    /// or binary cost.
304    pub const HANDLE_BYTES: usize = core::mem::size_of::<Self>();
305
306    /// Scalar interpolations a successful [`evaluate`](Self::evaluate) performs.
307    ///
308    /// Always three: X on the lower-Y row, X on the upper-Y row, then Y
309    /// between those two already-rounded results. A rejected evaluation
310    /// returns before any of them. This is operation structure, not a cycle
311    /// count.
312    pub const SUCCESS_INTERPOLATIONS: u32 = 3;
313
314    /// Value-grid reads a successful [`evaluate`](Self::evaluate) performs.
315    ///
316    /// Always four: the corners of the located cell. A rejected evaluation
317    /// returns before any of them. The grid is never scanned. This is
318    /// operation structure, not a cycle count.
319    pub const SUCCESS_GRID_READS: u32 = 4;
320
321    /// Declares a surface over two axes that have already chosen their lookup
322    /// strategies, and a static row-major value grid.
323    ///
324    /// Each axis validated itself when it was declared, so there is nothing left
325    /// to reject here: the knot counts are carried in the axis types, so an axis
326    /// that does not match the grid is a type error rather than a runtime one.
327    ///
328    /// Every domain side defaults to [`Boundary::Error`](crate::Boundary::Error);
329    /// use [`BilinearSurface::with_policy`] to select clamping on any side.
330    ///
331    /// # Examples
332    ///
333    /// A surface whose X axis is evenly spaced — so its knots are described
334    /// rather than stored — and whose Y axis keeps the default strategy:
335    ///
336    /// ```
337    /// use ph_surfaces::{BilinearSurface, BinaryAxis, UniformAxis};
338    ///
339    /// static Y: [u16; 2] = [0, 10];
340    /// static VALUES: [[i32; 5]; 2] = [[0, 25, 50, 75, 100], [10, 35, 60, 85, 110]];
341    ///
342    /// static SURFACE: BilinearSurface<5, 2, UniformAxis<5, 0, 25>, BinaryAxis<2>> =
343    ///     BilinearSurface::from_axes(UniformAxis::new(), BinaryAxis::new(&Y), &VALUES);
344    ///
345    /// assert_eq!(SURFACE.evaluate(50, 0), Ok(50));
346    /// assert_eq!(SURFACE.x_knot(1), 25);
347    /// ```
348    #[must_use]
349    pub const fn from_axes(x: X, y: Y, values: &'static [[i32; NX]; NY]) -> Self {
350        Self {
351            x,
352            y,
353            values,
354            policy: BoundaryPolicy::new(),
355        }
356    }
357
358    /// Returns this surface with its boundary policy replaced.
359    ///
360    /// The referenced tables are unchanged; only the four domain-side
361    /// selections differ.
362    #[must_use]
363    pub const fn with_policy(self, policy: BoundaryPolicy) -> Self {
364        Self {
365            x: self.x,
366            y: self.y,
367            values: self.values,
368            policy,
369        }
370    }
371
372    /// Returns the X axis together with its lookup strategy.
373    ///
374    /// This is the generic route to the axis: through it, code bounded on
375    /// [`AxisLookup`] (or [`KnotArray`](crate::KnotArray) for the stored
376    /// strategies) can read the domain bounds, individual knots, and cost
377    /// constants of any surface's axis without carrying the knot arrays
378    /// separately. The strategy-specific `x_knot` / `x_min` / `x_max`
379    /// accessors remain the constant-context conveniences.
380    #[must_use]
381    pub const fn x(&self) -> &X {
382        &self.x
383    }
384
385    /// Returns the Y axis together with its lookup strategy.
386    ///
387    /// The Y counterpart of [`x`](BilinearSurface::x); see there.
388    #[must_use]
389    pub const fn y(&self) -> &Y {
390        &self.y
391    }
392
393    /// Returns the declared row-major value grid, addressed as `values[y][x]`.
394    #[must_use]
395    pub const fn values(&self) -> &'static [[i32; NX]; NY] {
396        self.values
397    }
398
399    /// Returns the number of X knots.
400    #[must_use]
401    pub const fn nx(&self) -> usize {
402        NX
403    }
404
405    /// Returns the number of Y knots.
406    #[must_use]
407    pub const fn ny(&self) -> usize {
408        NY
409    }
410
411    /// Returns the four domain-side selections.
412    #[must_use]
413    pub const fn policy(&self) -> BoundaryPolicy {
414        self.policy
415    }
416}
417
418macro_rules! impl_stored_x_accessors {
419    ($axis:ident) => {
420        impl<const NX: usize, const NY: usize, Y: AxisLookup<NY>>
421            BilinearSurface<NX, NY, $axis<NX>, Y>
422        {
423            /// Returns the X knot at `index`.
424            ///
425            /// # Panics
426            ///
427            /// Panics if `index >= NX`.
428            #[must_use]
429            pub const fn x_knot(&self, index: usize) -> u16 {
430                assert!(index < NX, "knot index is outside the axis");
431                self.x.knots()[index]
432            }
433
434            /// Returns the first X knot: the inclusive lower bound of the X domain.
435            #[must_use]
436            pub const fn x_min(&self) -> u16 {
437                self.x.knots()[0]
438            }
439
440            /// Returns the last X knot: the inclusive upper bound of the X domain.
441            #[must_use]
442            pub const fn x_max(&self) -> u16 {
443                self.x.knots()[NX - 1]
444            }
445
446            /// Returns the declared X axis.
447            #[must_use]
448            pub const fn x_axis(&self) -> &'static [u16; NX] {
449                self.x.knots()
450            }
451        }
452    };
453}
454
455macro_rules! impl_stored_y_accessors {
456    ($axis:ident) => {
457        impl<const NX: usize, const NY: usize, X: AxisLookup<NX>>
458            BilinearSurface<NX, NY, X, $axis<NY>>
459        {
460            /// Returns the Y knot at `index`.
461            ///
462            /// # Panics
463            ///
464            /// Panics if `index >= NY`.
465            #[must_use]
466            pub const fn y_knot(&self, index: usize) -> u16 {
467                assert!(index < NY, "knot index is outside the axis");
468                self.y.knots()[index]
469            }
470
471            /// Returns the first Y knot: the inclusive lower bound of the Y domain.
472            #[must_use]
473            pub const fn y_min(&self) -> u16 {
474                self.y.knots()[0]
475            }
476
477            /// Returns the last Y knot: the inclusive upper bound of the Y domain.
478            #[must_use]
479            pub const fn y_max(&self) -> u16 {
480                self.y.knots()[NY - 1]
481            }
482
483            /// Returns the declared Y axis.
484            #[must_use]
485            pub const fn y_axis(&self) -> &'static [u16; NY] {
486                self.y.knots()
487            }
488        }
489    };
490}
491
492impl_stored_x_accessors!(BinaryAxis);
493impl_stored_x_accessors!(LinearAxis);
494impl_stored_y_accessors!(BinaryAxis);
495impl_stored_y_accessors!(LinearAxis);
496
497impl<const NX: usize, const NY: usize, const B: usize, Y: AxisLookup<NY>>
498    BilinearSurface<NX, NY, BucketedAxis<NX, B>, Y>
499{
500    /// Returns the X knot at `index`.
501    ///
502    /// # Panics
503    ///
504    /// Panics if `index >= NX`.
505    #[must_use]
506    pub const fn x_knot(&self, index: usize) -> u16 {
507        assert!(index < NX, "knot index is outside the axis");
508        self.x.knots()[index]
509    }
510
511    /// Returns the first X knot: the inclusive lower bound of the X domain.
512    #[must_use]
513    pub const fn x_min(&self) -> u16 {
514        self.x.knots()[0]
515    }
516
517    /// Returns the last X knot: the inclusive upper bound of the X domain.
518    #[must_use]
519    pub const fn x_max(&self) -> u16 {
520        self.x.knots()[NX - 1]
521    }
522
523    /// Returns the declared X axis.
524    #[must_use]
525    pub const fn x_axis(&self) -> &'static [u16; NX] {
526        self.x.knots()
527    }
528}
529
530impl<const NX: usize, const NY: usize, const B: usize, X: AxisLookup<NX>>
531    BilinearSurface<NX, NY, X, BucketedAxis<NY, B>>
532{
533    /// Returns the Y knot at `index`.
534    ///
535    /// # Panics
536    ///
537    /// Panics if `index >= NY`.
538    #[must_use]
539    pub const fn y_knot(&self, index: usize) -> u16 {
540        assert!(index < NY, "knot index is outside the axis");
541        self.y.knots()[index]
542    }
543
544    /// Returns the first Y knot: the inclusive lower bound of the Y domain.
545    #[must_use]
546    pub const fn y_min(&self) -> u16 {
547        self.y.knots()[0]
548    }
549
550    /// Returns the last Y knot: the inclusive upper bound of the Y domain.
551    #[must_use]
552    pub const fn y_max(&self) -> u16 {
553        self.y.knots()[NY - 1]
554    }
555
556    /// Returns the declared Y axis.
557    #[must_use]
558    pub const fn y_axis(&self) -> &'static [u16; NY] {
559        self.y.knots()
560    }
561}
562
563impl<const NX: usize, const NY: usize, const ORIGIN: u16, const STEP: u16, Y: AxisLookup<NY>>
564    BilinearSurface<NX, NY, UniformAxis<NX, ORIGIN, STEP>, Y>
565{
566    /// Returns the X knot at `index`, calculated from the uniform descriptor.
567    ///
568    /// # Panics
569    ///
570    /// Panics if `index >= NX`.
571    #[must_use]
572    pub const fn x_knot(&self, index: usize) -> u16 {
573        self.x.knot(index)
574    }
575
576    /// Returns the first X knot: the inclusive lower bound of the X domain.
577    #[must_use]
578    pub const fn x_min(&self) -> u16 {
579        self.x.origin()
580    }
581
582    /// Returns the last X knot: the inclusive upper bound of the X domain.
583    #[must_use]
584    pub const fn x_max(&self) -> u16 {
585        self.x.last()
586    }
587}
588
589impl<const NX: usize, const NY: usize, X: AxisLookup<NX>, const ORIGIN: u16, const STEP: u16>
590    BilinearSurface<NX, NY, X, UniformAxis<NY, ORIGIN, STEP>>
591{
592    /// Returns the Y knot at `index`, calculated from the uniform descriptor.
593    ///
594    /// # Panics
595    ///
596    /// Panics if `index >= NY`.
597    #[must_use]
598    pub const fn y_knot(&self, index: usize) -> u16 {
599        self.y.knot(index)
600    }
601
602    /// Returns the first Y knot: the inclusive lower bound of the Y domain.
603    #[must_use]
604    pub const fn y_min(&self) -> u16 {
605        self.y.origin()
606    }
607
608    /// Returns the last Y knot: the inclusive upper bound of the Y domain.
609    #[must_use]
610    pub const fn y_max(&self) -> u16 {
611        self.y.last()
612    }
613}
614
615#[cfg(test)]
616mod tests {
617    use super::BilinearSurface;
618    use crate::axis::{AxisLookup, BinaryAxis, BucketedAxis, LinearAxis, UniformAxis};
619    use crate::boundary::{Boundary, BoundaryPolicy};
620    use crate::{bucket_index, max_local_comparisons};
621    use core::mem::{align_of, size_of, size_of_val};
622
623    static X2: [u16; 2] = [0, 10];
624    static Y2: [u16; 2] = [0, 20];
625    static V22: [[i32; 2]; 2] = [[1, 2], [3, 4]];
626
627    // Declaring this as a `static` is itself the evidence that a valid 2x2
628    // surface is constant-evaluable without allocation: it either const-
629    // evaluates or the crate does not build.
630    static SURFACE: BilinearSurface<2, 2> = BilinearSurface::new(&X2, &Y2, &V22);
631
632    // These declarations guard the pre-strategy public API: default-surface
633    // axis and endpoint accessors remain usable in constant expressions.
634    const CONST_SURFACE: BilinearSurface<2, 2> = BilinearSurface::new(&X2, &Y2, &V22);
635    const CONST_X_AXIS: &[u16; 2] = CONST_SURFACE.x_axis();
636    const CONST_Y_AXIS: &[u16; 2] = CONST_SURFACE.y_axis();
637    const CONST_X_MIN: u16 = CONST_SURFACE.x_min();
638    const CONST_X_MAX: u16 = CONST_SURFACE.x_max();
639    const CONST_Y_MIN: u16 = CONST_SURFACE.y_min();
640    const CONST_Y_MAX: u16 = CONST_SURFACE.y_max();
641
642    // A deliberately asymmetric 3x2 fixture: NX = 3, NY = 2.
643    static X3: [u16; 3] = [0, 5, 100];
644    static Y2B: [u16; 2] = [7, 900];
645    static V23: [[i32; 3]; 2] = [[10, 20, 30], [40, 50, 60]];
646    static WIDE: BilinearSurface<3, 2> = BilinearSurface::new(&X3, &Y2B, &V23);
647
648    // The same 2x2 tables, declared through the general constructor with named
649    // strategies: one stored-knot axis and one described axis.
650    static MIXED: BilinearSurface<2, 2, LinearAxis<2>, UniformAxis<2, 0, 20>> =
651        BilinearSurface::from_axes(LinearAxis::new(&X2), UniformAxis::new(), &V22);
652
653    const fn side_from_bit(bits: usize, shift: u32) -> Boundary {
654        if (bits >> shift) & 1 == 0 {
655            Boundary::Error
656        } else {
657            Boundary::Clamp
658        }
659    }
660
661    const fn policy_from_bits(bits: usize) -> BoundaryPolicy {
662        BoundaryPolicy::new()
663            .with_x_below(side_from_bit(bits, 0))
664            .with_x_above(side_from_bit(bits, 1))
665            .with_y_below(side_from_bit(bits, 2))
666            .with_y_above(side_from_bit(bits, 3))
667    }
668
669    const POLICIES: [BoundaryPolicy; 16] = {
670        let mut out = [BoundaryPolicy::new(); 16];
671        let mut bits = 0;
672        while bits < 16 {
673            out[bits] = policy_from_bits(bits);
674            bits += 1;
675        }
676        out
677    };
678
679    // All sixteen selections attached to the one shared table, in const context.
680    static SURFACES: [BilinearSurface<2, 2>; 16] = {
681        let mut out = [BilinearSurface::new(&X2, &Y2, &V22); 16];
682        let mut bits = 0;
683        while bits < 16 {
684            out[bits] = BilinearSurface::new(&X2, &Y2, &V22).with_policy(POLICIES[bits]);
685            bits += 1;
686        }
687        out
688    };
689
690    #[test]
691    fn two_by_two_surface_declares_as_a_static() {
692        assert_eq!(SURFACE.nx(), 2);
693        assert_eq!(SURFACE.ny(), 2);
694        assert_eq!(SURFACE.x_axis(), &[0, 10]);
695        assert_eq!(SURFACE.y_axis(), &[0, 20]);
696        assert_eq!(SURFACE.values(), &[[1, 2], [3, 4]]);
697    }
698
699    #[test]
700    fn values_are_addressed_row_major_by_y_then_x() {
701        // Y selects the row, X selects the column.
702        assert_eq!(WIDE.nx(), 3);
703        assert_eq!(WIDE.ny(), 2);
704        assert_eq!(WIDE.values()[0][2], 30);
705        assert_eq!(WIDE.values()[1][0], 40);
706    }
707
708    #[test]
709    fn new_defaults_every_side_to_error() {
710        let policy = SURFACE.policy();
711
712        assert_eq!(policy, BoundaryPolicy::new());
713        assert_eq!(policy.x_below(), Boundary::Error);
714        assert_eq!(policy.x_above(), Boundary::Error);
715        assert_eq!(policy.y_below(), Boundary::Error);
716        assert_eq!(policy.y_above(), Boundary::Error);
717    }
718
719    #[test]
720    fn from_axes_defaults_every_side_to_error_as_well() {
721        assert_eq!(MIXED.policy(), BoundaryPolicy::new());
722        assert_eq!(MIXED.policy().x_below(), Boundary::Error);
723        assert_eq!(MIXED.policy().y_above(), Boundary::Error);
724    }
725
726    #[test]
727    fn endpoint_accessors_report_declared_extremes() {
728        assert_eq!(WIDE.x_min(), 0);
729        assert_eq!(WIDE.x_max(), 100);
730        assert_eq!(WIDE.y_min(), 7);
731        assert_eq!(WIDE.y_max(), 900);
732    }
733
734    #[test]
735    fn default_axis_accessors_remain_const_compatible() {
736        assert_eq!(CONST_X_AXIS, &X2);
737        assert_eq!(CONST_Y_AXIS, &Y2);
738        assert_eq!((CONST_X_MIN, CONST_X_MAX), (0, 10));
739        assert_eq!((CONST_Y_MIN, CONST_Y_MAX), (0, 20));
740    }
741
742    #[test]
743    fn endpoint_and_knot_accessors_do_not_depend_on_a_stored_knot_array() {
744        // The Y axis of this surface stores nothing, and still answers.
745        assert_eq!(MIXED.y_min(), 0);
746        assert_eq!(MIXED.y_max(), 20);
747        assert_eq!(MIXED.y_knot(0), 0);
748        assert_eq!(MIXED.y_knot(1), 20);
749
750        // The X axis stores its knots, so both spellings agree.
751        assert_eq!(MIXED.x_knot(0), MIXED.x_axis()[0]);
752        assert_eq!(MIXED.x_knot(1), MIXED.x_axis()[1]);
753        assert_eq!(SURFACE.x_knot(1), 10);
754        assert_eq!(SURFACE.y_knot(1), 20);
755    }
756
757    #[test]
758    fn accessors_return_the_declared_tables_without_copying() {
759        assert!(core::ptr::eq(SURFACE.x_axis(), &X2));
760        assert!(core::ptr::eq(SURFACE.y_axis(), &Y2));
761        assert!(core::ptr::eq(SURFACE.values(), &V22));
762    }
763
764    #[test]
765    fn handle_size_is_independent_of_table_size() {
766        // The handle stores references, not tables, so growing the grid by
767        // three orders of magnitude does not grow the handle.
768        assert_eq!(
769            size_of::<BilinearSurface<2, 2>>(),
770            size_of::<BilinearSurface<64, 64>>()
771        );
772    }
773
774    /// The documented referenced element payload: `2*NX + 2*NY + 4*NX*NY`.
775    const fn documented_payload(nx: usize, ny: usize) -> usize {
776        2 * nx + 2 * ny + 4 * nx * ny
777    }
778
779    /// The size of the three tables a `BilinearSurface<NX, NY>` references.
780    const fn referenced_payload<const NX: usize, const NY: usize>() -> usize {
781        size_of::<[u16; NX]>() + size_of::<[u16; NY]>() + size_of::<[[i32; NX]; NY]>()
782    }
783
784    #[test]
785    fn cost_constants_match_the_declared_tables() {
786        assert_eq!(
787            BilinearSurface::<3, 2>::VALUE_BYTES,
788            size_of::<[[i32; 3]; 2]>()
789        );
790        assert_eq!(
791            BilinearSurface::<3, 2>::PAYLOAD_BYTES,
792            size_of::<[u16; 3]>() + size_of::<[u16; 2]>() + size_of::<[[i32; 3]; 2]>()
793        );
794        assert_eq!(<LinearAxis<3>>::KNOT_BYTES, size_of::<[u16; 3]>());
795        assert_eq!(<LinearAxis<3>>::INDEX_BYTES, 0);
796        assert_eq!(<BucketedAxis<5, 8>>::KNOT_BYTES, size_of::<[u16; 5]>());
797        assert_eq!(<BucketedAxis<5, 8>>::INDEX_BYTES, size_of::<[u16; 8]>());
798        assert_eq!(<UniformAxis<3, 0, 50>>::KNOT_BYTES, 0);
799        assert_eq!(<UniformAxis<3, 0, 50>>::INDEX_BYTES, 0);
800    }
801
802    #[test]
803    fn uniform_uniform_payload_is_only_the_grid() {
804        type UniformUniform = BilinearSurface<2, 2, UniformAxis<2, 0, 10>, UniformAxis<2, 0, 20>>;
805
806        assert_eq!(UniformUniform::PAYLOAD_BYTES, UniformUniform::VALUE_BYTES);
807        assert_eq!(UniformUniform::PAYLOAD_BYTES, size_of::<[[i32; 2]; 2]>());
808        assert_eq!(UniformUniform::VALUE_BYTES, 16);
809    }
810
811    #[test]
812    fn handle_bytes_matches_size_of_self() {
813        assert_eq!(
814            BilinearSurface::<2, 2>::HANDLE_BYTES,
815            size_of::<BilinearSurface<2, 2>>()
816        );
817        assert_eq!(
818            BilinearSurface::<64, 64>::HANDLE_BYTES,
819            size_of::<BilinearSurface<64, 64>>()
820        );
821        type Mixed = BilinearSurface<2, 2, LinearAxis<2>, UniformAxis<2, 0, 20>>;
822        assert_eq!(Mixed::HANDLE_BYTES, size_of::<Mixed>());
823    }
824
825    #[test]
826    fn success_work_constants_are_three_interpolations_and_four_reads() {
827        assert_eq!(BilinearSurface::<2, 2>::SUCCESS_INTERPOLATIONS, 3);
828        assert_eq!(BilinearSurface::<2, 2>::SUCCESS_GRID_READS, 4);
829        type Mixed = BilinearSurface<5, 3, BucketedAxis<5, 8>, UniformAxis<3, 0, 50>>;
830        assert_eq!(Mixed::SUCCESS_INTERPOLATIONS, 3);
831        assert_eq!(Mixed::SUCCESS_GRID_READS, 4);
832    }
833
834    #[test]
835    fn default_binary_payload_matches_the_documented_formula() {
836        assert_eq!(
837            BilinearSurface::<5, 4>::PAYLOAD_BYTES,
838            2 * 5 + 2 * 4 + 4 * 5 * 4
839        );
840        assert_eq!(BilinearSurface::<5, 4>::PAYLOAD_BYTES, 98);
841        assert_eq!(BilinearSurface::<5, 4>::VALUE_BYTES, 80);
842        assert_eq!(
843            BilinearSurface::<2, 2>::PAYLOAD_BYTES,
844            documented_payload(2, 2)
845        );
846    }
847
848    #[test]
849    fn mixed_bucketed_uniform_payload_drops_the_uniform_knots() {
850        static X: [u16; 17] = [
851            0, 100, 210, 300, 405, 500, 610, 700, 805, 900, 1_010, 1_100, 1_205, 1_300, 1_410,
852            1_500, 1_600,
853        ];
854        static X_INDEX: [u16; 8] = bucket_index(&X);
855        type Mixed = BilinearSurface<17, 9, BucketedAxis<17, 8>, UniformAxis<9, 0, 200>>;
856        type AllBinary = BilinearSurface<17, 9>;
857
858        assert_eq!(Mixed::VALUE_BYTES, 612);
859        assert_eq!(Mixed::PAYLOAD_BYTES, 34 + 16 + 612);
860        assert_eq!(Mixed::PAYLOAD_BYTES, 662);
861        assert_eq!(AllBinary::PAYLOAD_BYTES, 664);
862        assert_eq!(max_local_comparisons(&X, &X_INDEX), 3);
863        assert_eq!(<BinaryAxis<17>>::MAX_SEARCH_COMPARISONS, 5);
864    }
865
866    #[test]
867    fn referenced_payload_matches_the_documented_formula() {
868        // `u16` and `i32` have guaranteed sizes and arrays have no padding, so
869        // this holds on every target; the fixtures below merely spell out
870        // representative shapes, including the asymmetric and large ones.
871        assert_eq!(referenced_payload::<2, 2>(), documented_payload(2, 2));
872        assert_eq!(referenced_payload::<2, 2>(), 24);
873        assert_eq!(referenced_payload::<3, 2>(), documented_payload(3, 2));
874        assert_eq!(referenced_payload::<3, 2>(), 34);
875        assert_eq!(referenced_payload::<2, 3>(), documented_payload(2, 3));
876        assert_eq!(referenced_payload::<16, 8>(), documented_payload(16, 8));
877        assert_eq!(referenced_payload::<64, 64>(), documented_payload(64, 64));
878        assert_eq!(referenced_payload::<64, 64>(), 16_640);
879
880        // The same figure measured on the live tables behind a handle.
881        let measured = size_of_val(SURFACE.x_axis())
882            + size_of_val(SURFACE.y_axis())
883            + size_of_val(SURFACE.values());
884        assert_eq!(measured, documented_payload(SURFACE.nx(), SURFACE.ny()));
885
886        let measured =
887            size_of_val(WIDE.x_axis()) + size_of_val(WIDE.y_axis()) + size_of_val(WIDE.values());
888        assert_eq!(measured, documented_payload(WIDE.nx(), WIDE.ny()));
889    }
890
891    #[test]
892    fn default_handle_is_three_references_plus_four_policy_bytes_and_padding() {
893        // The handle holds three references to sized arrays (thin, so each is
894        // one target-width pointer) and one four-byte policy. Its size is
895        // therefore that sum rounded up to the handle's alignment: at least the
896        // sum, less than the sum plus one alignment unit, and a multiple of
897        // the alignment. Nothing here assumes a particular pointer width or a
898        // particular field order.
899        let reference = size_of::<&'static [u16; 2]>();
900        assert_eq!(
901            reference,
902            size_of::<usize>(),
903            "a reference to a sized array is thin"
904        );
905        assert_eq!(size_of::<&'static [[i32; 2]; 2]>(), reference);
906
907        let fields = 3 * reference + size_of::<BoundaryPolicy>();
908        let handle = size_of::<BilinearSurface<2, 2>>();
909        let align = align_of::<BilinearSurface<2, 2>>();
910
911        assert_eq!(size_of::<BoundaryPolicy>(), 4);
912        assert!(
913            handle >= fields,
914            "handle {handle} smaller than its fields {fields}"
915        );
916        assert!(
917            handle < fields + align,
918            "handle {handle} carries more than alignment padding over {fields}"
919        );
920        assert_eq!(handle % align, 0);
921    }
922
923    fn assert_handle_layout<T>(references: usize) {
924        let fields = references * size_of::<usize>() + size_of::<BoundaryPolicy>();
925        let handle = size_of::<T>();
926        let align = align_of::<T>();
927
928        assert!(
929            handle >= fields,
930            "handle {handle} smaller than its fields {fields}"
931        );
932        assert!(
933            handle < fields + align,
934            "handle {handle} carries more than alignment padding over {fields}"
935        );
936        assert_eq!(handle % align, 0);
937    }
938
939    #[test]
940    fn handle_reference_count_follows_the_selected_strategies() {
941        type UniformUniform = BilinearSurface<2, 2, UniformAxis<2, 0, 10>, UniformAxis<2, 0, 20>>;
942        type LinearUniform = BilinearSurface<2, 2, LinearAxis<2>, UniformAxis<2, 0, 20>>;
943        type BinaryBinary = BilinearSurface<2, 2, BinaryAxis<2>, BinaryAxis<2>>;
944        type BucketedBucketed = BilinearSurface<2, 2, BucketedAxis<2, 1>, BucketedAxis<2, 1>>;
945
946        // Every handle has one value-grid reference. Uniform contributes no
947        // axis reference, linear/binary one, and bucketed two.
948        assert_handle_layout::<UniformUniform>(1);
949        assert_handle_layout::<LinearUniform>(2);
950        assert_handle_layout::<BinaryBinary>(3);
951        assert_handle_layout::<BucketedBucketed>(5);
952    }
953
954    #[test]
955    fn selecting_strategies_adds_no_discriminant_to_the_handle() {
956        // The default surface is exactly the binary pairing spelled out, and a
957        // surface whose axes store less is smaller rather than larger: nothing
958        // was added to remember which strategy is in use.
959        assert_eq!(
960            size_of::<BilinearSurface<2, 2>>(),
961            size_of::<BilinearSurface<2, 2, BinaryAxis<2>, BinaryAxis<2>>>()
962        );
963        assert!(
964            size_of::<BilinearSurface<2, 2, LinearAxis<2>, UniformAxis<2, 0, 20>>>()
965                < size_of::<BilinearSurface<2, 2>>()
966        );
967    }
968
969    #[test]
970    fn with_policy_keeps_the_declared_tables() {
971        let clamped = SURFACE.with_policy(BoundaryPolicy::new().with_x_below(Boundary::Clamp));
972
973        assert!(core::ptr::eq(clamped.x_axis(), &X2));
974        assert!(core::ptr::eq(clamped.y_axis(), &Y2));
975        assert!(core::ptr::eq(clamped.values(), &V22));
976        assert_eq!(clamped.policy().x_below(), Boundary::Clamp);
977    }
978
979    #[test]
980    fn with_policy_keeps_the_declared_strategies() {
981        let clamped = MIXED.with_policy(policy_from_bits(0b1111));
982
983        assert!(core::ptr::eq(clamped.x_axis(), &X2));
984        assert_eq!(clamped.y_max(), 20);
985        assert_eq!(clamped.policy(), policy_from_bits(0b1111));
986    }
987
988    #[test]
989    fn all_sixteen_boundary_combinations_are_representable() {
990        for (bits, surface) in SURFACES.iter().enumerate() {
991            let policy = surface.policy();
992
993            assert_eq!(policy.x_below(), side_from_bit(bits, 0));
994            assert_eq!(policy.x_above(), side_from_bit(bits, 1));
995            assert_eq!(policy.y_below(), side_from_bit(bits, 2));
996            assert_eq!(policy.y_above(), side_from_bit(bits, 3));
997
998            // Selecting a policy never changes the table data.
999            assert!(core::ptr::eq(surface.values(), &V22));
1000            assert!(core::ptr::eq(surface.x_axis(), &X2));
1001            assert!(core::ptr::eq(surface.y_axis(), &Y2));
1002        }
1003    }
1004
1005    #[test]
1006    fn the_sixteen_combinations_are_pairwise_distinct() {
1007        for (i, left) in POLICIES.iter().enumerate() {
1008            for (j, right) in POLICIES.iter().enumerate() {
1009                if i == j {
1010                    assert_eq!(left, right);
1011                } else {
1012                    assert_ne!(left, right);
1013                }
1014            }
1015        }
1016    }
1017
1018    #[test]
1019    #[should_panic(expected = "x axis must declare at least two knots")]
1020    fn zero_knot_x_axis_is_rejected() {
1021        static X0: [u16; 0] = [];
1022        static V20: [[i32; 0]; 2] = [[], []];
1023
1024        let _ = BilinearSurface::new(&X0, &Y2, &V20);
1025    }
1026
1027    #[test]
1028    #[should_panic(expected = "x axis must declare at least two knots")]
1029    fn one_knot_x_axis_is_rejected() {
1030        static X1: [u16; 1] = [3];
1031        static V21: [[i32; 1]; 2] = [[0], [1]];
1032
1033        let _ = BilinearSurface::new(&X1, &Y2, &V21);
1034    }
1035
1036    #[test]
1037    #[should_panic(expected = "y axis must declare at least two knots")]
1038    fn zero_knot_y_axis_is_rejected() {
1039        static Y0: [u16; 0] = [];
1040        static V02: [[i32; 2]; 0] = [];
1041
1042        let _ = BilinearSurface::new(&X2, &Y0, &V02);
1043    }
1044
1045    #[test]
1046    #[should_panic(expected = "y axis must declare at least two knots")]
1047    fn one_knot_y_axis_is_rejected() {
1048        static Y1: [u16; 1] = [3];
1049        static V12: [[i32; 2]; 1] = [[0, 1]];
1050
1051        let _ = BilinearSurface::new(&X2, &Y1, &V12);
1052    }
1053
1054    #[test]
1055    #[should_panic(expected = "x axis knots must be strictly increasing")]
1056    fn duplicate_x_knots_are_rejected() {
1057        static DUPLICATE: [u16; 2] = [5, 5];
1058
1059        let _ = BilinearSurface::new(&DUPLICATE, &Y2, &V22);
1060    }
1061
1062    #[test]
1063    #[should_panic(expected = "x axis knots must be strictly increasing")]
1064    fn descending_x_knots_are_rejected() {
1065        static DESCENDING: [u16; 2] = [10, 0];
1066
1067        let _ = BilinearSurface::new(&DESCENDING, &Y2, &V22);
1068    }
1069
1070    #[test]
1071    #[should_panic(expected = "y axis knots must be strictly increasing")]
1072    fn duplicate_y_knots_are_rejected() {
1073        static DUPLICATE: [u16; 2] = [5, 5];
1074
1075        let _ = BilinearSurface::new(&X2, &DUPLICATE, &V22);
1076    }
1077
1078    #[test]
1079    #[should_panic(expected = "y axis knots must be strictly increasing")]
1080    fn descending_y_knots_are_rejected() {
1081        static DESCENDING: [u16; 2] = [20, 0];
1082
1083        let _ = BilinearSurface::new(&X2, &DESCENDING, &V22);
1084    }
1085
1086    #[test]
1087    #[should_panic(expected = "y axis knots must be strictly increasing")]
1088    fn a_valid_x_axis_does_not_mask_an_invalid_y_axis() {
1089        // The X axis here is the valid nonuniform three-knot axis, so the
1090        // reported failure can only have come from the Y axis.
1091        static DESCENDING: [u16; 2] = [900, 7];
1092
1093        let _ = BilinearSurface::new(&X3, &DESCENDING, &V23);
1094    }
1095
1096    #[test]
1097    #[should_panic(expected = "x axis knots must be strictly increasing")]
1098    fn a_descending_interior_x_step_is_rejected() {
1099        static NOT_SORTED: [u16; 3] = [0, 100, 50];
1100
1101        let _ = BilinearSurface::new(&NOT_SORTED, &Y2B, &V23);
1102    }
1103}