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}