ph-surfaces 0.1.0

Deterministic no-std, no-alloc integer surface mappings for embedded Rust
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
# ph-surfaces

Deterministic `no_std`, no-alloc integer surface mappings for embedded Rust.

[![ph-surfaces on crates.io](https://img.shields.io/crates/v/ph-surfaces.svg?label=ph-surfaces)](https://crates.io/crates/ph-surfaces)
[![ph-surfaces API documentation](https://docs.rs/ph-surfaces/badge.svg)](https://docs.rs/ph-surfaces)
[![CI](https://github.com/photon-circus/ph-surfaces/actions/workflows/ci.yml/badge.svg)](https://github.com/photon-circus/ph-surfaces/actions/workflows/ci.yml)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)

> **Status:** Active. `ph-surfaces` is published at version `0.1.0`.
> The API is intentionally narrow: one static bilinear surface, four
> compile-time lookup strategies, and an explicit Error/Clamp boundary
> policy. There is no 1.0 compatibility promise.
> **Domain:** Libraries.

```toml
[dependencies]
ph-surfaces = "0.1"
```

## What this is

A reusable math crate for evaluating static rectilinear two-dimensional integer
surfaces on embedded firmware. The accepted v0.1 destination is:

> Evaluate a static rectilinear two-dimensional `u16 × u16 → i32` surface with
> deterministic X-then-Y bilinear interpolation, four independent Error/Clamp
> boundary sides, no allocation, no floating point at runtime, and an explicit
> compile-time choice of lookup strategy for each axis.

`BilinearSurface::evaluate` implements that contract, and binary lookup remains
the default on both axes. A firmware compensation table is three `static`
arrays and one `static` handle — no allocator, no warm-up, no cache. Coordinates
are already quantized to `u16` and values to `i32` by the application; the
surface stores neither units nor provenance.

```rust
use ph_surfaces::{BilinearSurface, SurfaceError};

// Operating codes and a signed correction. Invented, device-neutral numbers.
static X: [u16; 2] = [100, 200];
static Y: [u16; 2] = [10, 30];
static VALUES: [[i32; 2]; 2] = [
    [0, 100],  // Y = 10
    [40, 180], // Y = 30
];

static SURFACE: BilinearSurface<2, 2> = BilinearSurface::new(&X, &Y, &VALUES);

fn main() {
    assert_eq!(SURFACE.evaluate(100, 10), Ok(0)); // a declared knot
    assert_eq!(SURFACE.evaluate(125, 20), Ok(50)); // interior point; see the walkthrough
    assert_eq!(
        SURFACE.evaluate(0, 20),
        Err(SurfaceError::XBelow {
            coordinate: 0,
            bound: 100
        })
    );
}
```

Every Rust code block in this README is compiled and run as a doctest of the
packaged crate, so the README cannot drift from the API it describes.

## Start here

Task-oriented firmware guidance lives next to this README, not inside the
normative contract below.

1. **[Usage guide]https://github.com/photon-circus/ph-surfaces/blob/v0.1.0/docs/usage-guide.md** — lay out axes as `values[y][x]`,
   declare a static Binary surface, name all four boundary sides, and place
   payload / handle / work figures in the right budget.
2. **One evaluation.** The query `(125, 20)` above sits in the cell
   `X ∈ [100, 200]`, `Y ∈ [10, 30]`. X interpolates on each Y row, each step
   rounds to nearest with ties away from zero, then Y interpolates those two
   already-rounded results: `25`, then `75`, then `50`. An X-side `Error`
   short-circuits before Y. The arithmetic is walked in
   **[the interpolation walkthrough]https://github.com/photon-circus/ph-surfaces/blob/v0.1.0/docs/interpolation-walkthrough.md**.
3. **Choose a strategy** independently on each axis. Changing a strategy cannot
   change a value, an error, rounding, order, or boundary behaviour.

   | Situation | Starting choice | Then verify |
   | --- | --- | --- |
   | Unsure, or a general irregular axis | `BinaryAxis` (default) | its exact comparison bound is acceptable |
   | Knots are an exact arithmetic progression | `UniformAxis` | dropped knot storage is valuable; measure division on the target if timing matters |
   | Axis is very small | compare `LinearAxis` with `BinaryAxis` | generated target code and measured timing — no universal knot-count threshold |
   | Irregular axis needs a smaller proven local bound | `BucketedAxis` | `max_local_comparisons` improves enough to justify `2*B` index bytes |

   The cookbook, including Bucketed index tuning, is
   **[choosing a strategy]https://github.com/photon-circus/ph-surfaces/blob/v0.1.0/docs/choosing-a-strategy.md**.
4. **Runnable examples** (host `main` is an assertion harness; the tables are
   `static` and `core`-only):
   [`firmware_quickstart`]examples/firmware_quickstart.rs,
   [`uniform_sensor_compensation`]examples/uniform_sensor_compensation.rs,
   [`mixed_calibration_map`]examples/mixed_calibration_map.rs,
   [`fail_safe_boundaries`]examples/fail_safe_boundaries.rs,
   [`firmware_cost_budget`]examples/firmware_cost_budget.rs.

   ```sh
   cargo run --example firmware_quickstart
   ```

## Independence from `ph-curves`

**This crate has no dependency on `ph-curves` in any form** — not direct,
transitive, optional, feature-gated, target-specific, development, build,
path, or Git. Its `[dependencies]`, `[dev-dependencies]`, and
`[build-dependencies]` tables are empty. The scalar arithmetic it needs (one
signed segment interpolation with one rounding rule) is a private helper in
`src/interp.rs`, specified in this repository and verified locally against an
independent integer reference. That is a v0.1 decision, not an accident: shared
arithmetic can be reconsidered only after shipped duplication provides evidence
for a neutral common crate, in a separate post-v0.1 proposal.

The gate proves the absence rather than asserting it. `cargo xtask ci` rejects
the name in the manifest text (every dependency kind, `[patch]`, `[replace]`),
in `Cargo.lock`, and in `cargo metadata --all-features`; `deny.toml` bans it as
a fourth layer; the downstream consumer's fresh lockfile may name only two
packages; and the mutation tests in `tools/xtask` show the guard fires when a
`ph-curves` dependency is injected into a copy of the tree.

## Contract

This section is the consumer-facing statement of the implemented contract. Each
item below is implemented and tested by the black-box suite in
`tests/conformance/`.

### Representation

- The public concrete type is
  `BilinearSurface<const NX: usize, const NY: usize, X = BinaryAxis<NX>, Y = BinaryAxis<NY>>`.
  The two strategy parameters default to binary lookup, so `BilinearSurface<NX, NY>`
  is the binary-knotted surface it has always been.
- It references `&'static [u16; NX]` (X knots), `&'static [u16; NY]` (Y
  knots), and a row-major `&'static [[i32; NX]; NY]` value grid. Y selects the
  row and X selects the column: a value is addressed as **`values[y][x]`**.
- Because the grid type is `[[i32; NX]; NY]`, swapping unequal X/Y dimensions
  is a **compile-time type error**, not a runtime error. For a square surface,
  transposition preserves the type, so the caller must still supply the
  documented row-major `values[y][x]` orientation. There is no reachable
  runtime dimension-mismatch outcome.
- `BilinearSurface::new` is a `const fn`. It asserts at least two knots on each
  axis and strict increase of both axes. In a `static` or `const` definition
  those assertions run at compile time, so an invalid definition **fails to
  compile**. The rustdoc on `BilinearSurface::new` carries `compile_fail`
  doctests for each rejected shape.
- The handle stores no units, provenance, achieved-error claim, host report, or
  other generated metadata: for the default surface, three references and four
  one-byte boundary selections.

### Per-axis lookup strategies

Each axis chooses **in the type** how it locates a coordinate, and the two axes
choose independently. There is no runtime discriminant and no branch among
strategies: a firmware that names one combination compiles that one.

| Strategy | Stored per axis | Search work, in knot comparisons | Choose when |
| --- | --- | --- | --- |
| `LinearAxis<N>` | `2*N` knot bytes | bounded scan, at most `N - 1` | tiny axis; minimum auxiliary structure |
| `BinaryAxis<N>` (default) | `2*N` knot bytes | exactly `ceil(log2(N))` | the general default |
| `UniformAxis<N, ORIGIN, STEP>` | nothing | none: one subtraction, one division | even spacing; drop knot arrays; constant location |
| `BucketedAxis<N, B>` | `2*N` knot bytes plus `2*B` index bytes | one bucket read plus a local scan bounded by `max_local_comparisons` | irregular axis; extra index bytes for a smaller local bound |

- `AxisLookup` and `KnotArray` are **sealed**. Those four types are the only
  implementations, and each validates its own invariants in a `const fn`
  constructor, so an invalid axis fails to compile: fewer than two knots, a
  non-increasing knot array, a zero or unrepresentable uniform step, or a bucket
  index that does not match its knots.
- A `BucketedAxis` index is generated at compile time by `bucket_index` and
  re-derived by the constructor. Nothing is built, cached, or mutated at
  runtime.
- Every strategy locates the same cell, evaluates the same value, and reports
  the same error. Only stored bytes and search work differ; rounding,
  composition order, boundary semantics, and error variants are unchanged.
- A surface hands out its axes: `x()` / `y()` return each axis with its
  strategy, so generic code bounded on `AxisLookup` (or `KnotArray` for the
  stored strategies) can read domain bounds, knots, and cost constants
  without carrying the knot arrays separately.

```rust
use ph_surfaces::{
    AxisLookup, BilinearSurface, BinaryAxis, BucketedAxis, UniformAxis,
    bucket_index, max_local_comparisons,
};

static X: [u16; 17] = [
    0, 100, 210, 300, 405, 500, 610, 700, 805, 900, 1_010, 1_100, 1_205,
    1_300, 1_410, 1_500, 1_600,
]; // irregular: keeps its knots
static X_INDEX: [u16; 8] = bucket_index(&X);
static Y: [u16; 9] = [0, 200, 400, 600, 800, 1_000, 1_200, 1_400, 1_600];
static VALUES: [[i32; 17]; 9] = [[0; 17]; 9];

static MIXED: BilinearSurface<17, 9, BucketedAxis<17, 8>, UniformAxis<9, 0, 200>> =
    BilinearSurface::from_axes(BucketedAxis::new(&X, &X_INDEX), UniformAxis::new(), &VALUES);
static DEFAULT: BilinearSurface<17, 9> = BilinearSurface::new(&X, &Y, &VALUES);

fn main() {
    assert_eq!(MIXED.evaluate(610, 400), DEFAULT.evaluate(610, 400));
    assert_eq!(MIXED.y_knot(8), 1_600); // described, not stored
    assert_eq!(max_local_comparisons(&X, &X_INDEX), 3);
    assert_eq!(<BinaryAxis<17>>::MAX_SEARCH_COMPARISONS, 5);
}
```

### Boundary policies and errors

- `Boundary` is the whole v0.1 vocabulary: `Error` or `Clamp`.
- `BoundaryPolicy` names four independent sides — X-below, X-above, Y-below,
  Y-above — and **every side defaults to `Error`**. `BoundaryPolicy::new()`
  with `with_x_below` / `with_x_above` / `with_y_below` / `with_y_above` is
  const-usable, so a policy is part of a `static` definition.
- `SurfaceError` has exactly four variants — `XBelow`, `XAbove`, `YBelow`,
  `YAbove` — each carrying the `coordinate` as supplied and the applicable
  `bound` (the first knot for below, the last knot for above). It implements
  `Display` and `core::error::Error` and is deliberately not
  `#[non_exhaustive]`.
- `Clamp` substitutes the nearest declared endpoint coordinate and evaluates
  the boundary row or column. **Extrapolation is never performed** under either
  selection: a clamped result is a value inside the hull of the stored values.

### Precedence: X before Y

Coordinates are resolved X first, then Y. When both axes are outside `Error`
sides, the **X error wins**. If X clamps, Y is still evaluated under its own
two selections, so a clamped X can be followed by a Y error.

```rust
use ph_surfaces::{BilinearSurface, Boundary, BoundaryPolicy, SurfaceError};

static X: [u16; 2] = [0, 10];
static Y: [u16; 2] = [0, 10];
static VALUES: [[i32; 2]; 2] = [[0, 100], [200, 300]];

static STRICT: BilinearSurface<2, 2> = BilinearSurface::new(&X, &Y, &VALUES);
static CLAMP_X_ABOVE: BilinearSurface<2, 2> = BilinearSurface::new(&X, &Y, &VALUES)
    .with_policy(BoundaryPolicy::new().with_x_above(Boundary::Clamp));

fn main() {
    // Both out of domain on Error sides: the X-side error is the one reported.
    assert_eq!(
        STRICT.evaluate(11, 11),
        Err(SurfaceError::XAbove { coordinate: 11, bound: 10 })
    );
    // X clamps to 10 and evaluates the boundary column; nothing is extrapolated.
    assert_eq!(CLAMP_X_ABOVE.evaluate(4_000, 0), Ok(100));
    // X clamped, but Y is still resolved under its own (Error) side.
    assert_eq!(
        CLAMP_X_ABOVE.evaluate(4_000, 11),
        Err(SurfaceError::YAbove { coordinate: 11, bound: 10 })
    );
}
```

### Scalar rounding

Each scalar segment computes the exact signed rational
`(y0 * (span - offset) + y1 * offset) / span` in `i64` arithmetic, where
`span = t1 - t0` and `offset = t - t0`. The division **rounds to nearest, and an
exact half-way value rounds away from zero**. There is one rounding helper in
the crate and every interpolated value goes through it.

```rust
use ph_surfaces::BilinearSurface;

static AXIS: [u16; 2] = [0, 2];
static VALUES: [[i32; 2]; 2] = [[0, 1], [0, -1]];
static TIES: BilinearSurface<2, 2> = BilinearSurface::new(&AXIS, &AXIS, &VALUES);

fn main() {
    assert_eq!(TIES.evaluate(1, 0), Ok(1)); // +0.5 rounds away from zero to 1
    assert_eq!(TIES.evaluate(1, 2), Ok(-1)); // -0.5 rounds away from zero to -1
}
```

### Normative X-then-Y bilinear order

Bilinear evaluation always interpolates along X on the lower-Y row, along X on
the upper-Y row, and then interpolates those two **already-rounded** values
along Y. Because every step rounds, X-then-Y and Y-then-X are observably
different functions; the crate fixes X-then-Y and makes it part of the
contract. The locked fixture:

```rust
use ph_surfaces::BilinearSurface;

static AXIS: [u16; 2] = [0, 2];
static VALUES: [[i32; 2]; 2] = [[0, 0], [1, 3]];
static SURFACE: BilinearSurface<2, 2> = BilinearSurface::new(&AXIS, &AXIS, &VALUES);

fn main() {
    // X on the lower row: 0. X on the upper row: (1 + 3) / 2 = 2.
    // Y between them: (0 + 2) / 2 = 1. Y-then-X would return 2.
    assert_eq!(SURFACE.evaluate(1, 1), Ok(1));
}
```

### No arithmetic-overflow variant

The public v0.1 error surface has no overflow variant because none is
reachable for any surface this crate can define. Both segment weights,
`span - offset` and `offset`, are nonnegative and sum to `span ≤ 65_535`, so
the `i64` numerator `y0 * (span - offset) + y1 * offset` has magnitude at most
`2^31 * 65_535 < 2^47`, far inside `i64`. The rounded quotient lies in the
closed hull of `y0` and `y1`, so each scalar result fits `i32`. The Y step then
receives two `i32` values from the hull of the four corner values and returns
one from the same hull. This holds for the full `u16` axis range, including
knots at `0` and `65_535`, and for grids containing `i32::MIN` and `i32::MAX`;
the conformance suite asserts it on those extremes against an `i128`
reference.

### Stateless

Evaluation is a pure function of the handle and the two coordinates. The
primitive has no reset, warm-up, cache, clock, I/O, persistence, hardware, or
lifecycle semantics. The same handle and the same coordinates always produce
the same result, and evaluating never mutates or allocates anything.

### Panics and cross-target determinism

`evaluate` **cannot panic for any surface that can exist**: every index it
computes is bounded by the located cell's invariant, its one division is by a
validated positive span, and its arithmetic cannot overflow (see above). That
is a structural argument, exercised by the exhaustive conformance sweeps —
not a claim that the compiled artifact contains no panic branches: the
compiler keeps the bounds checks it cannot prove dead, and the committed
per-target emitted-instruction snapshots (`docs/asm-snapshot-*.txt`) record
exactly what is generated. The panicking paths in the API are confined to the
`const fn` constructors and to index accessors with documented `# Panics`
sections (the knot accessors and `AxisLookup::search`). In `static`/`const`
position those assertions are compile errors; at runtime they fire only on a
violated caller precondition, never on data.

Because evaluation is integer-only with one fixed rounding rule, a given
surface and coordinate pair produces the **bit-identical `i32` on every
supported target** — host, ARM, and RISC-V. There is no floating-point
rounding mode, target-width, or build-profile dependence to vary the result.
Floating point never participates, and that is a disclosed policy rather than
an accident: the crate declares no features, and any future hardware-specific
fast path (for example an FPU path on Cortex-M4F/M7, where single-precision
float can be cheaper than 64-bit integer division) would have to arrive as an
**off-by-default feature gate** that leaves default-build results untouched,
with its determinism trade-offs documented. It is excluded from v0.1 precisely
because per-target float rounding would break this guarantee.

## Examples

The firmware-first Cargo examples listed under [Start here](#start-here) are
the teaching path: static compensation, derating, and calibration maps, plus
an exact resource-budget comparison. They make no vendor, sensor, accuracy, or
safety claim.

The two maps below remain the packaged `ELEVATION` and `CORRECTION` fixtures.
They demonstrate nonuniform axes, mixed-sign values, a boundary policy, and
the rounding rule on hand-computable points. Every declared point is checked
against the independent reference in `tests/conformance/`, and they are two of
the surfaces the packaged downstream `no_std` consumer declares and evaluates.

A mixed-sign elevation map over unevenly spaced plan-view positions, holding
the last column past the far X edge:

```rust
use ph_surfaces::{BilinearSurface, Boundary, BoundaryPolicy, SurfaceError};

static ELEVATION_X: [u16; 5] = [0, 25, 60, 100, 180];
static ELEVATION_Y: [u16; 4] = [0, 40, 90, 150];
static ELEVATION_VALUES: [[i32; 5]; 4] = [
    [-120, -35, 40, 15, -60],
    [-80, 10, 95, 60, -20],
    [-15, 55, 130, 88, 5],
    [-40, 20, 70, 110, 45],
];
static ELEVATION: BilinearSurface<5, 4> =
    BilinearSurface::new(&ELEVATION_X, &ELEVATION_Y, &ELEVATION_VALUES)
        .with_policy(BoundaryPolicy::new().with_x_above(Boundary::Clamp));

fn main() {
    // A declared knot returns its stored height exactly.
    assert_eq!(ELEVATION.evaluate(60, 90), Ok(130));
    // (10, 20): rows give -86 and -44; midway along Y: -65.
    assert_eq!(ELEVATION.evaluate(10, 20), Ok(-65));
    // (75, 100): rows give 114.25 -> 114 and 85; then 114 - 29 * 10 / 60 -> 109.
    assert_eq!(ELEVATION.evaluate(75, 100), Ok(109));
    // (140, 60): rows give 20 and 46.5 -> 47; then 20 + 27 * 20 / 50 -> 31.
    assert_eq!(ELEVATION.evaluate(140, 60), Ok(31));
    // Past the far X edge the last column is held; Y still errors on its side.
    assert_eq!(ELEVATION.evaluate(u16::MAX, 0), Ok(-60));
    assert_eq!(
        ELEVATION.evaluate(500, 151),
        Err(SurfaceError::YAbove { coordinate: 151, bound: 150 })
    );
}
```

An asymmetric process-correction map — X a setpoint code, Y a load code,
values a signed correction in milli-units — holding the last load row above
its range:

```rust
use ph_surfaces::{BilinearSurface, Boundary, BoundaryPolicy, SurfaceError};

static CORRECTION_X: [u16; 4] = [40, 55, 90, 200];
static CORRECTION_Y: [u16; 5] = [0, 10, 25, 70, 120];
static CORRECTION_VALUES: [[i32; 4]; 5] = [
    [125, 80, -15, -140],
    [90, 41, -33, -170],
    [30, -7, -61, -205],
    [-48, -95, -150, -260],
    [-110, -142, -199, -333],
];
static CORRECTION: BilinearSurface<4, 5> =
    BilinearSurface::new(&CORRECTION_X, &CORRECTION_Y, &CORRECTION_VALUES)
        .with_policy(BoundaryPolicy::new().with_y_above(Boundary::Clamp));

fn main() {
    // (47, 5): rows give 104 and 67; (104 + 67) / 2 = 85.5, an exact tie -> 86.
    assert_eq!(CORRECTION.evaluate(47, 5), Ok(86));
    // (145, 100): rows give -205 and -266; then -205 - 61 * 30 / 50 -> -242.
    assert_eq!(CORRECTION.evaluate(145, 100), Ok(-242));
    // (60, 40): rows give -15 and -103; then -15 - 88 * 15 / 45 -> -44.
    assert_eq!(CORRECTION.evaluate(60, 40), Ok(-44));
    // Loads above the table hold the last row; setpoints outside are rejected.
    assert_eq!(CORRECTION.evaluate(90, u16::MAX), Ok(-199));
    assert_eq!(
        CORRECTION.evaluate(39, 500),
        Err(SurfaceError::XBelow { coordinate: 39, bound: 40 })
    );
}
```

## What it is for

Firmware that needs a device-neutral, allocation-free mapping from two `u16`
axes onto an `i32` value — for example multidimensional compensation — without
taking a dependency on `ph-curves` or pulling in host tooling.

## What state it is in

Active. Version `0.1.0` is published on
[crates.io](https://crates.io/crates/ph-surfaces); API documentation is on
[docs.rs](https://docs.rs/ph-surfaces). Compatibility follows semantic
versioning for a pre-1.0 crate: a breaking change increments the minor
version. There is no 1.0 compatibility promise. The maintainer-facing
[release traceability map](https://github.com/photon-circus/ph-surfaces/blob/v0.1.0/docs/v0.1-traceability.md)
summarizes the implementation, tests, and gates behind the contract.

## Responsibility

`ph-surfaces` owns static multidimensional mapping mechanics: shape and
invariant validation, axis location, explicit domain policies, deterministic
integer interpolation, and truthful resource and evidence accounting.

## Out of scope

It does not own hardware access, sensor configuration, sampling, clocks,
persistence, calibration discovery, fault or application policy, device
lifecycle, vendor catalogs, or total measurement accuracy.

v0.1 explicitly does not include:

- A dependency on `ph-curves` or extraction of a shared arithmetic crate
- Inverse lookup or solving for either axis
- Arbitrary N-dimensional tensors, signed or wider axes, or generic output
  types
- Scattered points, triangulation, irregular meshes, bicubic interpolation,
  extrapolation, or adaptive fitting
- Dynamic or runtime-loaded grids, runtime mutation, caching, allocation,
  unsafe code, or floating point
- Runtime semantic metadata, units, provenance, or generated error reports
- Host generation, CLI tooling, formula ingestion, or numerical fitting
- Runtime-selectable strategies, runtime-generated indexes, or a direct
  coordinate-to-cell LUT. A direct LUT remains deferred unless a concrete
  firmware consumer supplies a coordinate domain and latency/jitter bound,
  measurements showing Bucketed lookup misses it on a named target/profile, a
  static-data budget, and a reproducible generation and validation plan.
- Device-specific equations, source catalogs, filtering, fusion, scheduling,
  buses, GPIO, async, or storage

## Constraints

- Unconditional `#![no_std]`; core-only runtime; `unsafe` is forbidden
- No `[dependencies]`, `[dev-dependencies]`, or `[build-dependencies]`, and
  none of those tables may name `ph-curves` later either
- MSRV and toolchain pin: Rust 1.94.0, edition 2024
- Published version `0.1.0`; no 1.0 compatibility promise

## Resource accounting and cost

**Storage.** The referenced table element payload is exactly
`BilinearSurface::PAYLOAD_BYTES`: `X::KNOT_BYTES + X::INDEX_BYTES +
Y::KNOT_BYTES + Y::INDEX_BYTES + VALUE_BYTES`, with `VALUE_BYTES = 4*NX*NY`.
For the default binary pairing that is `2*NX + 2*NY + 4*NX*NY` bytes. Naming a
strategy changes the two axis terms and nothing else: `2*N` and no index for
`LinearAxis` and `BinaryAxis`, nothing at all for `UniformAxis`, and `2*N`
plus `2*B` for `BucketedAxis<N, B>`. Those figures are exact and
target-independent, and they are only the referenced element payload. It is
not total RAM, flash, binary, or linker cost; alignment, section placement,
code, and stack are outside it. The handle is separate and target-dependent:
`HANDLE_BYTES` is `size_of` of the handle on the current target. Every handle
has the value-grid reference and four one-byte boundary selections; each
Uniform axis adds no reference, each Linear or Binary axis adds one knot-array
reference, and each Bucketed axis adds both a knot-array and an index-array
reference. The default binary/binary handle is therefore three thin references
plus the policy and any alignment padding. It does not grow with `NX` or `NY`
for a fixed strategy pairing. Host tests assert these figures without assuming
a pointer width or field layout beyond Rust's guarantees. Code size, flash
placement, and stack depth are properties of the consuming build and its
linker; this crate states none of them as a guarantee.

Default binary `ELEVATION` 5×4: payload `10 + 8 + 80 = 98`. In-domain searches
are two endpoint comparisons plus `ceil(log2(5))` and `ceil(log2(4))` probes.
A successful evaluation is three interpolations and four grid reads:

```rust
use ph_surfaces::{AxisLookup, BilinearSurface, BinaryAxis};

fn main() {
    assert_eq!(BilinearSurface::<5, 4>::VALUE_BYTES, 80);
    assert_eq!(BilinearSurface::<5, 4>::PAYLOAD_BYTES, 98);
    assert_eq!(BilinearSurface::<5, 4>::SUCCESS_INTERPOLATIONS, 3);
    assert_eq!(BilinearSurface::<5, 4>::SUCCESS_GRID_READS, 4);
    assert_eq!(<BinaryAxis<5>>::MAX_SEARCH_COMPARISONS, 3);
    assert_eq!(<BinaryAxis<4>>::MAX_SEARCH_COMPARISONS, 2);
    assert_eq!(
        BilinearSurface::<5, 4>::HANDLE_BYTES,
        core::mem::size_of::<BilinearSurface<5, 4>>()
    );
}
```

Tiny Linear×Linear 3×2: six X knot bytes, four Y knot bytes, 24 value bytes,
payload 34; each axis searches at most `N - 1` knot comparisons:

```rust
use ph_surfaces::{AxisLookup, BilinearSurface, LinearAxis};

fn main() {
    type Tiny = BilinearSurface<3, 2, LinearAxis<3>, LinearAxis<2>>;
    assert_eq!(Tiny::VALUE_BYTES, 24);
    assert_eq!(Tiny::PAYLOAD_BYTES, 34);
    assert_eq!(<LinearAxis<3>>::MAX_SEARCH_COMPARISONS, 2);
    assert_eq!(<LinearAxis<2>>::MAX_SEARCH_COMPARISONS, 1);
    assert_eq!(Tiny::SUCCESS_INTERPOLATIONS, 3);
    assert_eq!(Tiny::SUCCESS_GRID_READS, 4);
}
```

Mixed `BucketedAxis<17, 8>` × `UniformAxis<9, 0, 200>`: X knots+index
`34 + 16`, Y knots 0, grid 612, payload 662. On this concrete irregular axis,
the bucket index reduces the X search bound from 5 comparisons (Binary) to 3;
Uniform uses no knot comparisons. Including the two endpoint comparisons per
in-domain axis, the lookup bound is 7 comparisons instead of 13 for
Binary×Binary, while the referenced payload is 662 bytes instead of 664:

```rust
use ph_surfaces::{
    AxisLookup, BilinearSurface, BinaryAxis, BucketedAxis, UniformAxis,
    bucket_index, max_local_comparisons,
};

fn main() {
    static X: [u16; 17] = [
        0, 100, 210, 300, 405, 500, 610, 700, 805, 900, 1_010, 1_100,
        1_205, 1_300, 1_410, 1_500, 1_600,
    ];
    static X_INDEX: [u16; 8] = bucket_index(&X);
    type Mixed = BilinearSurface<17, 9, BucketedAxis<17, 8>, UniformAxis<9, 0, 200>>;
    type AllBinary = BilinearSurface<17, 9>;
    assert_eq!(<BucketedAxis<17, 8>>::KNOT_BYTES, 34);
    assert_eq!(<BucketedAxis<17, 8>>::INDEX_BYTES, 16);
    assert_eq!(max_local_comparisons(&X, &X_INDEX), 3);
    assert_eq!(<BinaryAxis<17>>::MAX_SEARCH_COMPARISONS, 5);
    assert_eq!(<UniformAxis<9, 0, 200>>::KNOT_BYTES, 0);
    assert_eq!(<UniformAxis<9, 0, 200>>::MAX_SEARCH_COMPARISONS, 0);
    assert_eq!(Mixed::VALUE_BYTES, 612);
    assert_eq!(Mixed::PAYLOAD_BYTES, 662);
    assert_eq!(AllBinary::PAYLOAD_BYTES, 664);
    assert_eq!(Mixed::SUCCESS_INTERPOLATIONS, 3);
    assert_eq!(Mixed::SUCCESS_GRID_READS, 4);
}
```

**Work.** A worst-case `evaluate` is two axis searches and
`SUCCESS_INTERPOLATIONS` (exactly 3) scalar interpolations. Each in-domain
axis search is two endpoint comparisons plus the search work of that axis's
strategy — `AxisLookup::MAX_SEARCH_COMPARISONS`, and exactly `ceil(log2(len))`
probes for the default binary strategy. A clamped coordinate takes the
endpoint path: one or two comparisons and no probes. A rejected evaluation
returns before any interpolation or `SUCCESS_GRID_READS` (exactly 4) grid
reads, and a rejected X also skips the Y search. Exactly four grid elements
are read on success, and the grid is never scanned. For a `BucketedAxis`,
`max_local_comparisons` states the exact local bound for its own knots and
index, and raising the bucket count to a multiple of itself splits buckets
rather than moving their boundaries, so that bound never increases. That is
operation structure derived from the implementation and asserted by its tests.
It is not a cycle count or a WCET figure: no timing has been measured and none
is claimed.

**Verification targets.** The claims above are verified on the host and on two
representative bare-metal targets, `thumbv7em-none-eabi` (ARM Cortex-M4/M7)
and `riscv32imac-unknown-none-elf`, including a nightly core-only sysroot build
on both. Every other Rust target, and Xtensa in particular, is unproven and
unclaimed.

### Measured code-size (non-normative)

A reproducible recipe records compiler-object `.text` totals for four named,
single-pairing consumers. It is not a guarantee, not total flash, and not
WCET. The committed snapshot is
[`docs/code-size-snapshot.txt`](https://github.com/photon-circus/ph-surfaces/blob/v0.1.0/docs/code-size-snapshot.txt).

```sh
cargo xtask code-size
```

- Toolchain: pinned 1.94.0 from `rust-toolchain.toml`, not nightly
- Targets: `thumbv7em-none-eabi`, `riscv32imac-unknown-none-elf`
- Profile: `opt-level = "s"`, `lto = false`, `codegen-units = 1`,
  `panic = "abort"`, `debug = false`
- Tool: `llvm-nm --demangle --print-size` from `llvm-tools-preview`; each line
  totals the compiler-object `.text` emitted for one pairing and its named
  `ph_eval_*` wrapper, not whole-binary flash
- Pairings: default Binary×Binary elevation 5×4; Linear×Linear 3×2;
  Uniform×Uniform 2×2; mixed `BucketedAxis<17, 8>` × `UniformAxis<9, 0, 200>`
- `ph_interp_kernel`: the shared scalar interpolation (`ph_surfaces::interp`),
  measured from the ph-surfaces rlib. It is non-generic, so it is absent from
  the per-pairing objects above and is paid once per firmware, not per pairing

Compiler, linker, and `llvm-tools-preview` versions can move these numbers.
Re-run `cargo xtask code-size --write` and commit the snapshot when they do.
The `code size snapshot` CI check compares the measured sizes (the `#` header
is provenance, refreshed by `--write`, so a toolchain bump alone does not
fail the gate) and returns SKIP if either target or `llvm-tools-preview` is
missing.

The instruction streams behind those totals are committed alongside the
sizes: `cargo xtask asm --write` disassembles the same four pairing objects
plus the interp kernel with `llvm-objdump -d -r --demangle` into
[`docs/asm-snapshot-thumbv7em-none-eabi.txt`](https://github.com/photon-circus/ph-surfaces/blob/v0.1.0/docs/asm-snapshot-thumbv7em-none-eabi.txt)
and
[`docs/asm-snapshot-riscv32imac-unknown-none-elf.txt`](https://github.com/photon-circus/ph-surfaces/blob/v0.1.0/docs/asm-snapshot-riscv32imac-unknown-none-elf.txt).
They are informational, not a gate: review them when a branch on the hot
path, a new library call (the 64-bit rounding division lowers to
`__aeabi_ldivmod` on ARM and `__divdi3` on RISC-V), or a
compiler-retained bounds check matters to your target.

## Repository classification

These GitHub fields must agree with the manifest and this README.

| Field | Value |
| --- | --- |
| Custom property `Lifecycle` | `Active` |
| Custom property `Domain` | `Libraries` |
| Topics | `rust`, `embedded`, `no-std`, `no-alloc`, `interpolation` |

## How it is verified

The canonical entry point is local:

```sh
cargo xtask ci
```

Add `--coverage` to run the host tests under `cargo-llvm-cov` and print a
source-coverage summary. Coverage is diagnostic rather than a percentage gate;
if requested without `cargo-llvm-cov` installed, it is reported as `SKIP`.

That script reports each check as `PASS`, `FAIL`, or `SKIP`. A skipped check is
not a passed check. Release evidence sets an exact nightly and forbids skips:

```sh
cargo xtask ci --profile release --nightly nightly-2026-08-08
```

Strict mode also requires a clean Git worktree, validates the package's VCS
commit, and prints a verified archive SHA-256. Local `cargo xtask ci` is
authoritative. It gates:

- formatting, debug and release host tests and doctests (including every code
  block in this README), every Cargo example run as an assertion harness,
  clippy with warnings denied on the host and on both embedded targets, and
  rustdoc with warnings denied and `missing_docs` denied on every public
  item;
- unconditional `#![no_std]`: no `[features]` table, no `cfg_attr` on the
  attribute, and no feature-gated code anywhere in `src/`;
- an integer-only, core-only, `unsafe`-free runtime, by grepping code paths —
  including wide-integer confinement: 64-bit arithmetic only inside the
  `src/interp.rs` kernel, 128-bit integers only in test oracles;
- no `ph-curves` in any form — the manifest text (normal, optional,
  target-specific, development, build, path, Git, `[patch]`, `[replace]`),
  `Cargo.lock`, `cargo metadata --all-features`, and `cargo deny` all reject
  the name;
- the manifest floor (version, licence, edition, MSRV, empty dependency
  tables);
- the package: the exact packaged file set (no agent notes, changelog, CI,
  deny, toolchain, script, or `docs/` material), a `cargo package` build of
  the artifact, the artifact's own rustdoc, doctests — README blocks
  included — and Cargo examples built from the unpacked package, and a fresh
  downstream `#![no_std]` consumer that declares the firmware quickstart,
  Uniform, and mixed fixtures together with both example maps above and all
  sixteen X/Y strategy pairings, is built and tested against the unpacked
  package on the host, and is built for both embedded targets — ordinarily and
  against a core-only sysroot, which is what proves the pairings themselves are
  allocation-free;
- a guard self-test (`tools/xtask/tests/mutation.rs`) that mutates a copy of the
  tree — feature-conditional `no_std`, an allocator path, a `ph-curves`
  dependency — and requires the matching guard to fail;
- a code-size snapshot (`cargo xtask code-size`) that records single-pairing
  compiler-object `.text` totals plus the shared interp-kernel size on both
  embedded targets and compares the measured sizes against
  `docs/code-size-snapshot.txt` (the header is provenance, not part of the
  gate); the check reports `SKIP` if either target or `llvm-tools-preview` is
  missing;
- a full-history secret scan (`gitleaks git . --redact`), reported `SKIP`
  where the tool is absent and required in release evidence;
- representative bare-metal builds on ARM (`thumbv7em-none-eabi`) and RISC-V
  (`riscv32imac-unknown-none-elf`) with the pinned toolchain;
- the no-allocation proof: nightly `-Z build-std=core` builds of the same two
  targets against a sysroot containing only `core`. A plain `--target` build
  is not that proof, because bare-metal `rust-std` sysroots still ship `alloc`.

Not proven, and not claimed: every Rust target, Xtensa, cycle counts,
code-size ceilings, or hard real-time WCET. The committed code-size snapshot
is labelled non-normative and is not a guarantee, not total flash, and not
WCET.

`cargo test` runs the crate's unit tests, its doctests, and the black-box
conformance suite in `tests/conformance/`. The suite exercises only the public
API and compares against an independent `i128` reference with a linear scan of
fixture knot arrays and remainder-based rounding; `strategies.rs` extends that
evidence across every applicable Linear/Binary/Uniform/Bucketed pairing.
Small declared domains are enumerated
exhaustively, the full `u16 × u16` range is sampled with a stated rule and is
not claimed exhaustive, and the locked X-then-Y fixture (axes `[0, 2]`, rows
`[[0, 0], [1, 3]]`, input `(1, 1)` → `1`) is retained. The two example maps
above are the suite's `ELEVATION` and `CORRECTION` fixtures and demonstrate
shape and rounding behaviour only; they claim nothing about any sensor, vendor,
or measurement accuracy.

Hosted GitHub Actions run a bounded contributor subset: least privilege, a
job timeout, cancellation of superseded runs, SHA-pinned actions, and one
job as the aggregate status. That subset still skips `deny` and the nightly
core-only proofs. Local `cargo xtask ci` is the complete gate. A skipped
hosted check is not a pass.

## Contributing and releases

Contributions are welcome under the repository-specific
[`CONTRIBUTING.md`](https://github.com/photon-circus/ph-surfaces/blob/v0.1.0/CONTRIBUTING.md)
and
[`CODE_OF_CONDUCT.md`](https://github.com/photon-circus/ph-surfaces/blob/v0.1.0/CODE_OF_CONDUCT.md).
Never put vulnerability details in a public issue; follow
[`SECURITY.md`](https://github.com/photon-circus/ph-surfaces/blob/v0.1.0/SECURITY.md).

Releases follow
[`RELEASING.md`](https://github.com/photon-circus/ph-surfaces/blob/v0.1.0/RELEASING.md).
A pull request approval does not by itself authorize a tag, crates.io
upload, yank, or GitHub Release. The changelog is
[`CHANGELOG.md`](https://github.com/photon-circus/ph-surfaces/blob/v0.1.0/CHANGELOG.md).