From 58f692096b13c1b74bb2b80dc08f9f640d89d9f6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 25 Aug 2026 18:07:00 +0000 Subject: [PATCH 1/4] Emit static tables and gate generated source against drift. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ph-surfaces-bake --emit-rust writes Binary×Binary (optional Bucketed) static tables, PAYLOAD_BYTES, and MAX_ERR_LSB to stdout. cargo xtask generate places the baker-owned fixture; CI re-renders it in memory and compares through text::read_text. Co-authored-by: Steven Giacomelli --- AGENTS.md | 6 +- CHANGELOG.md | 8 + Cargo.lock | 1 + crates/surfaces-bake/README.md | 13 +- crates/surfaces-bake/generated/rounding.rs | 22 ++ crates/surfaces-bake/src/emit.rs | 290 +++++++++++++++++++++ crates/surfaces-bake/src/lib.rs | 13 +- crates/surfaces-bake/src/main.rs | 178 ++++++++++++- xtask/Cargo.toml | 1 + xtask/config.ron | 3 + xtask/src/checks/generated.rs | 83 ++++++ xtask/src/checks/mod.rs | 2 + xtask/src/config.rs | 7 +- xtask/src/main.rs | 14 + xtask/tests/mutation.rs | 33 ++- 15 files changed, 655 insertions(+), 19 deletions(-) create mode 100644 crates/surfaces-bake/generated/rounding.rs create mode 100644 crates/surfaces-bake/src/emit.rs create mode 100644 xtask/src/checks/generated.rs diff --git a/AGENTS.md b/AGENTS.md index 99342ef..9efae54 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -180,7 +180,10 @@ generated output directories. The first figure (1,500) was a guess before the baker existed. The cap exists to prevent unbounded growth, not to freeze a constant: raise the declared number in `config.ron` (and the mutation fixture that names it) when a real kernel needs room. Exceeding the current -declared cap without that bump is a FAIL. +declared cap without that bump is a FAIL. `cargo xtask generate` writes the +checked-in baker generated module declared by `baker.generated`; the +`generated source` check re-renders it in memory and compares through +`text::read_text`. `cargo xtask ci --profile release --nightly nightly-YYYY-MM-DD` is the release-evidence mode: every check must run, a would-be `SKIP` is recorded as @@ -215,6 +218,7 @@ bounded hosted subset is not the complete release evidence. | Version or crate `publish` setting | Release process (`RELEASING.md`): root `Cargo.lock`, changelog heading date, `package.version` and `package.manifest.publish` in `xtask/config.ron`, and GitHub `Lifecycle`. README and crate-doc status already describe published `0.1.0` Active; do not revert them to incubating. Pin unpackaged guide URLs (README, `crates/surfaces/src/lib.rs`, `crates/surfaces/examples/*.rs`) from `main` to the release tag. | | New packaged file | `include` in `crates/surfaces/Cargo.toml`, and crate-relative `package.files` in `xtask/config.ron` | | New baker packaged file | `include` in `crates/surfaces-bake/Cargo.toml`, and crate-relative `baker.files` in `xtask/config.ron` | +| Baker generated artifact | path in `baker.generated` (`xtask/config.ron`); rewrite with `cargo xtask generate`. Not a runtime packaged file. | | New guard in `xtask` | An `Action` variant and required-handler entry in `xtask/src/config.rs`, dispatch in `xtask/src/checks/mod.rs`, a row in `xtask/config.ron`, and a mutation case in `xtask/tests/mutation.rs` showing it fails | | Storage or cost wording | `crates/surfaces/src/lib.rs` crate docs, `crates/surfaces/src/surface.rs` / `evaluate.rs` / `axis/` item docs, `README.md` "Resource accounting and cost" | | New or changed axis strategy | `crates/surfaces/src/lib.rs` re-exports and § Contract, `README.md` "Per-axis lookup strategies" table, the sixteen-pairing consumer in `tools/consumer/src/lib.rs`, `docs/v0.1-traceability.md` | diff --git a/CHANGELOG.md b/CHANGELOG.md index 7fe4a1a..0290ebd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ ### Added +- Host-side baker Rust emission in `ph-surfaces-bake`: `--emit-rust` writes + deterministic static knot arrays, a row-major `values[y][x]` grid, + `PAYLOAD_BYTES` for the selected pairing, and `MAX_ERR_LSB` to stdout. + `cargo xtask generate` writes the baker-owned checked-in fixture; the + `generated source` check re-renders it in memory and fails with + `run cargo xtask generate` when it drifts. The bound is an i32 value LSB + of sample deviation, not a device or accuracy claim. This is not a + runtime API change. - Host-side baker quantization in `ph-surfaces-bake`: fill each declared grid node from on-knot samples, apply the caller-stated scale with round-to-nearest (exact half-way away from zero; values just below a diff --git a/Cargo.lock b/Cargo.lock index 0eff043..0240215 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -658,6 +658,7 @@ dependencies = [ "cargo_metadata", "clap", "hex", + "ph-surfaces-bake", "proc-macro2", "ron", "serde", diff --git a/crates/surfaces-bake/README.md b/crates/surfaces-bake/README.md index dbb14d2..e994736 100644 --- a/crates/surfaces-bake/README.md +++ b/crates/surfaces-bake/README.md @@ -37,11 +37,18 @@ The public host API is `BakeInput::quantize` → `QuantizedTable`, plus ph-surfaces-bake --help ph-surfaces-bake --samples points.txt --x-knots 0,10 --y-knots 0,5 --scale 1 ph-surfaces-bake --samples points.txt --x-uniform 0,10,3 --y-uniform 0,5,3 --scale 1 +ph-surfaces-bake --emit-rust --samples points.txt --x-knots 0,10 --y-knots 0,5 --scale 1 ``` -Rust emission and goldens are later issues. The baker may take reviewed host -crates for exact residual arithmetic. A declared implementation-line budget -keeps it from growing without bound. +`--emit-rust` writes Rust source on stdout. The baker does not own the +destination path; `cargo xtask generate` places the checked-in copy. The +emitted `MAX_ERR_LSB` is an i32 value LSB: deviation between the supplied +samples and the table built from them. It is not a device, accuracy, timing, +or flash claim. `--emit-golden` is not implemented yet. + +The baker may take reviewed host crates for exact residual arithmetic. A +declared implementation-line budget keeps it from growing without bound. The +generated fixture under `generated/` is not part of the packaged crate. ## License diff --git a/crates/surfaces-bake/generated/rounding.rs b/crates/surfaces-bake/generated/rounding.rs new file mode 100644 index 0000000..d502bd4 --- /dev/null +++ b/crates/surfaces-bake/generated/rounding.rs @@ -0,0 +1,22 @@ +// @generated by `cargo xtask generate`; do not edit by hand. + +use ph_surfaces::BilinearSurface; + +static X: [u16; 2] = [0, 2]; +static Y: [u16; 2] = [0, 2]; +#[rustfmt::skip] +static VALUES: [[i32; 2]; 2] = [ + [0, 1], + [1, 2], +]; + +/// Pairing: BinaryAxis × BinaryAxis. +/// Referenced element payload, not total RAM, flash, binary, or linker cost. +pub const PAYLOAD_BYTES: usize = 24; + +pub static SURFACE: BilinearSurface<2, 2> = + BilinearSurface::new(&X, &Y, &VALUES); + +// MAX_ERR_LSB is an i32 value LSB: deviation from supplied samples, not +// device, accuracy, timing, or flash. +pub const MAX_ERR_LSB: i32 = 1; diff --git a/crates/surfaces-bake/src/emit.rs b/crates/surfaces-bake/src/emit.rs new file mode 100644 index 0000000..3dce7c6 --- /dev/null +++ b/crates/surfaces-bake/src/emit.rs @@ -0,0 +1,290 @@ +//! Deterministic Rust table emission. +//! +//! The baker prints this source on stdout; `cargo xtask generate` places the +//! checked-in copy. Identical ingest and quantize input produces identical +//! bytes: `\n` only, no timestamps, no host paths. + +use std::fmt::Write as _; + +use crate::quantize::{QuantizedTable, emit_max_err_lsb}; +use crate::{Axis, BakeInput}; + +/// Per-axis lookup named in emitted source. This selects tokens, not a grid. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum EmitAxis { + /// Default BinaryAxis: stored knots, no auxiliary index. + Binary, + /// BucketedAxis with `buckets` entries. Generated source calls + /// `ph_surfaces::bucket_index`; this crate does not depend on `ph-surfaces`. + Bucketed { + /// Bucket table length `B`. + buckets: usize, + }, +} + +impl EmitAxis { + fn is_binary(self) -> bool { + matches!(self, Self::Binary) + } + + fn name(self) -> &'static str { + match self { + Self::Binary => "BinaryAxis", + Self::Bucketed { .. } => "BucketedAxis", + } + } + + fn index_bytes(self) -> usize { + match self { + Self::Binary => 0, + Self::Bucketed { buckets } => 2 * buckets, + } + } +} + +/// Samples for the checked-in drift-gate fixture (S4 runtime-rounding 2×2). +const CHECKED_IN_SAMPLES: &str = "0 0 0\n2 0 1\n0 2 1\n2 2 2\n1 1 1\n"; + +/// Render BinaryAxis × BinaryAxis static tables from a quantized grid. +#[must_use] +pub fn emit_rust(table: &QuantizedTable) -> String { + emit_rust_with(table, EmitAxis::Binary, EmitAxis::Binary) +} + +/// Render static tables for an explicit pairing. +/// +/// `PAYLOAD_BYTES` is the referenced element payload using the public +/// runtime formula, not total RAM, flash, binary, or linker cost. +#[must_use] +pub fn emit_rust_with(table: &QuantizedTable, x: EmitAxis, y: EmitAxis) -> String { + let nx = table.x.len(); + let ny = table.y.len(); + let mut src = String::from("// @generated by `cargo xtask generate`; do not edit by hand.\n\n"); + push_uses(&mut src, x, y); + src.push('\n'); + let _ = writeln!(src, "static X: [u16; {nx}] = {};", list(&table.x)); + let _ = writeln!(src, "static Y: [u16; {ny}] = {};", list(&table.y)); + src.push_str("#[rustfmt::skip]\n"); + let _ = writeln!( + src, + "static VALUES: [[i32; {nx}]; {ny}] = {};", + grid(&table.values) + ); + push_indexes(&mut src, x, y); + let payload = 2 * nx + x.index_bytes() + 2 * ny + y.index_bytes() + 4 * nx * ny; + let _ = writeln!(src, "\n/// Pairing: {} × {}.", x.name(), y.name()); + src.push_str("/// Referenced element payload, not total RAM, flash, binary, or linker cost.\n"); + let _ = writeln!(src, "pub const PAYLOAD_BYTES: usize = {payload};\n"); + push_surface(&mut src, nx, ny, x, y); + src.push_str( + "\n// MAX_ERR_LSB is an i32 value LSB: deviation from supplied samples, not\n\ + // device, accuracy, timing, or flash.\n", + ); + src.push_str(&emit_max_err_lsb(table.max_err_lsb)); + src +} + +/// Source for the checked-in drift-gate fixture. +/// +/// S4 rounding example: knots `[0, 2]`, grid `[[0, 1], [1, 2]]`, off-knot +/// sample `(1, 1, 1)`. Host bilinear is 1; runtime X-then-Y is 2; +/// `MAX_ERR_LSB` is 1. +#[must_use] +pub fn checked_in_source() -> String { + emit_rust( + &checked_in_input() + .quantize() + .expect("checked-in fixture must quantize"), + ) +} + +fn checked_in_input() -> BakeInput { + BakeInput::parse( + CHECKED_IN_SAMPLES, + Axis::knots(vec![0, 2]), + Axis::knots(vec![0, 2]), + 1.0, + ) + .expect("checked-in fixture must ingest") +} + +fn push_uses(src: &mut String, x: EmitAxis, y: EmitAxis) { + src.push_str("use ph_surfaces::BilinearSurface;\n"); + if x.is_binary() && y.is_binary() { + return; + } + if x.is_binary() || y.is_binary() { + src.push_str("use ph_surfaces::BinaryAxis;\n"); + } + src.push_str("use ph_surfaces::BucketedAxis;\n"); + src.push_str("use ph_surfaces::bucket_index;\n"); +} + +fn push_indexes(src: &mut String, x: EmitAxis, y: EmitAxis) { + if let EmitAxis::Bucketed { buckets } = x { + let _ = writeln!(src, "static X_INDEX: [u16; {buckets}] = bucket_index(&X);"); + } + if let EmitAxis::Bucketed { buckets } = y { + let _ = writeln!(src, "static Y_INDEX: [u16; {buckets}] = bucket_index(&Y);"); + } +} + +fn push_surface(src: &mut String, nx: usize, ny: usize, x: EmitAxis, y: EmitAxis) { + if x.is_binary() && y.is_binary() { + let _ = writeln!( + src, + "pub static SURFACE: BilinearSurface<{nx}, {ny}> =\n BilinearSurface::new(&X, &Y, &VALUES);" + ); + return; + } + let _ = writeln!( + src, + "pub static SURFACE: BilinearSurface<{nx}, {ny}, {}, {}> =\n BilinearSurface::from_axes(\n {},\n {},\n &VALUES,\n );", + axis_ty(x, nx), + axis_ty(y, ny), + axis_ctor(x, "X"), + axis_ctor(y, "Y"), + ); +} + +fn axis_ty(axis: EmitAxis, n: usize) -> String { + match axis { + EmitAxis::Binary => format!("BinaryAxis<{n}>"), + EmitAxis::Bucketed { buckets } => format!("BucketedAxis<{n}, {buckets}>"), + } +} + +fn axis_ctor(axis: EmitAxis, name: &str) -> String { + match axis { + EmitAxis::Binary => format!("BinaryAxis::new(&{name})"), + EmitAxis::Bucketed { .. } => format!("BucketedAxis::new(&{name}, &{name}_INDEX)"), + } +} + +fn list(values: &[T]) -> String { + let mut out = String::from("["); + for (i, v) in values.iter().enumerate() { + if i > 0 { + out.push_str(", "); + } + let _ = write!(out, "{v}"); + } + out.push(']'); + out +} + +fn grid(values: &[Vec]) -> String { + let mut out = String::from("[\n"); + for row in values { + out.push_str(" "); + out.push_str(&list(row)); + out.push_str(",\n"); + } + out.push(']'); + out +} + +#[cfg(test)] +mod tests { + use super::{EmitAxis, checked_in_source, emit_rust, emit_rust_with}; + use crate::{Axis, BakeInput}; + use ph_surfaces::BilinearSurface; + + mod fixture { + include!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/generated/rounding.rs" + )); + } + + fn corners() -> crate::quantize::QuantizedTable { + BakeInput::parse( + "0 0 1.5\n10 0 2.5\n0 5 3.5\n10 5 4.5\n", + Axis::knots(vec![0, 10]), + Axis::knots(vec![0, 5]), + 1000.0, + ) + .unwrap() + .quantize() + .unwrap() + } + + #[test] + fn binary_emission_is_byte_stable_and_names_the_bound_unit() { + let table = corners(); + let a = emit_rust(&table); + let b = emit_rust(&table); + assert_eq!(a, b); + assert!(!a.contains('\r')); + assert!(!a.contains('\\')); + assert!(a.contains("static X: [u16; 2] = [0, 10];")); + assert!(a.contains("static Y: [u16; 2] = [0, 5];")); + assert!(a.contains("static VALUES: [[i32; 2]; 2] = [")); + assert!(a.contains("[1500, 2500],")); + assert!(a.contains("[3500, 4500],")); + assert!(a.contains("Pairing: BinaryAxis × BinaryAxis.")); + assert!(a.contains("pub const PAYLOAD_BYTES: usize = 24;")); + assert!(a.contains("BilinearSurface::new(&X, &Y, &VALUES)")); + assert!(a.contains(&crate::emit_max_err_lsb(table.max_err_lsb))); + assert!(a.contains("i32 value LSB")); + assert_eq!( + 24, + BilinearSurface::<2, 2>::PAYLOAD_BYTES, + "emitted PAYLOAD_BYTES must match the public runtime constant" + ); + } + + #[test] + fn checked_in_fixture_compiles_against_the_runtime() { + assert_eq!( + fixture::PAYLOAD_BYTES, + BilinearSurface::<2, 2>::PAYLOAD_BYTES + ); + assert_eq!(fixture::SURFACE.evaluate(1, 1), Ok(2)); + assert_eq!(fixture::MAX_ERR_LSB, 1); + } + + #[test] + fn checked_in_fixture_is_the_s4_rounding_example() { + let src = checked_in_source(); + assert_eq!(src, checked_in_source()); + assert!(src.contains("pub const MAX_ERR_LSB: i32 = 1;")); + assert!(src.contains("pub const PAYLOAD_BYTES: usize = 24;")); + assert!(src.contains("[0, 1],")); + assert!(src.contains("[1, 2],")); + assert!(!src.contains('\r')); + } + + #[test] + fn bucketed_emission_calls_the_public_bucket_index() { + let table = corners(); + let src = emit_rust_with(&table, EmitAxis::Bucketed { buckets: 2 }, EmitAxis::Binary); + assert!(src.contains("use ph_surfaces::bucket_index;")); + assert!(src.contains("static X_INDEX: [u16; 2] = bucket_index(&X);")); + assert!(!src.contains("Y_INDEX")); + assert!(src.contains("Pairing: BucketedAxis × BinaryAxis.")); + assert!(src.contains("pub const PAYLOAD_BYTES: usize = 28;")); + assert!(src.contains("BucketedAxis<2, 2>")); + assert!(src.contains("BinaryAxis<2>")); + assert!(src.contains("BucketedAxis::new(&X, &X_INDEX)")); + assert!(src.contains("BinaryAxis::new(&Y)")); + assert_eq!( + src, + emit_rust_with(&table, EmitAxis::Bucketed { buckets: 2 }, EmitAxis::Binary) + ); + } + + #[test] + fn both_axes_bucketed_names_both_indexes() { + let table = corners(); + let src = emit_rust_with( + &table, + EmitAxis::Bucketed { buckets: 2 }, + EmitAxis::Bucketed { buckets: 2 }, + ); + assert!(src.contains("static X_INDEX: [u16; 2] = bucket_index(&X);")); + assert!(src.contains("static Y_INDEX: [u16; 2] = bucket_index(&Y);")); + assert!(!src.contains("BinaryAxis")); + assert!(src.contains("pub const PAYLOAD_BYTES: usize = 32;")); + } +} diff --git a/crates/surfaces-bake/src/lib.rs b/crates/surfaces-bake/src/lib.rs index 72ed24b..c300b15 100644 --- a/crates/surfaces-bake/src/lib.rs +++ b/crates/surfaces-bake/src/lib.rs @@ -12,7 +12,7 @@ //! `i32` grid. The baker does not choose knots or parse expressions. //! //! ``` -//! use ph_surfaces_bake::{emit_max_err_lsb, Axis, BakeInput, Sample}; +//! use ph_surfaces_bake::{emit_max_err_lsb, emit_rust, Axis, BakeInput, Sample}; //! //! let samples = vec![ //! Sample::new(0.0, 0.0, 1.5), @@ -36,6 +36,9 @@ //! emit_max_err_lsb(table.max_err_lsb), //! "pub const MAX_ERR_LSB: i32 = 0;\n" //! ); +//! let src = emit_rust(&table); +//! assert!(src.contains("pub const PAYLOAD_BYTES: usize = 24;")); +//! assert!(src.contains("pub const MAX_ERR_LSB: i32 = 0;")); //! ``` //! //! `MAX_ERR_LSB` is an i32 value LSB: `ceil` of the exact rational @@ -47,18 +50,22 @@ //! includes the runtime-rounded X-then-Y path. It is not a typical error, and //! not a device, vendor, sensor, calibration, or accuracy claim. //! -//! Rust emission and the checked-in generated-source drift gate: issue #41. -//! Frozen golden vectors: issue #42. +//! [`emit_rust`] writes BinaryAxis × BinaryAxis static tables, `PAYLOAD_BYTES`, +//! and `MAX_ERR_LSB` as source text. The baker prints that text on stdout; +//! `cargo xtask generate` places the checked-in copy. Frozen golden vectors: +//! issue #42. #![deny(missing_docs)] #![forbid(unsafe_code)] mod bound; +mod emit; mod error; mod grid; mod quantize; mod samples; +pub use emit::{EmitAxis, checked_in_source, emit_rust, emit_rust_with}; pub use error::{AxisName, BakeError, SampleField}; pub use grid::{Axis, MAX_GRID_CELLS}; pub use quantize::{QuantizedTable, emit_max_err_lsb}; diff --git a/crates/surfaces-bake/src/main.rs b/crates/surfaces-bake/src/main.rs index 73c0fca..034c0cc 100644 --- a/crates/surfaces-bake/src/main.rs +++ b/crates/surfaces-bake/src/main.rs @@ -8,7 +8,7 @@ use std::path::PathBuf; use std::process::ExitCode; -use ph_surfaces_bake::{Axis, BakeError, BakeInput, QuantizedTable}; +use ph_surfaces_bake::{Axis, BakeError, BakeInput, EmitAxis, QuantizedTable, emit_rust_with}; fn main() -> ExitCode { let args: Vec = std::env::args().skip(1).collect(); @@ -28,7 +28,6 @@ fn dispatch(args: &[String]) -> Result { match args { [] => Err((2, usage_error("missing args"))), [a] if a == "--help" || a == "-h" => Ok(help()), - [a] if a == "--emit-rust" => Err((1, not_implemented("--emit-rust"))), [a] if a == "--emit-golden" => Err((1, not_implemented("--emit-golden"))), _ => ingest(args), } @@ -47,6 +46,9 @@ fn ingest(args: &[String]) -> Result { })?; match BakeInput::parse(&text, parsed.x, parsed.y, parsed.scale) { Ok(input) => match input.quantize() { + Ok(table) if parsed.emit_rust => { + Ok(emit_rust_with(&table, parsed.x_axis, parsed.y_axis)) + } Ok(table) => Ok(summary(&input, &table)), Err(error) => Err((1, bake_error(error))), }, @@ -60,6 +62,9 @@ struct IngestArgs { x: Axis, y: Axis, scale: f64, + emit_rust: bool, + x_axis: EmitAxis, + y_axis: EmitAxis, } fn parse_ingest(args: &[String]) -> Result { @@ -69,6 +74,9 @@ fn parse_ingest(args: &[String]) -> Result { let mut x_uniform = None; let mut y_uniform = None; let mut scale = None; + let mut emit_rust = false; + let mut x_buckets = None; + let mut y_buckets = None; let mut index = 0; while index < args.len() { match args[index].as_str() { @@ -90,6 +98,21 @@ fn parse_ingest(args: &[String]) -> Result { "--scale" => { scale = Some(parse_scale(take_value(args, &mut index, "--scale")?)?); } + "--emit-rust" => emit_rust = true, + "--x-bucketed" => { + x_buckets = Some(parse_buckets(take_value( + args, + &mut index, + "--x-bucketed", + )?)?); + } + "--y-bucketed" => { + y_buckets = Some(parse_buckets(take_value( + args, + &mut index, + "--y-bucketed", + )?)?); + } _ => return Err("unknown args".to_string()), } index += 1; @@ -98,14 +121,38 @@ fn parse_ingest(args: &[String]) -> Result { let x = axis_from_flags("--x-knots", x_knots, "--x-uniform", x_uniform)?; let y = axis_from_flags("--y-knots", y_knots, "--y-uniform", y_uniform)?; let scale = scale.ok_or_else(|| "missing --scale".to_string())?; + if (x_buckets.is_some() || y_buckets.is_some()) && !emit_rust { + return Err("--x-bucketed/--y-bucketed require --emit-rust".to_string()); + } Ok(IngestArgs { samples, x, y, scale, + emit_rust, + x_axis: emit_axis(x_buckets), + y_axis: emit_axis(y_buckets), }) } +fn emit_axis(buckets: Option) -> EmitAxis { + match buckets { + Some(buckets) => EmitAxis::Bucketed { buckets }, + None => EmitAxis::Binary, + } +} + +fn parse_buckets(raw: &str) -> Result { + let buckets: usize = raw + .parse() + .map_err(|_| "invalid bucket count".to_string())?; + if buckets == 0 { + Err("invalid bucket count".to_string()) + } else { + Ok(buckets) + } +} + fn take_value<'a>(args: &'a [String], index: &mut usize, flag: &str) -> Result<&'a str, String> { *index += 1; match args.get(*index) { @@ -209,7 +256,7 @@ fn help() -> String { ph-surfaces-bake --help\n\ ph-surfaces-bake --samples PATH --x-knots LIST --y-knots LIST --scale N\n\ ph-surfaces-bake --samples PATH --x-uniform ORIGIN,STEP,COUNT --y-uniform ORIGIN,STEP,COUNT --scale N\n\ - ph-surfaces-bake --emit-rust\n\ + ph-surfaces-bake --emit-rust --samples PATH --x-knots LIST --y-knots LIST --scale N\n\ ph-surfaces-bake --emit-golden\n\ \n\ --samples delimited text: one X Y value point per line (whitespace and/or comma)\n\ @@ -218,7 +265,9 @@ fn help() -> String { --x-uniform X axis as origin,step,count (runtime UniformAxis)\n\ --y-uniform Y axis as origin,step,count (runtime UniformAxis)\n\ --scale output scale for the i32 value domain (applied at quantize)\n\ - --emit-rust not implemented yet\n\ + --emit-rust write static Rust tables to stdout (BinaryAxis × BinaryAxis)\n\ + --x-bucketed emit X as BucketedAxis with this many buckets (requires --emit-rust)\n\ + --y-bucketed emit Y as BucketedAxis with this many buckets (requires --emit-rust)\n\ --emit-golden not implemented yet\n\ \n\ Each axis takes either a knot list or a uniform descriptor, never both.\n\ @@ -241,14 +290,15 @@ fn bake_error(error: BakeError) -> String { #[cfg(test)] mod tests { use super::{dispatch, parse_ingest}; - use ph_surfaces_bake::Axis; + use ph_surfaces_bake::{Axis, EmitAxis, emit_rust}; #[test] fn help_is_available() { let text = dispatch(&["--help".to_string()]).unwrap(); assert!(text.contains("--samples")); assert!(text.contains("--scale")); - assert!(text.contains("not implemented yet")); + assert!(text.contains("write static Rust tables to stdout")); + assert!(text.contains("--emit-golden not implemented yet")); assert_eq!(dispatch(&["-h".to_string()]).unwrap(), text); } @@ -260,10 +310,14 @@ mod tests { } #[test] - fn emit_stubs_are_not_implemented_yet() { - let rust = dispatch(&["--emit-rust".to_string()]).unwrap_err(); - assert_eq!(rust.0, 1); - assert!(rust.1.contains("not implemented yet")); + fn emit_rust_alone_needs_ingest_flags() { + let err = dispatch(&["--emit-rust".to_string()]).unwrap_err(); + assert_eq!(err.0, 2); + assert!(err.1.contains("missing --samples")); + } + + #[test] + fn emit_golden_is_not_implemented_yet() { let golden = dispatch(&["--emit-golden".to_string()]).unwrap_err(); assert_eq!(golden.0, 1); assert!(golden.1.contains("not implemented yet")); @@ -293,6 +347,9 @@ mod tests { assert_eq!(parsed.x, Axis::knots(vec![0, 10, 20])); assert_eq!(parsed.y, Axis::knots(vec![0, 5])); assert_eq!(parsed.scale, 1000.0); + assert!(!parsed.emit_rust); + assert_eq!(parsed.x_axis, EmitAxis::Binary); + assert_eq!(parsed.y_axis, EmitAxis::Binary); } #[test] @@ -399,4 +456,105 @@ mod tests { .contains("x coordinate 11 is above the x axis maximum 10") ); } + + fn emit_args(path: &std::path::Path) -> [String; 9] { + [ + "--emit-rust", + "--samples", + path.to_str().unwrap(), + "--x-knots", + "0,2", + "--y-knots", + "0,2", + "--scale", + "1", + ] + .map(String::from) + } + + #[test] + fn emit_rust_writes_the_same_bytes_as_the_library() { + let path = std::env::temp_dir().join("ph-surfaces-bake-s5-emit.txt"); + std::fs::write(&path, "0 0 0\n2 0 1\n0 2 1\n2 2 2\n1 1 1\n").unwrap(); + let args = emit_args(&path); + let a = dispatch(&args).unwrap(); + let b = dispatch(&args).unwrap(); + assert_eq!(a, b); + assert!(!a.contains('\r')); + let table = ph_surfaces_bake::BakeInput::parse( + "0 0 0\n2 0 1\n0 2 1\n2 2 2\n1 1 1\n", + Axis::knots(vec![0, 2]), + Axis::knots(vec![0, 2]), + 1.0, + ) + .unwrap() + .quantize() + .unwrap(); + assert_eq!(a, emit_rust(&table)); + assert!(a.contains("pub const MAX_ERR_LSB: i32 = 1;")); + assert!(a.contains("pub const PAYLOAD_BYTES: usize = 24;")); + assert!(a.contains("deviation from supplied samples")); + } + + #[test] + fn emit_rust_flag_order_does_not_change_bytes() { + let path = std::env::temp_dir().join("ph-surfaces-bake-s5-emit-order.txt"); + std::fs::write(&path, "0 0 0\n2 0 1\n0 2 1\n2 2 2\n1 1 1\n").unwrap(); + let first = dispatch(&emit_args(&path)).unwrap(); + let rotated = [ + "--samples", + path.to_str().unwrap(), + "--scale", + "1", + "--y-knots", + "0,2", + "--emit-rust", + "--x-knots", + "0,2", + ] + .map(String::from); + assert_eq!(first, dispatch(&rotated).unwrap()); + } + + #[test] + fn emit_rust_bucketed_flag_calls_bucket_index() { + let path = std::env::temp_dir().join("ph-surfaces-bake-s5-bucketed.txt"); + std::fs::write(&path, "0 0 0\n10 0 1\n0 5 2\n10 5 3\n").unwrap(); + let args = [ + "--samples", + path.to_str().unwrap(), + "--x-knots", + "0,10", + "--y-knots", + "0,5", + "--scale", + "1", + "--emit-rust", + "--x-bucketed", + "2", + ] + .map(String::from); + let out = dispatch(&args).unwrap(); + assert!(out.contains("bucket_index(&X)")); + assert!(out.contains("Pairing: BucketedAxis × BinaryAxis.")); + assert!(!out.contains("Y_INDEX")); + } + + #[test] + fn bucketed_without_emit_rust_is_rejected() { + let err = parse_ingest(&[ + "--samples".to_string(), + "p.txt".to_string(), + "--x-knots".to_string(), + "0,10".to_string(), + "--y-knots".to_string(), + "0,5".to_string(), + "--scale".to_string(), + "1".to_string(), + "--x-bucketed".to_string(), + "2".to_string(), + ]) + .unwrap_err(); + assert!(err.contains("require --emit-rust")); + } } diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml index 17a67d1..29383e6 100644 --- a/xtask/Cargo.toml +++ b/xtask/Cargo.toml @@ -34,3 +34,4 @@ syn.workspace = true time.workspace = true toml.workspace = true walkdir.workspace = true +ph-surfaces-bake = { path = "../crates/surfaces-bake", version = "0.1.0" } diff --git a/xtask/config.ron b/xtask/config.ron index 314b2fc..aa438d8 100644 --- a/xtask/config.ron +++ b/xtask/config.ron @@ -66,6 +66,7 @@ baker: ( src: "crates/surfaces-bake/src", max_implementation_lines: 1600, + generated: "crates/surfaces-bake/generated/rounding.rs", files: [ ".cargo_vcs_info.json", "Cargo.lock", @@ -74,6 +75,7 @@ "LICENSE", "README.md", "src/bound.rs", + "src/emit.rs", "src/error.rs", "src/grid.rs", "src/lib.rs", @@ -105,6 +107,7 @@ (name: "no ph-curves", profiles: [Dev, Full, Release], action: NoPhCurves), (name: "baker line budget", profiles: [Dev, Full, Release], action: BakeLineBudget), (name: "baker package", profiles: [Full, Release], action: BakePackage), + (name: "generated source", profiles: [Dev, Full, Release], action: GeneratedSource), (name: "manifest floor", profiles: [Dev, Full, Release], action: ManifestFloor), (name: "publish lock", profiles: [Dev, Full, Release], action: PublishLock), (name: "fmt", profiles: [Dev, Full, Release], action: Fmt), diff --git a/xtask/src/checks/generated.rs b/xtask/src/checks/generated.rs new file mode 100644 index 0000000..1c56a93 --- /dev/null +++ b/xtask/src/checks/generated.rs @@ -0,0 +1,83 @@ +//! Checked-in generated-source regeneration and drift detection. +//! +//! `cargo xtask generate` writes the baker-owned artifact. CI re-renders the +//! same source in memory and compares it to the file on disk through +//! `text::read_text` so a CRLF checkout cannot fail the comparison. A SKIP is +//! not a pass: this check either matches or fails. + +use std::fs; + +use similar::TextDiff; + +use crate::runner::{Ctx, Outcome}; +use crate::text; + +/// Write the checked-in generated module from the baker's fixture renderer. +pub fn write(ctx: &Ctx) -> Result<(), String> { + let relative = &ctx.config.baker.generated; + let path = ctx.path(relative); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|error| format!("could not create {}: {error}", parent.display()))?; + } + fs::write(&path, ph_surfaces_bake::checked_in_source()) + .map_err(|error| format!("could not write {relative}: {error}"))?; + println!("wrote {relative}"); + Ok(()) +} + +/// Re-render the checked-in module in memory and compare it to the file. +pub fn generated_source(ctx: &Ctx) -> Outcome { + let relative = &ctx.config.baker.generated; + let expected = ph_surfaces_bake::checked_in_source(); + let path = ctx.path(relative); + let actual = match text::read_text(&path) { + Ok(text) => text, + Err(error) => { + return Outcome::fail(format!( + "cannot read {relative}: {error}; run cargo xtask generate" + )); + } + }; + if actual == expected { + println!("generated source: {relative}"); + return Outcome::Pass; + } + let diff = TextDiff::from_lines(&expected, &actual) + .unified_diff() + .header("expected", "committed") + .to_string(); + Outcome::fail(format!( + "{diff}{relative} differs from the baker output; run cargo xtask generate" + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Config; + use crate::runner::{Ctx, Profile}; + use std::sync::Arc; + + fn ctx(root: &std::path::Path) -> Ctx { + Ctx { + root: root.to_path_buf(), + profile: Profile::Full, + nightly: "nightly".to_string(), + skip_embedded: false, + coverage: false, + config: Arc::new(Config::load(root).expect("committed configuration must load")), + } + } + + #[test] + fn committed_generated_source_matches_the_renderer() { + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("xtask sits one level below the repository root"); + assert!( + matches!(generated_source(&ctx(root)), Outcome::Pass), + "committed generated source drifted; run cargo xtask generate" + ); + } +} diff --git a/xtask/src/checks/mod.rs b/xtask/src/checks/mod.rs index ba27a81..2697263 100644 --- a/xtask/src/checks/mod.rs +++ b/xtask/src/checks/mod.rs @@ -5,6 +5,7 @@ pub mod cargo; pub mod code_size; pub mod deny; pub mod embedded; +pub mod generated; pub mod history; pub mod line_endings; pub mod package; @@ -22,6 +23,7 @@ pub fn run_action(ctx: &Ctx, action: &Action) -> Outcome { Action::NoPhCurves => ratchets::no_ph_curves(ctx), Action::BakeLineBudget => bake::baker_line_budget(ctx), Action::BakePackage => bake::baker_package(ctx), + Action::GeneratedSource => generated::generated_source(ctx), Action::ManifestFloor => ratchets::manifest_floor(ctx), Action::Fmt => cargo::fmt(ctx), Action::Test => cargo::test(ctx), diff --git a/xtask/src/config.rs b/xtask/src/config.rs index 79a15fb..ad70499 100644 --- a/xtask/src/config.rs +++ b/xtask/src/config.rs @@ -60,6 +60,7 @@ pub struct SourcePolicy { pub struct Baker { pub src: String, pub max_implementation_lines: usize, + pub generated: String, pub files: Vec, } @@ -112,6 +113,7 @@ pub enum Action { NoPhCurves, BakeLineBudget, BakePackage, + GeneratedSource, ManifestFloor, Fmt, Test, @@ -143,6 +145,7 @@ impl Action { Self::NoPhCurves => "NoPhCurves", Self::BakeLineBudget => "BakeLineBudget", Self::BakePackage => "BakePackage", + Self::GeneratedSource => "GeneratedSource", Self::ManifestFloor => "ManifestFloor", Self::Fmt => "Fmt", Self::Test => "Test", @@ -216,6 +219,7 @@ impl Config { "dependency manifests", &self.source_policy.dependency_manifests, )?; + nonempty("baker generated", &self.baker.generated)?; nonempty("code-size snapshot", &self.code_size.snapshot)?; nonempty("kernel symbol", &self.code_size.kernel_symbol)?; nonempty("kernel path fragment", &self.code_size.kernel_path_fragment)?; @@ -234,7 +238,7 @@ impl Config { .chain(self.source_policy.example_roots.iter()) .chain([&self.source_policy.arithmetic_kernel]) .chain(self.source_policy.dependency_manifests.iter()) - .chain([&self.baker.src]) + .chain([&self.baker.src, &self.baker.generated]) .chain(self.baker.files.iter()) .chain([&self.code_size.snapshot]) { @@ -339,6 +343,7 @@ impl Config { "NoPhCurves", "BakeLineBudget", "BakePackage", + "GeneratedSource", "ManifestFloor", "Fmt", "Test", diff --git a/xtask/src/main.rs b/xtask/src/main.rs index be070a9..f599a12 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -27,6 +27,8 @@ enum Command { Asm(WriteArgs), /// Print the configured check registry. List, + /// Rewrite the checked-in baker generated module. + Generate, } #[derive(Args)] @@ -104,6 +106,17 @@ fn dispatch(command: Command) -> Result { runner::list(&ctx.config.checks); Ok(0) } + Command::Generate => { + let ctx = context( + current_root()?, + Profile::Full, + "nightly".into(), + false, + false, + )?; + xtask::checks::generated::write(&ctx)?; + Ok(0) + } } } @@ -224,5 +237,6 @@ mod tests { #[test] fn unknown_options_are_usage_errors() { assert!(Cli::try_parse_from(["xtask", "ci", "--unknown"]).is_err()); + assert!(Cli::try_parse_from(["xtask", "generate"]).is_ok()); } } diff --git a/xtask/tests/mutation.rs b/xtask/tests/mutation.rs index 5e82187..1903dc2 100644 --- a/xtask/tests/mutation.rs +++ b/xtask/tests/mutation.rs @@ -13,7 +13,7 @@ use std::path::{Path, PathBuf}; use std::process::Command; use std::sync::Arc; -use xtask::checks::{bake, history, line_endings, package, publish_lock, ratchets}; +use xtask::checks::{bake, generated, history, line_endings, package, publish_lock, ratchets}; use xtask::config::{Action, CheckSpec, Config}; use xtask::runner::{Ctx, Outcome, Profile}; @@ -302,6 +302,37 @@ fn a_baker_packaged_file_set_mismatch_is_rejected() { ); } +#[test] +fn a_stale_generated_source_is_rejected() { + let root = tracked_copy("generated-source"); + rewrite( + &root.join("crates/surfaces-bake/generated/rounding.rs"), + |text| { + text.replace( + "pub const MAX_ERR_LSB: i32 = 1;", + "pub const MAX_ERR_LSB: i32 = 99;", + ) + }, + ); + assert_fires( + "generated-source", + "generated source", + generated::generated_source(&ctx(&root, Profile::Full)), + ); +} + +#[test] +fn a_missing_generated_source_is_rejected() { + let root = tracked_copy("generated-source-missing"); + fs::remove_file(root.join("crates/surfaces-bake/generated/rounding.rs")) + .expect("could not delete the generated artifact"); + assert_fires( + "generated-source-missing", + "generated source", + generated::generated_source(&ctx(&root, Profile::Full)), + ); +} + #[test] fn a_manifest_floor_change_is_rejected() { let root = tracked_copy("manifest-version"); From 1e1e9f12b7f23455c952d23ee9d096766a32d502 Mon Sep 17 00:00:00 2001 From: Steven Giacomelli Date: Tue, 25 Aug 2026 17:10:01 -0400 Subject: [PATCH 2/4] Route the baker xtask dep through workspace.dependencies and reject oversized bucket counts. xtask was the only member with a direct path dep, and --x-bucketed 65537 printed uncompilable BucketedAxis source. Align both with the workspace policy and the runtime B <= 65536 bound. Co-authored-by: Cursor --- CHANGELOG.md | 3 +++ Cargo.toml | 2 ++ crates/surfaces-bake/src/main.rs | 44 +++++++++++++++++++++++++++++--- xtask/Cargo.toml | 2 +- 4 files changed, 47 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0290ebd..93cd7a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ - Host-side baker Rust emission in `ph-surfaces-bake`: `--emit-rust` writes deterministic static knot arrays, a row-major `values[y][x]` grid, `PAYLOAD_BYTES` for the selected pairing, and `MAX_ERR_LSB` to stdout. + `--x-bucketed` / `--y-bucketed` reject a bucket count outside the runtime + `1..=65_536` bound so the emitter does not print uncompilable + `BucketedAxis` source. `cargo xtask generate` writes the baker-owned checked-in fixture; the `generated source` check re-renders it in memory and fails with `run cargo xtask generate` when it drifts. The bound is an i32 value LSB diff --git a/Cargo.toml b/Cargo.toml index 4c2c0e3..18876a7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,3 +31,5 @@ syn = { version = "3.0.3", features = ["full", "visit"] } time = { version = "0.3.55", features = ["macros", "parsing"] } toml = "1.1.4" walkdir = "2.5.0" +# Host gate only. The runtime crate must not name this package. +ph-surfaces-bake = { path = "crates/surfaces-bake", version = "0.1.0" } diff --git a/crates/surfaces-bake/src/main.rs b/crates/surfaces-bake/src/main.rs index 034c0cc..eca2f38 100644 --- a/crates/surfaces-bake/src/main.rs +++ b/crates/surfaces-bake/src/main.rs @@ -146,7 +146,7 @@ fn parse_buckets(raw: &str) -> Result { let buckets: usize = raw .parse() .map_err(|_| "invalid bucket count".to_string())?; - if buckets == 0 { + if buckets == 0 || buckets > 65_536 { Err("invalid bucket count".to_string()) } else { Ok(buckets) @@ -266,8 +266,8 @@ fn help() -> String { --y-uniform Y axis as origin,step,count (runtime UniformAxis)\n\ --scale output scale for the i32 value domain (applied at quantize)\n\ --emit-rust write static Rust tables to stdout (BinaryAxis × BinaryAxis)\n\ - --x-bucketed emit X as BucketedAxis with this many buckets (requires --emit-rust)\n\ - --y-bucketed emit Y as BucketedAxis with this many buckets (requires --emit-rust)\n\ + --x-bucketed emit X as BucketedAxis with B in 1..=65536 (requires --emit-rust)\n\ + --y-bucketed emit Y as BucketedAxis with B in 1..=65536 (requires --emit-rust)\n\ --emit-golden not implemented yet\n\ \n\ Each axis takes either a knot list or a uniform descriptor, never both.\n\ @@ -540,6 +540,44 @@ mod tests { assert!(!out.contains("Y_INDEX")); } + #[test] + fn bucket_count_above_the_runtime_limit_is_rejected() { + let err = parse_ingest(&[ + "--samples".to_string(), + "p.txt".to_string(), + "--x-knots".to_string(), + "0,10".to_string(), + "--y-knots".to_string(), + "0,5".to_string(), + "--scale".to_string(), + "1".to_string(), + "--emit-rust".to_string(), + "--x-bucketed".to_string(), + "65537".to_string(), + ]) + .unwrap_err(); + assert!(err.contains("invalid bucket count")); + } + + #[test] + fn max_runtime_bucket_count_is_accepted() { + let parsed = parse_ingest(&[ + "--samples".to_string(), + "p.txt".to_string(), + "--x-knots".to_string(), + "0,10".to_string(), + "--y-knots".to_string(), + "0,5".to_string(), + "--scale".to_string(), + "1".to_string(), + "--emit-rust".to_string(), + "--y-bucketed".to_string(), + "65536".to_string(), + ]) + .unwrap(); + assert_eq!(parsed.y_axis, EmitAxis::Bucketed { buckets: 65_536 }); + } + #[test] fn bucketed_without_emit_rust_is_rejected() { let err = parse_ingest(&[ diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml index 29383e6..99b1b03 100644 --- a/xtask/Cargo.toml +++ b/xtask/Cargo.toml @@ -34,4 +34,4 @@ syn.workspace = true time.workspace = true toml.workspace = true walkdir.workspace = true -ph-surfaces-bake = { path = "../crates/surfaces-bake", version = "0.1.0" } +ph-surfaces-bake.workspace = true From b93aec9809fc34b82b55b104e344c67fdbbc8a50 Mon Sep 17 00:00:00 2001 From: Steven Giacomelli Date: Tue, 25 Aug 2026 18:07:44 -0400 Subject: [PATCH 3/4] Raise the baker line cap so emit.rs fits the declared budget. S5 adds emission on top of the S4 residual kernel; 1,651 lines exceeded 1,600. The cap is a ballooning brake, not a freeze. Co-authored-by: Cursor --- xtask/config.ron | 2 +- xtask/tests/mutation.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/xtask/config.ron b/xtask/config.ron index aa438d8..6c73080 100644 --- a/xtask/config.ron +++ b/xtask/config.ron @@ -65,7 +65,7 @@ ), baker: ( src: "crates/surfaces-bake/src", - max_implementation_lines: 1600, + max_implementation_lines: 1800, generated: "crates/surfaces-bake/generated/rounding.rs", files: [ ".cargo_vcs_info.json", diff --git a/xtask/tests/mutation.rs b/xtask/tests/mutation.rs index 1903dc2..9a603e5 100644 --- a/xtask/tests/mutation.rs +++ b/xtask/tests/mutation.rs @@ -261,7 +261,7 @@ fn exceeding_the_baker_line_budget_is_rejected() { let root = tracked_copy("baker-line-budget"); rewrite(&root.join("xtask/config.ron"), |text| { text.replace( - "max_implementation_lines: 1600", + "max_implementation_lines: 1800", "max_implementation_lines: 1", ) }); From 02d55f31898191a39166e86d2de0dc4051977b54 Mon Sep 17 00:00:00 2001 From: Steven Giacomelli Date: Tue, 25 Aug 2026 18:11:32 -0400 Subject: [PATCH 4/4] Keep packaged baker tests off the unpackaged fixture and reject illegal bucket counts in emit_rust_with. The generated rounding module is excluded from the .crate, so library tests construct the S4 surface in-test. Bucketed emission shares the runtime 1..=65536 bound as BakeError::InvalidBucketCount. Co-authored-by: Cursor --- CHANGELOG.md | 4 +- crates/surfaces-bake/src/emit.rs | 103 ++++++++++++++++++++++++------ crates/surfaces-bake/src/error.rs | 29 +++++++++ crates/surfaces-bake/src/lib.rs | 8 ++- crates/surfaces-bake/src/main.rs | 7 +- 5 files changed, 122 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 93cd7a3..d5b12b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,9 @@ `PAYLOAD_BYTES` for the selected pairing, and `MAX_ERR_LSB` to stdout. `--x-bucketed` / `--y-bucketed` reject a bucket count outside the runtime `1..=65_536` bound so the emitter does not print uncompilable - `BucketedAxis` source. + `BucketedAxis` source; `emit_rust_with` returns the same closed + `BakeError::InvalidBucketCount`. Packaged baker tests do not + `include!` the unpackaged generated fixture. `cargo xtask generate` writes the baker-owned checked-in fixture; the `generated source` check re-renders it in memory and fails with `run cargo xtask generate` when it drifts. The bound is an i32 value LSB diff --git a/crates/surfaces-bake/src/emit.rs b/crates/surfaces-bake/src/emit.rs index 3dce7c6..cc5122c 100644 --- a/crates/surfaces-bake/src/emit.rs +++ b/crates/surfaces-bake/src/emit.rs @@ -7,7 +7,7 @@ use std::fmt::Write as _; use crate::quantize::{QuantizedTable, emit_max_err_lsb}; -use crate::{Axis, BakeInput}; +use crate::{Axis, AxisName, BakeError, BakeInput}; /// Per-axis lookup named in emitted source. This selects tokens, not a grid. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -23,10 +23,17 @@ pub enum EmitAxis { } impl EmitAxis { + /// Inclusive runtime `BucketedAxis` bound: `B <= 65_536`. + pub const MAX_BUCKETS: usize = 65_536; + fn is_binary(self) -> bool { matches!(self, Self::Binary) } + pub(crate) fn bucket_count_ok(buckets: usize) -> bool { + (1..=Self::MAX_BUCKETS).contains(&buckets) + } + fn name(self) -> &'static str { match self { Self::Binary => "BinaryAxis", @@ -48,15 +55,40 @@ const CHECKED_IN_SAMPLES: &str = "0 0 0\n2 0 1\n0 2 1\n2 2 2\n1 1 1\n"; /// Render BinaryAxis × BinaryAxis static tables from a quantized grid. #[must_use] pub fn emit_rust(table: &QuantizedTable) -> String { - emit_rust_with(table, EmitAxis::Binary, EmitAxis::Binary) + emit_pairing(table, EmitAxis::Binary, EmitAxis::Binary) } /// Render static tables for an explicit pairing. /// /// `PAYLOAD_BYTES` is the referenced element payload using the public /// runtime formula, not total RAM, flash, binary, or linker cost. -#[must_use] -pub fn emit_rust_with(table: &QuantizedTable, x: EmitAxis, y: EmitAxis) -> String { +/// +/// # Errors +/// +/// Returns [`crate::BakeError::InvalidBucketCount`] when a [`EmitAxis::Bucketed`] +/// count is outside the runtime `1..=65_536` range. +pub fn emit_rust_with( + table: &QuantizedTable, + x: EmitAxis, + y: EmitAxis, +) -> Result { + validate_emit_axis(AxisName::X, x)?; + validate_emit_axis(AxisName::Y, y)?; + Ok(emit_pairing(table, x, y)) +} + +fn validate_emit_axis(name: AxisName, axis: EmitAxis) -> Result<(), BakeError> { + match axis { + EmitAxis::Binary => Ok(()), + EmitAxis::Bucketed { buckets } if EmitAxis::bucket_count_ok(buckets) => Ok(()), + EmitAxis::Bucketed { buckets } => Err(BakeError::InvalidBucketCount { + axis: name, + buckets, + }), + } +} + +fn emit_pairing(table: &QuantizedTable, x: EmitAxis, y: EmitAxis) -> String { let nx = table.x.len(); let ny = table.y.len(); let mut src = String::from("// @generated by `cargo xtask generate`; do not edit by hand.\n\n"); @@ -187,16 +219,9 @@ fn grid(values: &[Vec]) -> String { #[cfg(test)] mod tests { use super::{EmitAxis, checked_in_source, emit_rust, emit_rust_with}; - use crate::{Axis, BakeInput}; + use crate::{Axis, AxisName, BakeError, BakeInput}; use ph_surfaces::BilinearSurface; - mod fixture { - include!(concat!( - env!("CARGO_MANIFEST_DIR"), - "/generated/rounding.rs" - )); - } - fn corners() -> crate::quantize::QuantizedTable { BakeInput::parse( "0 0 1.5\n10 0 2.5\n0 5 3.5\n10 5 4.5\n", @@ -236,12 +261,14 @@ mod tests { #[test] fn checked_in_fixture_compiles_against_the_runtime() { - assert_eq!( - fixture::PAYLOAD_BYTES, - BilinearSurface::<2, 2>::PAYLOAD_BYTES - ); - assert_eq!(fixture::SURFACE.evaluate(1, 1), Ok(2)); - assert_eq!(fixture::MAX_ERR_LSB, 1); + // Construct the S4 rounding surface in-test. The generated file is + // unpackaged; the xtask drift gate owns it. + static AXIS: [u16; 2] = [0, 2]; + static VALUES: [[i32; 2]; 2] = [[0, 1], [1, 2]]; + static SURFACE: BilinearSurface<2, 2> = BilinearSurface::new(&AXIS, &AXIS, &VALUES); + assert_eq!(SURFACE.evaluate(1, 1), Ok(2)); + assert_eq!(BilinearSurface::<2, 2>::PAYLOAD_BYTES, 24); + assert!(checked_in_source().contains("pub const MAX_ERR_LSB: i32 = 1;")); } #[test] @@ -258,7 +285,8 @@ mod tests { #[test] fn bucketed_emission_calls_the_public_bucket_index() { let table = corners(); - let src = emit_rust_with(&table, EmitAxis::Bucketed { buckets: 2 }, EmitAxis::Binary); + let src = + emit_rust_with(&table, EmitAxis::Bucketed { buckets: 2 }, EmitAxis::Binary).unwrap(); assert!(src.contains("use ph_surfaces::bucket_index;")); assert!(src.contains("static X_INDEX: [u16; 2] = bucket_index(&X);")); assert!(!src.contains("Y_INDEX")); @@ -270,7 +298,7 @@ mod tests { assert!(src.contains("BinaryAxis::new(&Y)")); assert_eq!( src, - emit_rust_with(&table, EmitAxis::Bucketed { buckets: 2 }, EmitAxis::Binary) + emit_rust_with(&table, EmitAxis::Bucketed { buckets: 2 }, EmitAxis::Binary).unwrap() ); } @@ -281,10 +309,43 @@ mod tests { &table, EmitAxis::Bucketed { buckets: 2 }, EmitAxis::Bucketed { buckets: 2 }, - ); + ) + .unwrap(); assert!(src.contains("static X_INDEX: [u16; 2] = bucket_index(&X);")); assert!(src.contains("static Y_INDEX: [u16; 2] = bucket_index(&Y);")); assert!(!src.contains("BinaryAxis")); assert!(src.contains("pub const PAYLOAD_BYTES: usize = 32;")); } + + #[test] + fn a_zero_bucket_count_is_a_closed_error() { + let err = emit_rust_with( + &corners(), + EmitAxis::Bucketed { buckets: 0 }, + EmitAxis::Binary, + ); + assert_eq!( + err, + Err(BakeError::InvalidBucketCount { + axis: AxisName::X, + buckets: 0 + }) + ); + } + + #[test] + fn a_bucket_count_above_the_runtime_limit_is_a_closed_error() { + let err = emit_rust_with( + &corners(), + EmitAxis::Binary, + EmitAxis::Bucketed { buckets: 65_537 }, + ); + assert_eq!( + err, + Err(BakeError::InvalidBucketCount { + axis: AxisName::Y, + buckets: 65_537 + }) + ); + } } diff --git a/crates/surfaces-bake/src/error.rs b/crates/surfaces-bake/src/error.rs index f08d16f..744124f 100644 --- a/crates/surfaces-bake/src/error.rs +++ b/crates/surfaces-bake/src/error.rs @@ -132,6 +132,13 @@ pub enum BakeError { /// Declared Y knot count. ny: usize, }, + /// A bucketed emit axis declared `B` outside the runtime `1..=65_536` range. + InvalidBucketCount { + /// Which axis the illegal bucket count was requested for. + axis: AxisName, + /// The rejected bucket count. + buckets: usize, + }, } impl Display for BakeError { @@ -213,6 +220,12 @@ impl Display for BakeError { crate::MAX_GRID_CELLS ) } + Self::InvalidBucketCount { buckets: 0, .. } => { + f.write_str("a bucket index must declare at least one bucket") + } + Self::InvalidBucketCount { .. } => { + f.write_str("a bucket index declares at most 65_536 buckets") + } } } } @@ -362,6 +375,22 @@ mod tests { .to_string(), "declared grid is 65536 by 65536 cells; the baker accepts at most 1048576" ); + assert_eq!( + BakeError::InvalidBucketCount { + axis: AxisName::X, + buckets: 0 + } + .to_string(), + "a bucket index must declare at least one bucket" + ); + assert_eq!( + BakeError::InvalidBucketCount { + axis: AxisName::Y, + buckets: 65_537 + } + .to_string(), + "a bucket index declares at most 65_536 buckets" + ); } #[test] diff --git a/crates/surfaces-bake/src/lib.rs b/crates/surfaces-bake/src/lib.rs index c300b15..6d80e9d 100644 --- a/crates/surfaces-bake/src/lib.rs +++ b/crates/surfaces-bake/src/lib.rs @@ -51,9 +51,11 @@ //! not a device, vendor, sensor, calibration, or accuracy claim. //! //! [`emit_rust`] writes BinaryAxis × BinaryAxis static tables, `PAYLOAD_BYTES`, -//! and `MAX_ERR_LSB` as source text. The baker prints that text on stdout; -//! `cargo xtask generate` places the checked-in copy. Frozen golden vectors: -//! issue #42. +//! and `MAX_ERR_LSB` as source text. [`emit_rust_with`] is the same for an +//! explicit pairing and returns [`BakeError::InvalidBucketCount`] when a +//! bucketed axis is outside `1..=65_536`. The baker prints that text on +//! stdout; `cargo xtask generate` places the checked-in copy. Frozen golden +//! vectors: issue #42. #![deny(missing_docs)] #![forbid(unsafe_code)] diff --git a/crates/surfaces-bake/src/main.rs b/crates/surfaces-bake/src/main.rs index eca2f38..323b056 100644 --- a/crates/surfaces-bake/src/main.rs +++ b/crates/surfaces-bake/src/main.rs @@ -46,9 +46,8 @@ fn ingest(args: &[String]) -> Result { })?; match BakeInput::parse(&text, parsed.x, parsed.y, parsed.scale) { Ok(input) => match input.quantize() { - Ok(table) if parsed.emit_rust => { - Ok(emit_rust_with(&table, parsed.x_axis, parsed.y_axis)) - } + Ok(table) if parsed.emit_rust => emit_rust_with(&table, parsed.x_axis, parsed.y_axis) + .map_err(|error| (1, bake_error(error))), Ok(table) => Ok(summary(&input, &table)), Err(error) => Err((1, bake_error(error))), }, @@ -146,7 +145,7 @@ fn parse_buckets(raw: &str) -> Result { let buckets: usize = raw .parse() .map_err(|_| "invalid bucket count".to_string())?; - if buckets == 0 || buckets > 65_536 { + if !(1..=EmitAxis::MAX_BUCKETS).contains(&buckets) { Err("invalid bucket count".to_string()) } else { Ok(buckets)