Skip to main content

ph_surfaces/
evaluate.rs

1//! The public evaluator: composing axis lookup and scalar interpolation into
2//! one deterministic two-dimensional value.
3//!
4//! This module owns the composition order of the crate. It is the only place
5//! that decides which axis is resolved first, which error wins when both are
6//! out of domain, and in which order the three scalar interpolations run. It
7//! introduces no arithmetic and no search of its own: rounding lives in
8//! [`crate::interp`] and axis location lives in [`crate::lookup`].
9//!
10//! # Why the order is part of the contract
11//!
12//! Each scalar step rounds to an `i32` before the next one runs, so composing
13//! X first and composing Y first are observably different functions rather than
14//! two spellings of the same one. The crate therefore fixes one order and makes
15//! it normative rather than offering a choice.
16
17use crate::axis::AxisLookup;
18use crate::error::SurfaceError;
19use crate::interp::interpolate_segment;
20use crate::lookup::Cell;
21use crate::surface::BilinearSurface;
22
23impl<const NX: usize, const NY: usize, X: AxisLookup<NX>, Y: AxisLookup<NY>>
24    BilinearSurface<NX, NY, X, Y>
25{
26    /// Evaluates this surface at `(x, y)` with deterministic X-then-Y bilinear
27    /// interpolation.
28    ///
29    /// # Order
30    ///
31    /// The composition is normative, not an implementation detail:
32    ///
33    /// 1. interpolate along X on the lower-Y row;
34    /// 2. interpolate along X on the upper-Y row;
35    /// 3. interpolate those two *already rounded* results along Y.
36    ///
37    /// Because every step rounds to nearest with exact half-way values away
38    /// from zero, a Y-then-X implementation would return different values, so
39    /// this order is observable. For the axes `[0, 2]` with rows
40    /// `[[0, 0], [1, 3]]`, this order returns `1` at `(1, 1)` where Y-then-X
41    /// would return `2`:
42    ///
43    /// ```
44    /// use ph_surfaces::BilinearSurface;
45    ///
46    /// static AXIS: [u16; 2] = [0, 2];
47    /// static VALUES: [[i32; 2]; 2] = [[0, 0], [1, 3]];
48    /// static SURFACE: BilinearSurface<2, 2> = BilinearSurface::new(&AXIS, &AXIS, &VALUES);
49    ///
50    /// // X on the lower row: 0. X on the upper row: (1 + 3) / 2 = 2.
51    /// // Y between them: (0 + 2) / 2 = 1.
52    /// assert_eq!(SURFACE.evaluate(1, 1), Ok(1));
53    /// ```
54    ///
55    /// # Domain
56    ///
57    /// X is resolved before Y. If both coordinates leave the domain on sides
58    /// selecting [`Boundary::Error`](crate::Boundary::Error), the X-side error
59    /// is the one reported. If the X side clamps, Y is still resolved under its
60    /// own two selections, so a clamped X can be followed by a Y error.
61    ///
62    /// A clamped coordinate is replaced by the nearest declared endpoint knot
63    /// and then evaluated through this same path. Nothing extrapolates: the
64    /// result of a clamped evaluation is a value the surface actually declares
65    /// on its boundary.
66    ///
67    /// ```
68    /// use ph_surfaces::{BilinearSurface, Boundary, BoundaryPolicy};
69    ///
70    /// static X: [u16; 2] = [0, 10];
71    /// static Y: [u16; 2] = [0, 10];
72    /// static VALUES: [[i32; 2]; 2] = [[0, 100], [200, 300]];
73    ///
74    /// static SURFACE: BilinearSurface<2, 2> = BilinearSurface::new(&X, &Y, &VALUES)
75    ///     .with_policy(BoundaryPolicy::new().with_x_above(Boundary::Clamp));
76    ///
77    /// // X clamps to 10 and evaluates the boundary column, never past it.
78    /// assert_eq!(SURFACE.evaluate(4_000, 0), Ok(100));
79    ///
80    /// // Y still errors on its own side, even though X clamped.
81    /// assert_eq!(
82    ///     SURFACE.evaluate(4_000, 11),
83    ///     Err(ph_surfaces::SurfaceError::YAbove { coordinate: 11, bound: 10 }),
84    /// );
85    /// ```
86    ///
87    /// # Errors
88    ///
89    /// Returns the [`SurfaceError`] variant naming the side the coordinate fell
90    /// off, carrying the coordinate as supplied and the applicable first or
91    /// last knot of that axis. Only a side selecting
92    /// [`Boundary::Error`](crate::Boundary::Error) can produce one.
93    ///
94    /// # Cost
95    ///
96    /// A successful evaluation performs exactly
97    /// [`BilinearSurface::SUCCESS_INTERPOLATIONS`] scalar interpolations and
98    /// exactly [`BilinearSurface::SUCCESS_GRID_READS`] reads of the value grid.
99    /// Each in-domain axis lookup costs two endpoint comparisons plus the
100    /// search work of that axis's strategy —
101    /// `ceil(log2(len))` probes for the default
102    /// [`BinaryAxis`](crate::BinaryAxis), and at most
103    /// [`AxisLookup::MAX_SEARCH_COMPARISONS`](crate::AxisLookup::MAX_SEARCH_COMPARISONS)
104    /// comparisons for any of them. A clamped lookup costs one or two endpoint
105    /// comparisons and performs no probes: the endpoint path, not a search. A
106    /// rejected coordinate returns before any interpolation or value-grid
107    /// read, and an X rejection also skips the Y lookup because X is resolved
108    /// first.
109    ///
110    /// The value grid is never scanned and its size affects only in-domain
111    /// lookup cost. Evaluation allocates nothing, keeps no state, and has no
112    /// warm-up, reset, cache, or lifecycle behaviour: the same handle and the
113    /// same coordinates always produce the same result.
114    ///
115    /// The arithmetic cannot overflow for any surface this crate can define.
116    /// That is the bound proven for the private scalar helper: both weights are
117    /// nonnegative and sum to a span of at most `65_535`, and each rounded
118    /// result stays inside the convex hull of its two endpoints. The Y step
119    /// therefore receives two `i32` values drawn from the hull of the four
120    /// corner values and returns one from the same hull, so there is no
121    /// overflow outcome to report.
122    ///
123    /// # Examples
124    ///
125    /// ```
126    /// use ph_surfaces::{BilinearSurface, SurfaceError};
127    ///
128    /// static X: [u16; 3] = [0, 10, 30];
129    /// static Y: [u16; 2] = [0, 100];
130    /// static VALUES: [[i32; 3]; 2] = [[0, 10, 30], [100, 110, 130]];
131    ///
132    /// static SURFACE: BilinearSurface<3, 2> = BilinearSurface::new(&X, &Y, &VALUES);
133    ///
134    /// // A declared knot returns its stored value exactly.
135    /// assert_eq!(SURFACE.evaluate(10, 100), Ok(110));
136    ///
137    /// // An interior point of the plane.
138    /// assert_eq!(SURFACE.evaluate(20, 50), Ok(70));
139    ///
140    /// // Out of domain on the default Error policy.
141    /// assert_eq!(
142    ///     SURFACE.evaluate(31, 0),
143    ///     Err(SurfaceError::XAbove { coordinate: 31, bound: 30 }),
144    /// );
145    /// ```
146    pub fn evaluate(&self, x: u16, y: u16) -> Result<i32, SurfaceError> {
147        // X is resolved first, and this `?` is the whole precedence rule: an
148        // X-side Error returns before Y is ever consulted. An X-side Clamp
149        // yields a cell instead, so Y is then resolved normally under its own
150        // two selections.
151        let x_cell = self.locate_x(x)?;
152        let y_cell = self.locate_y(y)?;
153
154        // Both rows of the located cell. `Cell::upper` is `lower + 1` with
155        // `lower` at most `len - 2`, so every index below is inside its array
156        // and no bounds reasoning is repeated here.
157        let lower_row = &self.values()[y_cell.lower()];
158        let upper_row = &self.values()[y_cell.upper()];
159
160        let at_lower_y = self.interpolate_row(x_cell, lower_row);
161        let at_upper_y = self.interpolate_row(x_cell, upper_row);
162
163        // Step three: interpolate the two already-rounded row results along Y.
164        Ok(interpolate_segment(
165            y_cell.coordinate(),
166            self.y().knot(y_cell.lower()),
167            self.y().knot(y_cell.upper()),
168            at_lower_y,
169            at_upper_y,
170        ))
171    }
172
173    /// Interpolates one row of the value grid along X, at the coordinate the X
174    /// cell resolved to.
175    ///
176    /// Both X steps of the composition are this same function, so the lower-Y
177    /// and upper-Y rows cannot be treated differently by accident.
178    fn interpolate_row(&self, x_cell: Cell, row: &[i32; NX]) -> i32 {
179        interpolate_segment(
180            x_cell.coordinate(),
181            self.x().knot(x_cell.lower()),
182            self.x().knot(x_cell.upper()),
183            row[x_cell.lower()],
184            row[x_cell.upper()],
185        )
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    use crate::boundary::{Boundary, BoundaryPolicy};
192    use crate::error::SurfaceError;
193    use crate::surface::BilinearSurface;
194
195    // On "no evaluator path scans the value grid, allocates, uses floating
196    // point, uses unsafe, or calls `ph-curves`": a unit test cannot observe the
197    // absence of a code path. That evidence is mechanical, as it is for
198    // `src/interp.rs` — `#![forbid(unsafe_code)]` in the crate root, the
199    // `integer only` grep and the `-Z build-std=core` core-only builds in
200    // `cargo xtask ci`, and the `deny.toml` ban on the crate name. The absence
201    // of a scan is structural: `evaluate` reads exactly the four corner values
202    // of one located cell, and the only search in the crate is the counted
203    // binary lookup whose exact comparison bound `src/lookup.rs` asserts. The
204    // tests below carry the numerical and boundary contract instead.
205
206    // The locked order fixture from the accepted contract.
207    static ORDER_AXIS: [u16; 2] = [0, 2];
208    static ORDER_VALUES: [[i32; 2]; 2] = [[0, 0], [1, 3]];
209    static ORDER: BilinearSurface<2, 2> =
210        BilinearSurface::new(&ORDER_AXIS, &ORDER_AXIS, &ORDER_VALUES);
211
212    // A hand-computable plane: value = 10 * x + 100 * y over the axes below.
213    static PLANE_X: [u16; 2] = [0, 10];
214    static PLANE_Y: [u16; 2] = [0, 10];
215    static PLANE_VALUES: [[i32; 2]; 2] = [[0, 100], [1_000, 1_100]];
216    static PLANE: BilinearSurface<2, 2> = BilinearSurface::new(&PLANE_X, &PLANE_Y, &PLANE_VALUES);
217
218    // A nonuniform 3x3 grid with an asymmetric step on both axes.
219    static GRID_X: [u16; 3] = [0, 10, 110];
220    static GRID_Y: [u16; 3] = [0, 4, 8];
221    static GRID_VALUES: [[i32; 3]; 3] = [[0, 100, 300], [10, 110, 310], [-40, 60, 260]];
222    static GRID: BilinearSurface<3, 3> = BilinearSurface::new(&GRID_X, &GRID_Y, &GRID_VALUES);
223
224    // Rows chosen for their shapes: positive increasing, negative decreasing,
225    // flat, and zero-crossing.
226    static SHAPE_X: [u16; 3] = [0, 100, 200];
227    static SHAPE_Y: [u16; 4] = [0, 10, 20, 30];
228    static SHAPE_VALUES: [[i32; 3]; 4] = [
229        [10, 20, 30],    // positive, increasing
230        [-10, -20, -30], // negative, decreasing
231        [7, 7, 7],       // flat
232        [-100, 0, 100],  // zero-crossing
233    ];
234    static SHAPES: BilinearSurface<3, 4> = BilinearSurface::new(&SHAPE_X, &SHAPE_Y, &SHAPE_VALUES);
235
236    // The full `u16` span on both axes with the extreme `i32` corners: the
237    // widest operands a v0.1 surface can present to the arithmetic.
238    static FULL_AXIS: [u16; 2] = [0, u16::MAX];
239    static EXTREME_VALUES: [[i32; 2]; 2] = [[i32::MIN, i32::MAX], [i32::MAX, i32::MIN]];
240    static EXTREME: BilinearSurface<2, 2> =
241        BilinearSurface::new(&FULL_AXIS, &FULL_AXIS, &EXTREME_VALUES);
242
243    // A surface whose domain has room on every side, so each of the four sides
244    // can be probed one unit out.
245    static INSET_X: [u16; 3] = [10, 20, 40];
246    static INSET_Y: [u16; 3] = [100, 200, 400];
247    static INSET_VALUES: [[i32; 3]; 3] = [[0, 1, 2], [10, 11, 12], [20, 21, 22]];
248
249    const fn inset(policy: BoundaryPolicy) -> BilinearSurface<3, 3> {
250        BilinearSurface::new(&INSET_X, &INSET_Y, &INSET_VALUES).with_policy(policy)
251    }
252
253    static INSET: BilinearSurface<3, 3> = inset(BoundaryPolicy::new());
254
255    /// Interpolates one segment under the crate's rounding policy, written out
256    /// here so the reference below shares no code with the implementation.
257    fn segment(t: u16, t0: u16, t1: u16, v0: i32, v1: i32) -> i32 {
258        let span = i64::from(t1) - i64::from(t0);
259        let offset = i64::from(t) - i64::from(t0);
260        let numerator = i64::from(v0) * (span - offset) + i64::from(v1) * offset;
261        let half = span / 2;
262
263        let rounded = if numerator >= 0 {
264            (numerator + half) / span
265        } else {
266            (numerator - half) / span
267        };
268
269        rounded as i32
270    }
271
272    /// Independent reference for the *rejected* composition order: Y first on
273    /// each column, then X across the two rounded results.
274    ///
275    /// It exists so the order fixture is shown to discriminate between the two
276    /// orders rather than merely to agree with one of them.
277    fn y_then_x(surface: &BilinearSurface<2, 2>, x: u16, y: u16) -> i32 {
278        let axis_x = surface.x_axis();
279        let axis_y = surface.y_axis();
280        let values = surface.values();
281
282        let column =
283            |index: usize| segment(y, axis_y[0], axis_y[1], values[0][index], values[1][index]);
284
285        segment(x, axis_x[0], axis_x[1], column(0), column(1))
286    }
287
288    #[test]
289    fn the_locked_order_fixture_returns_the_x_then_y_value() {
290        assert_eq!(ORDER.evaluate(1, 1), Ok(1));
291    }
292
293    #[test]
294    fn the_locked_order_fixture_discriminates_between_the_two_orders() {
295        // X first: rows interpolate to 0 and to (1 + 3) / 2 = 2, and Y between
296        // them gives 1. Y first: the columns interpolate to (0 + 1) / 2 = 1 and
297        // to (0 + 3) / 2 = 2 — each rounded before X sees it — and X between
298        // those gives 2. The fixture therefore separates the two orders instead
299        // of merely agreeing with one.
300        assert_eq!(y_then_x(&ORDER, 1, 1), 2);
301        assert_ne!(ORDER.evaluate(1, 1), Ok(y_then_x(&ORDER, 1, 1)));
302    }
303
304    #[test]
305    fn every_knot_returns_its_stored_value_exactly() {
306        for (row, &y) in GRID.y_axis().iter().enumerate() {
307            for (column, &x) in GRID.x_axis().iter().enumerate() {
308                assert_eq!(
309                    GRID.evaluate(x, y),
310                    Ok(GRID.values()[row][column]),
311                    "knot ({x}, {y}) at values[{row}][{column}]"
312                );
313            }
314        }
315    }
316
317    #[test]
318    fn every_knot_of_a_nonuniform_shape_grid_returns_its_stored_value() {
319        for (row, &y) in SHAPES.y_axis().iter().enumerate() {
320            for (column, &x) in SHAPES.x_axis().iter().enumerate() {
321                assert_eq!(SHAPES.evaluate(x, y), Ok(SHAPES.values()[row][column]));
322            }
323        }
324    }
325
326    #[test]
327    fn a_hand_computable_plane_evaluates_at_interior_points() {
328        // value = 10 * x + 100 * y on this fixture.
329        assert_eq!(PLANE.evaluate(5, 0), Ok(50));
330        assert_eq!(PLANE.evaluate(0, 5), Ok(500));
331        assert_eq!(PLANE.evaluate(5, 5), Ok(550));
332        assert_eq!(PLANE.evaluate(3, 7), Ok(730));
333        assert_eq!(PLANE.evaluate(10, 10), Ok(1_100));
334    }
335
336    #[test]
337    fn a_nonuniform_grid_evaluates_at_interior_points() {
338        // Cell x in [0, 10], y in [0, 4]. At x = 5: lower row (0, 100) -> 50,
339        // upper row (10, 110) -> 60. At y = 2: (50 + 60) / 2 = 55.
340        assert_eq!(GRID.evaluate(5, 2), Ok(55));
341
342        // The wide X segment [10, 110] at x = 60: lower row (100, 300) -> 200,
343        // upper row (110, 310) -> 210. At y = 2: 205.
344        assert_eq!(GRID.evaluate(60, 2), Ok(205));
345
346        // The upper Y segment [4, 8] at y = 6, x = 10: column values 110 and
347        // 60 give 85.
348        assert_eq!(GRID.evaluate(10, 6), Ok(85));
349
350        // A point interior on both axes: x = 60, y = 6. Lower row (y = 4):
351        // (110 + 310) / 2 = 210. Upper row (y = 8): (60 + 260) / 2 = 160.
352        // Y midway: 185.
353        assert_eq!(GRID.evaluate(60, 6), Ok(185));
354    }
355
356    #[test]
357    fn rows_of_every_sign_and_slope_compose_correctly() {
358        // Positive increasing row, X midway.
359        assert_eq!(SHAPES.evaluate(50, 0), Ok(15));
360        // Negative decreasing row, X midway.
361        assert_eq!(SHAPES.evaluate(50, 10), Ok(-15));
362        // Flat row: every X returns the same value.
363        for x in [0, 1, 50, 99, 100, 101, 199, 200] {
364            assert_eq!(SHAPES.evaluate(x, 20), Ok(7));
365        }
366        // Zero-crossing row: the crossing lands exactly on the middle knot.
367        assert_eq!(SHAPES.evaluate(100, 30), Ok(0));
368        assert_eq!(SHAPES.evaluate(50, 30), Ok(-50));
369        assert_eq!(SHAPES.evaluate(150, 30), Ok(50));
370    }
371
372    #[test]
373    fn a_row_pair_of_opposite_sign_interpolates_through_zero_along_y() {
374        // Between the positive row (y = 0) and the negative row (y = 10), the
375        // Y midpoint of a symmetric pair is zero.
376        assert_eq!(SHAPES.evaluate(0, 5), Ok(0));
377        assert_eq!(SHAPES.evaluate(100, 5), Ok(0));
378        assert_eq!(SHAPES.evaluate(200, 5), Ok(0));
379    }
380
381    #[test]
382    fn half_way_values_round_away_from_zero_through_the_composition() {
383        static X: [u16; 2] = [0, 2];
384        static Y: [u16; 2] = [0, 2];
385        // X midpoints are 0.5 and -0.5 on the two rows before rounding.
386        static VALUES: [[i32; 2]; 2] = [[0, 1], [0, -1]];
387        static SURFACE: BilinearSurface<2, 2> = BilinearSurface::new(&X, &Y, &VALUES);
388
389        // Lower row at x = 1: (0 + 1) / 2 = 0.5 -> 1. Upper row: -0.5 -> -1.
390        assert_eq!(SURFACE.evaluate(1, 0), Ok(1));
391        assert_eq!(SURFACE.evaluate(1, 2), Ok(-1));
392        // Y between the two rounded results: (1 + -1) / 2 = 0.
393        assert_eq!(SURFACE.evaluate(1, 1), Ok(0));
394    }
395
396    #[test]
397    fn extreme_operands_evaluate_without_overflow() {
398        let mid = u16::MAX / 2;
399
400        assert_eq!(EXTREME.evaluate(0, 0), Ok(i32::MIN));
401        assert_eq!(EXTREME.evaluate(u16::MAX, 0), Ok(i32::MAX));
402        assert_eq!(EXTREME.evaluate(0, u16::MAX), Ok(i32::MAX));
403        assert_eq!(EXTREME.evaluate(u16::MAX, u16::MAX), Ok(i32::MIN));
404
405        // The centre of this saddle. Both X rows round to +/- 32_768 and the Y
406        // step between them rounds a numerator of -32_768 over a span of
407        // 65_535 away from zero.
408        assert_eq!(EXTREME.evaluate(mid, mid), Ok(-1));
409
410        // Sweeping the widest span exercises the largest numerators the
411        // arithmetic can see. Every intermediate stays in the convex hull of
412        // its two endpoints, which is what `interpolate_segment` asserts in a
413        // debug build, so an overflow or a truncating cast here would panic
414        // rather than pass silently.
415        for x in [0, 1, mid, mid + 1, u16::MAX - 1, u16::MAX] {
416            for y in [0, 1, mid, u16::MAX] {
417                assert!(EXTREME.evaluate(x, y).is_ok(), "({x}, {y}) left the domain");
418            }
419        }
420    }
421
422    #[test]
423    fn an_error_side_reports_the_coordinate_and_the_bound() {
424        assert_eq!(
425            INSET.evaluate(9, 100),
426            Err(SurfaceError::XBelow {
427                coordinate: 9,
428                bound: 10,
429            })
430        );
431        assert_eq!(
432            INSET.evaluate(41, 100),
433            Err(SurfaceError::XAbove {
434                coordinate: 41,
435                bound: 40,
436            })
437        );
438        assert_eq!(
439            INSET.evaluate(10, 99),
440            Err(SurfaceError::YBelow {
441                coordinate: 99,
442                bound: 100,
443            })
444        );
445        assert_eq!(
446            INSET.evaluate(10, 401),
447            Err(SurfaceError::YAbove {
448                coordinate: 401,
449                bound: 400,
450            })
451        );
452    }
453
454    #[test]
455    fn error_error_corners_report_the_x_side() {
456        // All four corners with both axes out of domain and every side an
457        // Error: the X-side error wins in each.
458        let cases = [
459            (
460                9,
461                99,
462                SurfaceError::XBelow {
463                    coordinate: 9,
464                    bound: 10,
465                },
466            ),
467            (
468                9,
469                401,
470                SurfaceError::XBelow {
471                    coordinate: 9,
472                    bound: 10,
473                },
474            ),
475            (
476                41,
477                99,
478                SurfaceError::XAbove {
479                    coordinate: 41,
480                    bound: 40,
481                },
482            ),
483            (
484                41,
485                401,
486                SurfaceError::XAbove {
487                    coordinate: 41,
488                    bound: 40,
489                },
490            ),
491        ];
492
493        for (x, y, expected) in cases {
494            assert_eq!(INSET.evaluate(x, y), Err(expected), "corner ({x}, {y})");
495        }
496    }
497
498    #[test]
499    fn a_clamped_x_does_not_suppress_a_y_error() {
500        static CLAMP_X: BilinearSurface<3, 3> = inset(
501            BoundaryPolicy::new()
502                .with_x_below(Boundary::Clamp)
503                .with_x_above(Boundary::Clamp),
504        );
505
506        // X clamps on both sides, so the surviving error can only be the Y one.
507        assert_eq!(
508            CLAMP_X.evaluate(0, 99),
509            Err(SurfaceError::YBelow {
510                coordinate: 99,
511                bound: 100,
512            })
513        );
514        assert_eq!(
515            CLAMP_X.evaluate(65_535, 401),
516            Err(SurfaceError::YAbove {
517                coordinate: 401,
518                bound: 400,
519            })
520        );
521    }
522
523    #[test]
524    fn a_clamped_y_does_not_suppress_an_x_error() {
525        static CLAMP_Y: BilinearSurface<3, 3> = inset(
526            BoundaryPolicy::new()
527                .with_y_below(Boundary::Clamp)
528                .with_y_above(Boundary::Clamp),
529        );
530
531        assert_eq!(
532            CLAMP_Y.evaluate(9, 0),
533            Err(SurfaceError::XBelow {
534                coordinate: 9,
535                bound: 10,
536            })
537        );
538        assert_eq!(
539            CLAMP_Y.evaluate(41, 65_535),
540            Err(SurfaceError::XAbove {
541                coordinate: 41,
542                bound: 40,
543            })
544        );
545    }
546
547    #[test]
548    fn clamped_edges_evaluate_the_boundary_without_extrapolating() {
549        static CLAMPED: BilinearSurface<3, 3> = inset(
550            BoundaryPolicy::new()
551                .with_x_below(Boundary::Clamp)
552                .with_x_above(Boundary::Clamp)
553                .with_y_below(Boundary::Clamp)
554                .with_y_above(Boundary::Clamp),
555        );
556
557        // An X-clamped edge evaluates the boundary column at the given Y.
558        assert_eq!(CLAMPED.evaluate(0, 100), CLAMPED.evaluate(10, 100));
559        assert_eq!(CLAMPED.evaluate(65_535, 300), CLAMPED.evaluate(40, 300));
560        // A Y-clamped edge evaluates the boundary row at the given X.
561        assert_eq!(CLAMPED.evaluate(20, 0), CLAMPED.evaluate(20, 100));
562        assert_eq!(CLAMPED.evaluate(30, 65_535), CLAMPED.evaluate(30, 400));
563
564        // No clamped result can leave the hull of the declared values.
565        for x in [0, 1, 9, 41, 65_535] {
566            for y in [0, 99, 401, 65_535] {
567                let value = CLAMPED.evaluate(x, y).expect("every side clamps");
568                assert!(
569                    (0..=22).contains(&value),
570                    "({x}, {y}) extrapolated to {value}"
571                );
572            }
573        }
574    }
575
576    #[test]
577    fn clamp_clamp_corners_return_the_corner_value() {
578        static CLAMPED: BilinearSurface<3, 3> = inset(
579            BoundaryPolicy::new()
580                .with_x_below(Boundary::Clamp)
581                .with_x_above(Boundary::Clamp)
582                .with_y_below(Boundary::Clamp)
583                .with_y_above(Boundary::Clamp),
584        );
585
586        assert_eq!(CLAMPED.evaluate(0, 0), Ok(INSET_VALUES[0][0]));
587        assert_eq!(CLAMPED.evaluate(65_535, 0), Ok(INSET_VALUES[0][2]));
588        assert_eq!(CLAMPED.evaluate(0, 65_535), Ok(INSET_VALUES[2][0]));
589        assert_eq!(CLAMPED.evaluate(65_535, 65_535), Ok(INSET_VALUES[2][2]));
590    }
591
592    #[test]
593    fn each_side_selects_independently_of_the_other_three() {
594        // Only X-below clamps. The other three sides still reject.
595        static ONE_SIDE: BilinearSurface<3, 3> =
596            inset(BoundaryPolicy::new().with_x_below(Boundary::Clamp));
597
598        assert_eq!(ONE_SIDE.evaluate(0, 100), Ok(INSET_VALUES[0][0]));
599        assert_eq!(
600            ONE_SIDE.evaluate(41, 100),
601            Err(SurfaceError::XAbove {
602                coordinate: 41,
603                bound: 40,
604            })
605        );
606        assert_eq!(
607            ONE_SIDE.evaluate(0, 99),
608            Err(SurfaceError::YBelow {
609                coordinate: 99,
610                bound: 100,
611            })
612        );
613        assert_eq!(
614            ONE_SIDE.evaluate(0, 401),
615            Err(SurfaceError::YAbove {
616                coordinate: 401,
617                bound: 400,
618            })
619        );
620    }
621
622    #[test]
623    fn all_sixteen_policies_agree_in_domain() {
624        // A policy selects what happens outside the domain and nothing else,
625        // so no combination may change an in-domain value.
626        let expected = INSET.evaluate(25, 250);
627
628        for bits in 0..16u8 {
629            let side = |shift: u8| {
630                if (bits >> shift) & 1 == 0 {
631                    Boundary::Error
632                } else {
633                    Boundary::Clamp
634                }
635            };
636            let surface = inset(
637                BoundaryPolicy::new()
638                    .with_x_below(side(0))
639                    .with_x_above(side(1))
640                    .with_y_below(side(2))
641                    .with_y_above(side(3)),
642            );
643
644            assert_eq!(surface.evaluate(25, 250), expected, "policy bits {bits}");
645        }
646    }
647
648    #[test]
649    fn repeated_calls_are_identical_and_mutate_no_state() {
650        let before = GRID;
651        let first = GRID.evaluate(60, 6);
652
653        for _ in 0..64 {
654            assert_eq!(GRID.evaluate(60, 6), first);
655        }
656
657        // Interleaving other coordinates cannot disturb the result either:
658        // there is no cached cell to invalidate.
659        for x in [0, 10, 55, 110] {
660            for y in [0, 4, 7, 8] {
661                let _ = GRID.evaluate(x, y);
662            }
663        }
664
665        assert_eq!(GRID.evaluate(60, 6), first);
666        assert_eq!(GRID, before);
667        assert_eq!(GRID.values(), before.values());
668    }
669
670    #[test]
671    fn evaluation_is_free_of_warm_up_behaviour() {
672        // A freshly declared handle over the same tables answers exactly as one
673        // that has already been evaluated many times.
674        static FRESH: BilinearSurface<3, 3> = inset(BoundaryPolicy::new());
675
676        let warmed = INSET.evaluate(25, 250);
677        assert_eq!(FRESH.evaluate(25, 250), warmed);
678    }
679
680    #[test]
681    fn a_two_knot_axis_pairs_with_a_longer_one() {
682        // `len - 2 == 0` on Y: every clamp resolves to cell zero, and the
683        // composition still reads two distinct rows.
684        static X: [u16; 4] = [0, 1, 2, 3];
685        static Y: [u16; 2] = [0, 1];
686        static VALUES: [[i32; 4]; 2] = [[0, 10, 20, 30], [100, 110, 120, 130]];
687        static SURFACE: BilinearSurface<4, 2> = BilinearSurface::new(&X, &Y, &VALUES);
688
689        for (row, &y) in Y.iter().enumerate() {
690            for (column, &x) in X.iter().enumerate() {
691                assert_eq!(SURFACE.evaluate(x, y), Ok(VALUES[row][column]));
692            }
693        }
694    }
695}