From 140de36367f71812810193e084ee35e2adcd180a Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Tue, 13 Oct 2026 01:01:04 -0400 Subject: [PATCH 01/10] WIP (does not compile): D2c, decoders on wai-ops, as left when a write was refused Saved so the work is not lost. byte-exact-conformance/src/lib.rs declares mod int_codec_golden, and that file does not exist yet, so the tree does not build. Contains the Kernels trait in wai-ops/interp.rs, wai-rs/src/int_ops.rs, int_synth decoding through int_synth/1, tests/int_ops_differential.rs and the SPEC text for the planned breaks. Co-Authored-By: Claude Opus 5.5 --- wai/CHANGELOG.md | 22 +++ wai/SPEC.md | 19 +- wai/byte-exact-conformance/Cargo.lock | 5 + wai/byte-exact-conformance/Cargo.toml | 9 + wai/byte-exact-conformance/src/lib.rs | 17 ++ wai/extensions/integer-payloads.md | 15 +- wai/wai-ops/src/interp.rs | 56 +++++- wai/wai-ops/src/lib.rs | 2 +- wai/wai-ops/src/lower/mod.rs | 2 +- wai/wai-ops/src/lower/whm.rs | 50 +++-- wai/wai-rs/Cargo.lock | 5 + wai/wai-rs/Cargo.toml | 14 +- wai/wai-rs/src/codecs/mod.rs | 29 +++ wai/wai-rs/src/codecs/registry.rs | 60 +++++- wai/wai-rs/src/int_ops.rs | 221 ++++++++++++++++++++++ wai/wai-rs/src/int_synth.rs | 146 +++++++++++---- wai/wai-rs/src/lib.rs | 5 + wai/wai-rs/tests/int_kernels_geometry.rs | 52 +++--- wai/wai-rs/tests/int_ops_differential.rs | 222 +++++++++++++++++++++++ wai/wai-web/Cargo.lock | 5 + 20 files changed, 872 insertions(+), 84 deletions(-) create mode 100644 wai/wai-rs/src/int_ops.rs create mode 100644 wai/wai-rs/tests/int_ops_differential.rs diff --git a/wai/CHANGELOG.md b/wai/CHANGELOG.md index 6caadec37..f391e9443 100644 --- a/wai/CHANGELOG.md +++ b/wai/CHANGELOG.md @@ -6,6 +6,28 @@ wire format may change and such changes are recorded here. ## v0.4.0 — current +### The integer decoders through the integer operator set + +- **Each integer decoder runs its networks through `wai-ops/1`** + ([integer-operators](extensions/integer-operators.md) §13). A decoder lowers its + parameter set to `WHG1` graphs, loads each under `acc64` (load steps 1–8, the last + being the bounds certificate) and runs it through the operator set's interpreter, with + the fast kernels behind it for the convolutions and the inverse GDN + (`wai-rs/src/int_ops.rs`). Every committed output is unchanged, byte for byte, and so + is every committed refusal's code. The direct paths the decoders ran before are kept, + test-only (`legacy_int`), and `tests/int_ops_differential.rs` decodes every committed + vector and random sets and payloads both ways. +- **`wai.neural.int_synth`** decodes through `int_synth/1` (§13.1). The registry gains + its `ops` member (§11): `acc64`, subset `full`, lowering `int_synth/1`. +- **Planned breaks** (SPEC §8, integer-operators §15.3): new refusals of input no + corpus contains. `wai.neural.int_synth` refuses a payload whose reserved byte is not + 0, whose `C` is 0 or whose latents exceed `2^26` values, and a set whose shift is 128 + or more, whose `k` or `stride` is 0, which a cap refuses (a field past 32 bits, a + tensor or a transposed convolution's uncropped output past `2^26` elements), or whose + certificate does not meet `acc64`. A shift from 64 to 127, which a build used to mask + or panic on, now has its exact meaning. A scan of every committed `int_synth` set and + payload finds none affected. + ### Packaging: refinement layers, stills, a piece's ladder, live windows and delivery - **Refinement layers in packages** ([packaging](extensions/packaging.md) §5.4). For diff --git a/wai/SPEC.md b/wai/SPEC.md index a1c587a72..fc56eb3ae 100644 --- a/wai/SPEC.md +++ b/wai/SPEC.md @@ -619,7 +619,7 @@ registration and makes no conforming envelope non-conforming (§8). |---|---|---|---| | `wai.neural.bmshj2018` | bmshj2018-factorized + rANS (byte-exact CompressAI port) | about 0.26 bpp at 29 dB PSNR (quality 3) | CompressAI | | `wai.neural.mbt2018_mean` | mean-scale hyperprior (Gaussian-conditional entropy) | the conditional-entropy family (arXiv 1809.02736) | `neural/hyperprior.rs` | -| `wai.neural.int_synth` | integer fixed-point synthesis (all-i64 transposed-conv, no float/ONNX) | bit-identical on every machine — soundly backs `intent = replicate` | `int_synth.rs` (integer weights sink-supplied; the reference tools make them by training in float, then quantising after training with per-layer shifts calibrated on measured activations, `tools/wai_int_synth_train.py`. Of the committed vectors only `photo` comes from a trained model, `tools/wai_int_synth_real.py`; `gradient`, `bloom`, `facade` and the cross-architecture golden use seeded integer weights) | +| `wai.neural.int_synth` | integer fixed-point synthesis (all-i64 transposed-conv, no float/ONNX) | bit-identical on every machine — soundly backs `intent = replicate` | payload and set: [integer-payloads](extensions/integer-payloads.md) §13; decoded through its lowering, [integer-operators](extensions/integer-operators.md) §13.1 (`int_synth/1`); `int_synth.rs` (integer weights sink-supplied; the reference tools make them by training in float, then quantising after training with per-layer shifts calibrated on measured activations, `tools/wai_int_synth_train.py`. Of the committed vectors only `photo` comes from a trained model, `tools/wai_int_synth_real.py`; `gradient`, `bloom`, `facade` and the cross-architecture golden use seeded integer weights) | | `wai.neural.int_hyper` | integer fixed-point **conditional** codec — whole bitstream→image path integer (integer hyper-decoder σ → integer-σ entropy → integer synthesis) | the integer-entropy contract fully assembled: a PTQ of mbt2018_mean, bit-identical on every machine, whose integer decode CI holds within 1 dB of the same model's float decode on a pinned photograph | payload `WIH1`; `int_hyper_synth.rs` (tables sink-supplied) | | `wai.neural.int_mlicpp` | integer fixed-point **MLIC++ entropy topology** — `K` channel slices of the latent, each decoded as a checkerboard anchor pass then a non-anchor pass, with an entropy network over concat[latent decoded so far, hyper] re-run before every pass to give σ‖μ; hyper-decoder, entropy network and synthesis all integer | the channel-autoregressive × checkerboard entropy model at **decode-equivalence**: the encoder and every decoder compute the same σ, so the same distribution for every element, at any thread count; bit-identical on every machine. The committed conformance vector has synthetic weights, so it demonstrates decode-equivalence and states no quality figure | `int_mlicpp.rs` (one-file model bundle sink-supplied; `tools/wai_int_mlicpp_export.py`) | | `wai.image.jpegai` | JPEG AI (ISO/IEC 6048-1:2025) codestream → RGB — integer entropy front end (mANS, with an integer scale-index derivation) + 16-bit-PTQ integer hyper-decoder, context-model and synthesis networks + dequantisation, latent refinement, post-filters (the eICCI CNN among them) and colour conversion in IEEE-754 float with a fixed evaluation order. Scope: one configuration — 256×256 4:4:4, `model_id` 0 (tools_0), `beta_displacement_log` 0, the high-operation-point synthesis, the eICCI model pair the weight bundle carries, and the other header conditions in [determinism-tiers](extensions/determinism-tiers.md) §5; both decode paths refuse any other codestream. Within it, a sink without the integer weight bundle, or in a non-default floating-point environment, takes a float ONNX decode instead. Only checkpoint constants are sink-supplied | **entropy-consistency** (registry class `NeuralEntropyExact`): the entropy decode, including the scale-index derivation, is integer on both decode paths, so the decoded symbols reproduce on every machine. The exact decoder also pins one reconstruction to RGB, reproducible across machines under the build and floating-point conditions in [determinism-tiers](extensions/determinism-tiers.md) §5–§6 (it checks the floating-point environment before decoding) — a property of that decoder, not of the capability, so `wai.image.jpegai` does not back `replicate` (§7). On one reference codestream the exact decoder stays within max\|Δ\| ≤ 2 of the reference decoder (recorded max\|Δ\| = 1 on ~4.4% of samples); evidence in [determinism-tiers](extensions/determinism-tiers.md) §6. **Downgraded in v0.4.0:** this row previously claimed decode-equivalence, and the registry classed the capability `StandardDefined` | `jpegai_decode.rs` (+ `jpegai_{bitstream,mans,pipeline,synthesis,icci}.rs`) | @@ -1434,6 +1434,12 @@ registry as `codecs::capability_replicate_class(cap) -> ReplicateClass`: checkerboard entropy model on integer networks, so the σ it re-derives before each of its `2·K` passes is integer too; `wai.video.int_hyper`, `wai.audio.int_codec` and `wai.splat.int_codec` are integer throughout as well.) + A capability of this class whose networks decode through the integer operator set + names it in the registry, in its `ops` member + ([integer-operators](extensions/integer-operators.md) §11): the lowering its sets + are lowered by, the profile every conforming set's certificate meets, and the modes + of its stage operators. A set whose certificate does not meet that profile is + refused. In this revision: `wai.neural.int_synth`. - **`NeuralEntropyExact`** — the entropy decode is integer by construction, so the decoded symbols are byte-identical on every machine; later stages run float. Registered at `entropy-consistency`. An encoder MUST NOT emit `intent = @@ -2308,6 +2314,17 @@ envelope that uses any string a later revision registers. envelope that carried it as a `WAI1` `capability` without `refines`, as a `fallback`, or as a selectable rendition or a companion, and one that bound a companion to it. Such envelopes still parse. +- **Planned breaks.** A capability that comes to decode through the integer operator + set ([integer-operators](extensions/integer-operators.md) §13) refuses some inputs it + decoded before, or decoded differently by build. Each is a new refusal of input no + committed corpus contains, and no committed output changes; integer-operators §15.3 + states them, and the changelog records each when it lands. Landed in this revision: + - `wai.neural.int_synth`: a payload whose reserved byte is not 0, or whose `C` is 0, + or whose latents exceed `2^26` values; a set whose shift is 128 or more, whose + `k` or `stride` is 0, which a cap refuses (a value past 32 bits where `WHG1` + holds 32, a tensor past `2^26` elements, a transposed convolution's uncropped + output past `2^26`), or whose certificate does not meet `acc64`. A shift from 64 + to 127, which a build used to mask or panic on, now has its exact meaning. --- diff --git a/wai/byte-exact-conformance/Cargo.lock b/wai/byte-exact-conformance/Cargo.lock index 7226ce652..6897f57c4 100644 --- a/wai/byte-exact-conformance/Cargo.lock +++ b/wai/byte-exact-conformance/Cargo.lock @@ -182,8 +182,13 @@ dependencies = [ "serde", "serde_json", "sha2", + "wai-ops", ] +[[package]] +name = "wai-ops" +version = "0.1.0" + [[package]] name = "zmij" version = "1.0.23" diff --git a/wai/byte-exact-conformance/Cargo.toml b/wai/byte-exact-conformance/Cargo.toml index a6829ce26..664b4bbe6 100644 --- a/wai/byte-exact-conformance/Cargo.toml +++ b/wai/byte-exact-conformance/Cargo.toml @@ -22,3 +22,12 @@ unexpected_cfgs = { level = "warn", check-cfg = ["cfg(kani)"] } serde = { version = "1", features = ["derive"] } serde_json = "1" sha2 = "0.10" +# The integer operator set the included decoders run their networks through +# (`wai-rs/src/int_ops.rs`, included below at the crate root). no_std, no dependencies. +wai-ops = { path = "../wai-ops" } + +[features] +# The included decoders keep their direct paths behind this test-only switch +# (wai-rs's feature of the same name). Declared so the included sources' `cfg` names +# it; the goldens here do not use it. +legacy_int = [] diff --git a/wai/byte-exact-conformance/src/lib.rs b/wai/byte-exact-conformance/src/lib.rs index 3ff907d42..7a136295b 100644 --- a/wai/byte-exact-conformance/src/lib.rs +++ b/wai/byte-exact-conformance/src/lib.rs @@ -26,6 +26,13 @@ pub mod int_entropy; #[path = "../../wai-rs/src/int_kernels.rs"] pub mod int_kernels; +// The bridge the integer decoders run their networks through: each parameter set +// lowered to `wai-ops/1` graphs, loaded under its certificate's profile, and run by the +// operator set's interpreter with the fast kernels above behind it. Included so the +// matrix checks that path, the one sinks run. +#[path = "../../wai-rs/src/int_ops.rs"] +pub mod int_ops; + // The merged byte-exact JPEG-AI decoder's entropy stage (crate-root, self-contained — // no shims needed). Included so the cross-arch byte-equality matrix covers the decoder // that `wai.image.jpegai` actually reconstructs with, not only the older `neural/` port. @@ -69,6 +76,15 @@ pub mod int_hyper_synth; #[path = "../../wai-rs/src/int_mlicpp.rs"] pub mod int_mlicpp; +// The learned integer audio and Gaussian-splat attribute codecs (`wai.audio.int_codec`, +// `wai.splat.int_codec`): integer transposed convolutions and IGDN over the networks +// and kernels already included here, so the matrix checks their PCM and attribute +// bytes on every platform. +#[path = "../../wai-rs/src/int_audio.rs"] +pub mod int_audio; +#[path = "../../wai-rs/src/int_splat.rs"] +pub mod int_splat; + // The wire parser of the integer payloads SPEC §5 pins (WIV1, WIH1, WIA1, WIS1). // Self-contained — std's Vec only, nothing from the crate — so the matrix parses the // committed binary payloads with the parser a sink runs. @@ -87,4 +103,5 @@ mod int_video_golden; mod neural_keyframe_golden; mod jpegai_golden; mod int_mlicpp_golden; +mod int_codec_golden; mod staged_golden; diff --git a/wai/extensions/integer-payloads.md b/wai/extensions/integer-payloads.md index 46b00e96e..9d84807cb 100644 --- a/wai/extensions/integer-payloads.md +++ b/wai/extensions/integer-payloads.md @@ -611,13 +611,14 @@ The decode is [integer-operators](integer-operators.md) §13.1's graph on the la Its output must be `[3, H, W]`, and it is presented as row-major HWC bytes. Every refusal of the payload, the set or the decode is `payload_malformed` (§8). -**Checks that wait for the lowering.** The reference decoder -(`int_synth::decode_bytes`) makes every check of this section except these, which it -makes once the capability decodes through its lowering -([integer-operators](integer-operators.md) §15.3): a reserved byte of 0; `C ≥ 1`; the -caps; `k ≥ 1`, `stride ≥ 1` and a shift of at most 127; and the certificate. Until -then it decodes a payload or set that fails one of them where it can, and with a shift -from 64 to 127 its result depends on how it was built. +The reference decoder (`int_synth::decode_bytes`) decodes through the lowering. It +checks the payload first: its header, a reserved byte of 0, `C ≥ 1`, `C·S·S ≤ 2^26` +and its length. It then lowers the set and loads the graph under `acc64`, which makes +the set's checks (§13.1 of [integer-operators](integer-operators.md): the layers chain, +the lengths, `k ≥ 1`, `stride ≥ 1`, a shift of at most 127, the caps and the +certificate), and runs it. A shift from 64 to 127 has its exact meaning. These checks +are new refusals of input no corpus contains ([integer-operators](integer-operators.md) +§15.3, SPEC §8 *Planned breaks*). ## 14. `WIM1` and `WMB1` — `wai.neural.int_mlicpp` diff --git a/wai/wai-ops/src/interp.rs b/wai/wai-ops/src/interp.rs index 5383341eb..a8d5c9f91 100644 --- a/wai/wai-ops/src/interp.rs +++ b/wai/wai-ops/src/interp.rs @@ -151,17 +151,60 @@ fn to64(v: i128, at: u32) -> Res { i64::try_from(v).map_err(|_| wide(at)) } +/// A host's own kernels for some operators (§6). A sink may compute an operator's +/// output by other means (another order of the same sums, narrower types where the +/// values fit, threads, vector lanes) provided it gives the same integers; [`run_with`] +/// asks the host first and computes an operator itself only where the host declines. +/// Everything else stays the interpreter's: the plan, every scalar and input check, +/// every tensor's range policy, the state of a sequence and every refusal. +pub trait Kernels { + /// The outputs of `op` on its inputs `ins` (in the operator's input order), each + /// output `k` of the shape `out[k]` (`(C, H, W)`, from the run-time plan); or `None` + /// for the interpreter to compute them. An implementation that returns `Some` + /// returns exactly the integers the interpreter would, so it returns `None` for + /// anything it does not hold to that: an operator whose arithmetic would leave + /// its narrower types, or one the interpreter would refuse. + fn eval(&self, op: &crate::graph::Op, ins: &[&Tensor], out: &[(u32, u32, u32)]) -> Option>; +} + +/// The interpreter's own kernels for every operator: a host that has none. +pub struct Reference; + +impl Kernels for Reference { + #[inline(always)] + fn eval(&self, _: &crate::graph::Op, _: &[&Tensor], _: &[(u32, u32, u32)]) -> Option> { + None + } +} + +/// `rq_o(v)` (§5.1) for output channel `o`: the requantisation a host kernel applies to +/// the exact sum `v` it formed, so that its outputs are the interpreter's. `None` +/// where a value leaves `i128`, which no certified graph forms. +pub fn rq(rq: &Rq, o: usize, v: i128) -> Option { + rq_apply(rq, o, v, 0, &mut Quiet) +} + /// Run one decode of `g`, a graph that passed every load step ([`crate::graph::load`]). /// `inputs` holds the data inputs, in input-table order; each state input is taken /// from `seq` (§10.3). `scalars` holds every scalar's value. Returns the graph's /// outputs, in output-table order. A refused decode leaves `seq` as it was. pub fn run(g: &Loaded, inputs: &[Tensor], scalars: &[i64], seq: &mut Sequence) -> Res> { - run_observed(g, inputs, scalars, seq, &mut Quiet) + run_inner(g, inputs, scalars, seq, &mut Quiet, &Reference) +} + +/// [`run`], with the host's kernels `k` asked first for each operator ([`Kernels`]). +/// The outputs and refusals are [`run`]'s, provided `k` keeps its contract. +pub fn run_with(g: &Loaded, inputs: &[Tensor], scalars: &[i64], seq: &mut Sequence, k: &K) -> Res> { + run_inner(g, inputs, scalars, seq, &mut Quiet, k) } /// [`run`], reporting every named intermediate and producer result to `obs` /// ([`Observe`]). The outputs and refusals are [`run`]'s. pub fn run_observed(g: &Loaded, inputs: &[Tensor], scalars: &[i64], seq: &mut Sequence, obs: &mut O) -> Res> { + run_inner(g, inputs, scalars, seq, obs, &Reference) +} + +fn run_inner(g: &Loaded, inputs: &[Tensor], scalars: &[i64], seq: &mut Sequence, obs: &mut O, k: &K) -> Res> { let g: &Graph = g.graph(); // The inputs, data and state, in input-table order. let n_data = g.inputs.iter().filter(|i| i.kind == 0).count(); @@ -204,7 +247,16 @@ pub fn run_observed(g: &Loaded, inputs: &[Tensor], scalars: &[i64], vals[rec.tensor as usize] = Some(t); } for (i, op) in g.ops.iter().enumerate() { - let outs = eval(g, op, i as u32, &vals, &hw, scalars, obs)?; + let host = { + let ins: Vec<&Tensor> = op.ins.iter().map(|&t| get(&vals, t)).collect(); + let shapes: Vec<(u32, u32, u32)> = + op.outs.iter().map(|&t| (g.tensors[t as usize].channels, hw[t as usize].0 as u32, hw[t as usize].1 as u32)).collect(); + k.eval(op, &ins, &shapes) + }; + let outs = match host { + Some(o) => o, + None => eval(g, op, i as u32, &vals, &hw, scalars, obs)?, + }; for (k, ten) in outs.iter().enumerate() { for &v in &ten.data { obs.see(i as u32, OUT[k], v as i128); diff --git a/wai/wai-ops/src/lib.rs b/wai/wai-ops/src/lib.rs index 5df059449..598c589e1 100644 --- a/wai/wai-ops/src/lib.rs +++ b/wai/wai-ops/src/lib.rs @@ -49,4 +49,4 @@ pub mod xint; pub use bounds::Profile; pub use code::{Code, Refusal, Site}; pub use graph::{Graph, Loaded}; -pub use interp::{Sequence, Tensor, run}; +pub use interp::{Kernels, Sequence, Tensor, run, run_with}; diff --git a/wai/wai-ops/src/lower/mod.rs b/wai/wai-ops/src/lower/mod.rs index 702abbf7f..8adba41fe 100644 --- a/wai/wai-ops/src/lower/mod.rs +++ b/wai/wai-ops/src/lower/mod.rs @@ -16,7 +16,7 @@ mod whm; pub use jpegai::jpegai; pub use synth::int_synth; -pub use whm::{Net, NetLayer, int_hyper, parse_wam1, parse_whm1, splat, wam1, whm1, wmb1, world}; +pub use whm::{Net, NetLayer, int_hyper, parse_wam1, parse_whm1, splat, wam1, wam1_net, whm1, whm1_net, wmb1, world, world_nets}; #[cfg(kani)] pub(crate) use whm::{p_conv, p_igdn, p_leaky}; diff --git a/wai/wai-ops/src/lower/whm.rs b/wai/wai-ops/src/lower/whm.rs index d3833782a..65efbee1c 100644 --- a/wai/wai-ops/src/lower/whm.rs +++ b/wai/wai-ops/src/lower/whm.rs @@ -312,7 +312,16 @@ fn net_graph(net: &Net, output_step: bool) -> Res { /// `whm1/1`: one `WHM1` network, with the RGB8 step when `output_step` is set. pub fn whm1(bytes: &[u8], output_step: bool) -> Res { - net_graph(&parse_whm1(bytes)?, output_step) + whm1_net(&parse_whm1(bytes)?, output_step) +} + +/// [`whm1`] of a network already parsed (a decoder that holds its networks parsed +/// lowers them from that form, which [`parse_whm1`] of their bytes gives). +pub fn whm1_net(net: &Net, output_step: bool) -> Res { + if net.audio { + return refuse(); + } + net_graph(net, output_step) } /// `whm1/1` for `wai.neural.int_hyper` and the keyframe of `wai.video.int_hyper`: @@ -328,14 +337,21 @@ pub fn splat(g_s: &[u8]) -> Res { /// `wam1/1` (§13.3): the `WAM1` synthesis with the PCM step. pub fn wam1(bytes: &[u8]) -> Res { - let net = parse_wam1(bytes)?; - header_ok(&net)?; - let c0 = in_channels(&net)?; + wam1_net(&parse_wam1(bytes)?) +} + +/// [`wam1`] of a network already parsed ([`parse_wam1`]). +pub fn wam1_net(net: &Net) -> Res { + if !net.audio { + return refuse(); + } + header_ok(net)?; + let c0 = in_channels(net)?; let mut b = Builder::new(); let first = net.layers.first().ok_or(()).or_else(|_| refuse())?; - let x0 = b.input(x_record(&net, first, c0, true)?, 0, 0); + let x0 = b.input(x_record(net, first, c0, true)?, 0, 0); let t_out = b.scalar(1, 1 << 26); - let x = layers(&mut b, &net, x0)?; + let x = layers(&mut b, net, x0)?; let c = b.channels(x); // The range in which v·32767 + 2^(afrac−1) fits i64. let lo = -((1i128 << 63) / 32767) as i64; // ⌈−2^63 / 32767⌉ @@ -379,14 +395,20 @@ pub fn wmb1(bytes: &[u8]) -> Res { /// `world/1` (§13.5, informative): the transition `T` with `A` action planes, then /// the synthesis `g_s` with the RGB8 step. `c` is the latent's channel count. pub fn world(t: &[u8], g_s: &[u8], c: u32) -> Res { - let tn = parse_whm1(t)?; - let gn = parse_whm1(g_s)?; - header_ok(&tn)?; - header_ok(&gn)?; + world_nets(&parse_whm1(t)?, &parse_whm1(g_s)?, c) +} + +/// [`world`] of two networks already parsed ([`parse_whm1`]). +pub fn world_nets(tn: &Net, gn: &Net, c: u32) -> Res { + if tn.audio || gn.audio { + return refuse(); + } + header_ok(tn)?; + header_ok(gn)?; if tn.afrac != gn.afrac { return refuse(); } - let cin = in_channels(&tn)?; + let cin = in_channels(tn)?; if c == 0 || cin <= c { return refuse(); } @@ -399,11 +421,11 @@ pub fn world(t: &[u8], g_s: &[u8], c: u32) -> Res { parts.push(b.op(opcode::FILL, vec![z], TensorRec::derived(1), Params::Fill { kind: 1, value: s as i64 })); } let first = tn.layers.first().ok_or(()).or_else(|_| refuse())?; - let x0 = b.op(opcode::CONCAT, parts, x_record(&tn, first, cin, true)?, Params::None); - let x = layers(&mut b, &tn, x0)?; + let x0 = b.op(opcode::CONCAT, parts, x_record(tn, first, cin, true)?, Params::None); + let x = layers(&mut b, tn, x0)?; let one = 1i64 << tn.afrac; let cc = b.channels(x); let out = b.op(opcode::CLAMP, vec![x], TensorRec::derived(cc), Params::Clamp { lo: -one, hi: one }); let tg = b.finish(vec![out])?; - Ok(Lowered { roles: vec![("T", tg), ("g_s", net_graph(&gn, true)?)] }) + Ok(Lowered { roles: vec![("T", tg), ("g_s", net_graph(gn, true)?)] }) } diff --git a/wai/wai-rs/Cargo.lock b/wai/wai-rs/Cargo.lock index 6a99e2be5..fe8b554f0 100644 --- a/wai/wai-rs/Cargo.lock +++ b/wai/wai-rs/Cargo.lock @@ -4065,12 +4065,17 @@ dependencies = [ "serde", "serde_json", "sha2 0.10.9", + "wai-ops", "wai-quantum", "xz2", "zstd", "zstd-safe", ] +[[package]] +name = "wai-ops" +version = "0.1.0" + [[package]] name = "wai-quantum" version = "0.4.0" diff --git a/wai/wai-rs/Cargo.toml b/wai/wai-rs/Cargo.toml index 61189574c..4018b2ef8 100644 --- a/wai/wai-rs/Cargo.toml +++ b/wai/wai-rs/Cargo.toml @@ -97,6 +97,13 @@ num-bigint = { version = "0.4", optional = true } # oer: exact decimal arithm # verifier checks S < L itself (§3.1 step 3). curve25519-dalek = { version = "4", optional = true } +# ---- the integer operator set (behind `neural_int`) ---- +# `wai-ops/1` (extensions/integer-operators.md): the integer decoders lower their +# parameter sets to WHG1 graphs, load them under their capability's profile (the +# bounds certificate) and run them through its interpreter, with this crate's fast +# kernels behind it (src/int_ops.rs). no_std, no dependencies of its own. +wai-ops = { path = "../wai-ops", optional = true } + # ---- quantum (optional, behind the `quantum*` features) ---- # The quantum stack now lives in its own dependency-light crate so it can be used # without the media/codec chain. `wai` re-exports it unchanged. @@ -127,7 +134,12 @@ ffi = [] # construction — so it sits behind its own light feature, checkable in a CI # matrix with no ONNX Runtime present. `neural` pulls it in. blake3 is for the # conformance corpus's content hash (the same hash the other classes freeze). -neural_int = ["dep:blake3"] +neural_int = ["dep:blake3", "dep:wai-ops"] +# Test-only: the integer decoders' direct paths as they were before they ran through +# the integer operator set (`int_synth::legacy`, …). The differential tests decode +# every committed vector and random sets and payloads both ways and require the same +# output or the same refusal (tests/int_ops_differential.rs). No sink enables it. +legacy_int = ["neural_int"] # Deterministic integer generative synthesis (`wai.generate.*`): byte-exact # generation pinned to (model, prompt, seed, steps) — the create-side of the # integer contract. Pure i64, no ONNX/float (serde_json is already a base dep), diff --git a/wai/wai-rs/src/codecs/mod.rs b/wai/wai-rs/src/codecs/mod.rs index d8f0c00fc..9b941602c 100644 --- a/wai/wai-rs/src/codecs/mod.rs +++ b/wai/wai-rs/src/codecs/mod.rs @@ -1330,6 +1330,35 @@ pub fn capability_replicate_class(cap: &str) -> Option { }) } +/// The integer operator set a capability's decode runs through +/// (`extensions/integer-operators.md` §11): the registry's `ops` member. `profile` is the +/// profile every conforming parameter set of the capability meets, and a set whose +/// certificate does not meet it is refused; `subset` is `full` or `npu`; `lowering` +/// names the §13 lowering the capability's sets are lowered by; `rans64` and `bucket` +/// fix the modes of the stage operators its driver uses (§5E). Strengthening a profile +/// or a subset narrows the sets a capability accepts, a breaking change (SPEC §8). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct OpsMember { + pub profile: &'static str, + pub subset: &'static str, + pub lowering: &'static str, + /// `rans64_decode`'s `(table, escape, end)` (E1), where the driver decodes one. + pub rans64: Option<(&'static str, &'static str, &'static str)>, + /// `bucket`'s rule (E3), where the driver buckets a scale. + pub bucket: Option<&'static str>, +} + +/// The `ops` member of `cap`, for a capability decoded through the integer operator +/// set; `None` for any other. A capability gains one when its decoder runs its +/// networks through their lowering (`extensions/integer-operators.md` §15.1). +pub fn capability_ops(cap: &str) -> Option { + let ops = |lowering, rans64, bucket| Some(OpsMember { profile: "acc64", subset: "full", lowering, rans64, bucket }); + match cap { + CAP_NEURAL_INT_SYNTH => ops("int_synth/1", None, None), + _ => None, + } +} + /// Whether a capability's **neural** decode is integer-exact by construction — /// the integer-entropy contract. Isolates exactly the neural codecs WAI itself /// guarantees byte-identical on every machine, so a deployer can require diff --git a/wai/wai-rs/src/codecs/registry.rs b/wai/wai-rs/src/codecs/registry.rs index ce6f4678e..4043894b0 100644 --- a/wai/wai-rs/src/codecs/registry.rs +++ b/wai/wai-rs/src/codecs/registry.rs @@ -564,6 +564,9 @@ pub struct Registration { /// See [`ROWS`]: a designation, or `None` for the edition current at `registered`. pub edition: Option<&'static str>, pub registered: &'static str, + /// The integer operator set the capability decodes through, if it does + /// (`extensions/integer-operators.md` §11). + pub ops: Option, } /// Every registered string — payload, companion, derivation and record — sorted @@ -585,6 +588,7 @@ pub fn registrations() -> Vec { spec_section: section, edition, registered, + ops: super::capability_ops(cap), }) .collect(); for op in derivation::derivation_operations() { @@ -602,6 +606,7 @@ pub fn registrations() -> Vec { spec_section: S_PRIOR, edition: Some("ISO/IEC 23001-16:2021"), registered: DERIVATIONS_REGISTERED, + ops: None, }); } v.sort_by(|a, b| a.capability.cmp(b.capability)); @@ -746,7 +751,7 @@ pub fn capabilities_json() -> String { let caps: Vec = registrations() .iter() .map(|r| { - json!({ + let mut v = json!({ "capability": r.capability, "kind": r.kind.label(), "status": r.status.label(), @@ -759,7 +764,11 @@ pub fn capabilities_json() -> String { "spec_section": r.spec_section, "edition": r.edition, "registered": r.registered, - }) + }); + if let Some(o) = r.ops { + v["ops"] = ops_json(&o); + } + v }) .collect(); let mut ids: Vec<&Identifier> = IDENTIFIERS.iter().collect(); @@ -790,6 +799,24 @@ pub fn capabilities_json() -> String { })) } +/// The registry's `ops` member (`extensions/integer-operators.md` §11), its members in +/// that section's order. +fn ops_json(o: &super::OpsMember) -> serde_json::Value { + use serde_json::{json, Map, Value}; + let mut v = json!({ "set": "wai-ops/1", "profile": o.profile, "subset": o.subset, "lowering": o.lowering }); + let mut stage = Map::new(); + if let Some((table, escape, end)) = o.rans64 { + stage.insert("rans64".into(), json!({ "table": table, "escape": escape, "end": end })); + } + if let Some(b) = o.bucket { + stage.insert("bucket".into(), Value::from(b)); + } + if !stage.is_empty() { + v["stage"] = Value::Object(stage); + } + v +} + fn nullable_enum(labels: &[&str]) -> serde_json::Value { let mut e: Vec = labels.iter().map(|l| serde_json::Value::from(*l)).collect(); e.push(serde_json::Value::Null); @@ -845,6 +872,35 @@ pub fn capabilities_schema() -> String { "type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$", "description": "The date of the first commit of this repository that registers the string; the specification entered the repository on 2026-06-12." + }, + "ops": { + "type": "object", + "description": "The integer operator set the capability decodes through (extensions/integer-operators.md §11). Absent for a capability that does not.", + "additionalProperties": false, + "required": ["set", "profile", "subset", "lowering"], + "properties": { + "set": { "const": "wai-ops/1" }, + "profile": { "enum": ["acc32", "acc64"] }, + "subset": { "enum": ["full", "npu"] }, + "lowering": { "type": "string" }, + "stage": { + "type": "object", + "additionalProperties": false, + "properties": { + "rans64": { + "type": "object", + "additionalProperties": false, + "required": ["table", "escape", "end"], + "properties": { + "table": { "enum": ["t4097", "t2"] }, + "escape": { "enum": ["lenient", "strict"] }, + "end": { "enum": ["canonical", "final_only"] } + } + }, + "bucket": { "enum": ["ceil", "floor"] } + } + } + } } } } diff --git a/wai/wai-rs/src/int_ops.rs b/wai/wai-rs/src/int_ops.rs new file mode 100644 index 000000000..1ff118e61 --- /dev/null +++ b/wai/wai-rs/src/int_ops.rs @@ -0,0 +1,221 @@ +//! The integer decoders' networks through `wai-ops/1`, the integer operator set +//! (`extensions/integer-operators.md`). +//! +//! A decoder lowers its parameter set to `WHG1` graphs (§13), loads each graph under +//! its capability's profile — load steps 1–8 of §10.5, the last of which is the bounds +//! pass whose certificate must meet that profile (§8.6) — and runs it through the +//! operator set's interpreter ([`wai_ops::run_with`]). The interpreter owns the plan, +//! every range check and every refusal; this module only lends it this crate's fast +//! kernels ([`crate::int_kernels`]) for the operators they cover, under the current +//! [`crate::int_kernels::Policy`]: +//! +//! - under [`KernelPath::Reference`](crate::int_kernels::KernelPath) the interpreter +//! computes every operator itself, so a decode is the operator set's reference; +//! - under [`KernelPath::Fast`](crate::int_kernels::KernelPath) a convolution, a +//! transposed convolution and an inverse GDN run on the fast kernels, which form the +//! same exact sums (`int_kernels`' equivalence rule) and finish each output with the +//! operator set's own requantisation ([`wai_ops::interp::rq`]). Where a fast kernel +//! declines (operands past its narrowed types, geometry it does not cover), the +//! interpreter computes the operator. +//! +//! Either way the integers are the interpreter's (§6), which `tests/int_kernels_vectors.rs` +//! checks on every committed vector and the tests below check operator by operator. + +use wai_ops::graph::{Graph, Loaded, Op, Params}; +use wai_ops::interp::{Kernels, Sequence, Tensor}; +use wai_ops::{Code, Profile, Refusal}; + +use crate::int_kernels::{policy, ConvGeom, KernelPath}; + +/// Load a lowered graph as a sink does: from its canonical bytes, under `acc64`, the +/// profile every lowering of §13 declares (§11). Steps 1–8 of §10.5; a refusal is +/// `model_mismatch`, or `too_large` for a size. +pub fn load(g: &Graph) -> Result { + wai_ops::graph::load(&g.to_bytes(), Profile::Acc64) +} + +/// One decode of a loaded graph with one data input and no state: its first output. +pub fn run1(g: &Loaded, x: Tensor, scalars: &[i64]) -> Result { + let mut out = wai_ops::run_with(g, &[x], scalars, &mut Sequence::new(), &Host)?; + Ok(out.swap_remove(0)) +} + +/// Whether a refusal is about the values a decode presented (a `check` range, a +/// scalar out of its range), rather than the set or the shapes. +pub fn is_range(r: &Refusal) -> bool { + r.code == Code::FieldRange +} + +/// This crate's fast kernels, lent to the interpreter (see the module docs). +pub struct Host; + +impl Kernels for Host { + fn eval(&self, op: &Op, ins: &[&Tensor], out: &[(u32, u32, u32)]) -> Option> { + if policy().path != KernelPath::Fast { + return None; + } + let x = ins[0]; + let &(oc, oh, ow) = out.first()?; + let (cin, h, w) = (x.c as usize, x.h as usize, x.w as usize); + let u = |v: &u32| *v as usize; + let data = match &op.p { + Params::Conv { cout, kh, kw, sh, sw, ph, pw, groups, rq, w: wt, .. } => { + let g = ConvGeom::conv(cin, h, w, u(cout), u(groups), u(kh), u(kw), u(sh), u(sw), u(ph), u(pw), x.data.len(), wt.len())?; + conv(&g, &x.data, wt, rq, (oh, ow))? + } + Params::ConvT { cout, kh, kw, sh, sw, ph, pw, oph, opw, rq, w: wt, .. } => { + let g = ConvGeom::transposed(cin, h, w, u(cout), u(kh), u(kw), u(sh), u(sw), u(ph), u(pw), u(oph), u(opw), x.data.len(), wt.len())?; + conv(&g, &x.data, wt, rq, (oh, ow))? + } + Params::Igdn { gf, frac, gamma, beta, .. } => crate::int_kernels::igdn(&x.data, cin, h * w, *frac, gamma, beta, *gf)?, + _ => return None, + }; + Some(vec![Tensor::new(oc, oh, ow, data)]) + } +} + +/// A (transposed) convolution on the fast kernel, each output finished with the +/// operator set's requantisation. `None` where the kernel declines, where its sizes are +/// not the plan's, or where a requantised value leaves `i64` (which no certified graph +/// forms: the interpreter then computes the operator, and refuses it). +fn conv(g: &ConvGeom, x: &[i64], wt: &[i64], rq: &wai_ops::graph::Rq, (oh, ow): (u32, u32)) -> Option> { + use std::sync::atomic::{AtomicBool, Ordering}; + if (g.ho, g.wo) != (oh as usize, ow as usize) { + return None; + } + let wide = AtomicBool::new(false); + let out = crate::int_kernels::conv_acc_i128(g, x, wt, |oc, acc| match wai_ops::interp::rq(rq, oc, acc).map(i64::try_from) { + Some(Ok(v)) => v, + _ => { + wide.store(true, Ordering::Relaxed); + 0 + } + })?; + (!wide.load(Ordering::Relaxed)).then_some(out) +} + +/// The fast kernels behind the interpreter against the interpreter alone, operator by +/// operator, on randomised graphs: every output element must be the same integer under +/// every fast policy, and a refusal the same refusal. This is the per-operator half of +/// the equivalence the decoders rely on; the whole-decoder half is +/// `tests/int_kernels_vectors.rs`. +#[cfg(test)] +mod equivalence { + use super::*; + use crate::int_kernels::testkit::{Rng, FAST}; + use crate::int_kernels::{with_policy, Policy}; + use wai_ops::graph::{BiasKind, InputRec, Round, Rq, TensorRec, opcode, CAP}; + + fn graph(x: TensorRec, code: u8, cout: u32, p: Params) -> Graph { + Graph { + max_elems: CAP as u32, + scalars: vec![], + inputs: vec![InputRec { tensor: 0, max_h: 0, max_w: 0, kind: 0, src: 0, init: 0 }], + outputs: vec![1], + tensors: vec![x, TensorRec::derived(cout)], + ops: vec![Op { code, ins: vec![0], outs: vec![1], p }], + } + } + + fn rq(g: &mut Rng, cout: u32, round: Round, bias_kind: BiasKind, mult: bool) -> Rq { + let n = cout as usize; + let mult_v: Vec = if mult { g.vec(n, 1 << 20, 0) } else { vec![] }; + let bias: Vec = if bias_kind == BiasKind::None { vec![] } else { g.vec(n, 1 << 24, 10) }; + Rq { + round, + bias_kind, + m_kind: if mult { 2 } else { 0 }, + s_kind: 1, + m_bytes: if mult { 8 } else { 0 }, + b_bytes: if bias_kind == BiasKind::None { 0 } else { 8 }, + mult: mult_v, + shift: vec![g.below(40) as u8], + bias, + } + } + + /// Run `g` on `x` under the interpreter alone and under every fast policy; the + /// results must agree. Returns whether the graph loaded (some random graphs fail + /// their certificate, which is then the same refusal under every policy). + fn agree(g: &Graph, x: &Tensor) -> bool { + let Ok(l) = load(g) else { return false }; + let want = with_policy(Policy::reference(), || run1(&l, x.clone(), &[])); + for p in FAST { + let got = with_policy(p, || run1(&l, x.clone(), &[])); + assert_eq!(got, want, "{p:?} on {:?}", g.ops[0].code); + } + true + } + + #[test] + fn convolutions_equal_the_interpreter() { + let mut r = Rng(0x0D2C_0001); + let mut loaded = 0; + for case in 0..400 { + let (k, s) = (1 + r.below(5), 1 + r.below(3)); + let (cin, cout) = (1 + r.below(5) as u32, 1 + r.below(6) as u32); + let (h, w) = (1 + r.below(6) as u32, 1 + r.below(8) as u32); + let transposed = case % 2 == 1; + let round = [Round::Floor, Round::HalfUp, Round::HalfEven][r.below(3)]; + let bias_kind = [BiasKind::None, BiasKind::Pre, BiasKind::Post][r.below(3)]; + let with_mult = r.below(2) == 1; + let q = rq(&mut r, cout, round, bias_kind, with_mult); + let xmax = [127i64, 1 << 12, 1 << 20][r.below(3)]; + let wt = r.vec(cin as usize * cout as usize * k * k, 1 << 14, 10); + let (code, p) = if transposed { + let pad = r.below(k); + let op = if s > 1 { r.below(s) } else { 0 }; + (opcode::CONV_TRANSPOSE, Params::ConvT { + cout, kh: k as u32, kw: k as u32, sh: s as u32, sw: s as u32, ph: pad as u32, pw: pad as u32, + oph: op as u32, opw: op as u32, w_bytes: 8, rq: q, w: wt, + }) + } else { + let pad = r.below(k); + (opcode::CONV, Params::Conv { + cout, kh: k as u32, kw: k as u32, sh: s as u32, sw: s as u32, ph: pad as u32, pw: pad as u32, + groups: 1, w_bytes: 8, rq: q, w: wt, + }) + }; + let g = graph(TensorRec::check(cin, -xmax, xmax), code, cout, p); + let x = Tensor::new(cin, h, w, r.vec((cin * h * w) as usize, xmax, 30)); + loaded += agree(&g, &x) as u32; + } + assert!(loaded > 300, "only {loaded} of 400 random graphs loaded"); + } + + #[test] + fn inverse_gdn_equals_the_interpreter() { + let mut r = Rng(0x0D2C_0002); + let mut loaded = 0; + for _ in 0..200 { + let c = 1 + r.below(6) as u32; + let (h, w) = (1 + r.below(5) as u32, 1 + r.below(7) as u32); + let (gf, frac) = (r.below(20) as u32, 1 + r.below(20) as u32); + let gamma: Vec = r.vec((c * c) as usize, 1 << 16, 20).into_iter().map(i64::abs).collect(); + let beta: Vec = r.vec(c as usize, 1 << 30, 0).into_iter().map(|b| b.abs() + 1).collect(); + let xmax = [1i64 << 10, 1 << 20, 1 << 31][r.below(3)]; + let p = Params::Igdn { gf, frac, g_bytes: 4, gamma, beta }; + let g = graph(TensorRec::check(c, -xmax, xmax), opcode::IGDN, c, p); + let x = Tensor::new(c, h, w, r.vec((c * h * w) as usize, xmax, 20)); + loaded += agree(&g, &x) as u32; + } + assert!(loaded > 150, "only {loaded} of 200 random graphs loaded"); + } + + /// A value outside an input's `check` range is refused by the interpreter before any + /// kernel runs, under every policy alike. + #[test] + fn a_check_range_refuses_before_the_kernel() { + let p = Params::Conv { + cout: 1, kh: 1, kw: 1, sh: 1, sw: 1, ph: 0, pw: 0, groups: 1, w_bytes: 1, + rq: Rq::plain(Round::Floor, 0), w: vec![3], + }; + let g = graph(TensorRec::check(1, -10, 10), opcode::CONV, 1, p); + let l = load(&g).unwrap(); + for pol in [Policy::reference(), Policy::fast(1), Policy::fast(0)] { + assert_eq!(with_policy(pol, || run1(&l, Tensor::new(1, 1, 2, vec![10, -10]), &[])).unwrap().data, vec![30, -30]); + let r = with_policy(pol, || run1(&l, Tensor::new(1, 1, 2, vec![10, 11]), &[])).unwrap_err(); + assert!(is_range(&r), "{pol:?}: {r:?}"); + } + } +} diff --git a/wai/wai-rs/src/int_synth.rs b/wai/wai-rs/src/int_synth.rs index 409c4f94c..fd06c9624 100644 --- a/wai/wai-rs/src/int_synth.rs +++ b/wai/wai-rs/src/int_synth.rs @@ -25,7 +25,7 @@ //! thing that makes cross-hardware bit-exactness real — lives here and is exact //! whatever the weights. //! -//! Wire format (little-endian): +//! Wire format (little-endian), `extensions/integer-payloads.md` §13: //! u32 H, u32 W, u16 C, u8 S_log2, u8 _reserved (12 B) //! C*S*S bytes int8 latents, channel-major //! @@ -34,9 +34,16 @@ //! weight:[i32; in*out*k*k] (layout ic,oc,ky,kx), //! bias:[i32; out_ch], shift:u32, relu:bool }, … ] } //! The final layer MUST output 3 channels at H×W; values are clamped to [0,255]. +//! +//! The decode is the graph `synth` of the set's lowering `int_synth/1` +//! (`extensions/integer-operators.md` §13.1), loaded under `acc64` and run through the +//! integer operator set ([`crate::int_ops`]). The direct path it replaced is kept, +//! test-only, as [`legacy`] (`legacy_int`): the reference the lowering was checked +//! against, decoder against decoder. use std::path::Path; +#[cfg(any(test, feature = "legacy_int"))] use serde::Deserialize; const HEADER: usize = 12; @@ -66,6 +73,7 @@ pub struct IntImage { pub height: u32, } +#[cfg(any(test, feature = "legacy_int"))] #[derive(Debug, Clone, Deserialize)] struct LayerSpec { in_ch: usize, @@ -87,6 +95,7 @@ struct LayerSpec { relu: bool, } +#[cfg(any(test, feature = "legacy_int"))] #[derive(Debug, Clone, Deserialize)] struct SynthSpec { layers: Vec, @@ -97,6 +106,7 @@ struct SynthSpec { /// ([`crate::int_kernels`]) unless the current [`crate::int_kernels::Policy`] selects /// the reference; both give the same integer for every output element, and every /// error case is left to the reference so errors are reported exactly as before. +#[cfg(any(test, feature = "legacy_int"))] fn conv_transpose2d( input: &[i64], cin: usize, @@ -137,6 +147,7 @@ fn conv_transpose2d( /// where the reference's size arithmetic fails. `ConvGeom::transposed`'s bound on /// the uncropped plane already keeps every term far below `i64::MAX`; this check /// states the rule where the reference's arithmetic is, independent of that cap. +#[cfg(any(test, feature = "legacy_int"))] fn reference_output_side(n: usize, l: &LayerSpec) -> Option { let v = (n as i64) .checked_sub(1)? @@ -148,6 +159,7 @@ fn reference_output_side(n: usize, l: &LayerSpec) -> Option { } /// The scalar reference kernel the fast path is checked against, kept verbatim. +#[cfg(any(test, feature = "legacy_int"))] mod reference { use super::{IntSynthError, LayerSpec}; @@ -259,7 +271,39 @@ pub fn decode_bytes(payload: &[u8], spec_json: &[u8]) -> Result Result { + let (h, w, c, s) = header(payload)?; + let refused = |what: &str, r: wai_ops::Refusal| IntSynthError::InvalidPayload(format!("int_synth {what}: {} at {:?}", r.code.as_str(), r.at)); + let set = wai_ops::lower::int_synth(spec_json).map_err(|r| refused("int_synth.json does not lower", r))?; + let g = crate::int_ops::load(set.get("synth").expect("int_synth/1 lowers to the graph synth")).map_err(|r| refused("set does not load", r))?; + let latents: Vec = payload[HEADER..].iter().map(|&b| b as i8 as i64).collect(); + let out = crate::int_ops::run1(&g, wai_ops::Tensor::new(c, s, s, latents), &[]).map_err(|r| refused("decode", r))?; + if (out.c, out.h as usize, out.w as usize) != (3, h, w) { + return Err(IntSynthError::InvalidPayload(format!( + "int_synth output [{},{},{}] != [3,{h},{w}]", out.c, out.h, out.w))); + } + // `clamp(0, 255)` ends the graph, so every value is a byte. + let hw = h * w; + let mut rgb = vec![0u8; hw * 3]; + for (k, &v) in out.data.iter().enumerate() { + rgb[(k % hw) * 3 + k / hw] = v as u8; + } + Ok(IntImage { rgb, width: w as u32, height: h as u32 }) +} + +/// The payload's header (`extensions/integer-payloads.md` §13): `(H, W, C, S)`, after +/// its checks. The side is `2^S_log2`; `C·S·S` is formed without overflow and is at +/// most `2^26`, and the payload is exactly `12 + C·S·S` bytes. +fn header(payload: &[u8]) -> Result<(usize, usize, u32, u32), IntSynthError> { if payload.len() < HEADER { return Err(IntSynthError::InvalidPayload(format!( "int_synth payload {} B < {HEADER} B header", payload.len()))); @@ -267,50 +311,86 @@ pub fn decode_from_spec(payload: &[u8], spec_json: &[u8]) -> Result = payload[HEADER..].iter().map(|&b| b as i8 as i64).collect(); - let (mut cin, mut ch, mut cw) = (c, s, s); - for (i, l) in spec.layers.iter().enumerate() { - if l.in_ch != cin { + /// The legacy decode of [`super::decode_from_spec`]'s arguments. + pub fn decode_from_spec(payload: &[u8], spec_json: &[u8]) -> Result { + if payload.len() < HEADER { return Err(IntSynthError::InvalidPayload(format!( - "int_synth layer {i} in_ch {} != current {cin}", l.in_ch))); + "int_synth payload {} B < {HEADER} B header", payload.len()))); } - let (out, ho, wo) = conv_transpose2d(&cur, cin, ch, cw, l)?; - cur = out; - cin = l.out_ch; - ch = ho; - cw = wo; - } - if cin != 3 || ch != h || cw != w { - return Err(IntSynthError::InvalidPayload(format!( - "int_synth output [{cin},{ch},{cw}] != [3,{h},{w}]"))); - } + let h = u32::from_le_bytes(payload[0..4].try_into().unwrap()) as usize; + let w = u32::from_le_bytes(payload[4..8].try_into().unwrap()) as usize; + let c = u16::from_le_bytes(payload[8..10].try_into().unwrap()) as usize; + // The side is 2^payload[10]. A shift of the word's width or more panics with + // overflow checks on and masks without them, so the same bytes would decode two + // ways; and C·S·S must be formed without overflow. Neither can match a payload's + // length when it overflows, so both are refused here as a length mismatch. + let n = 1usize.checked_shl(payload[10] as u32) + .and_then(|s| c.checked_mul(s)?.checked_mul(s).map(|n| (s, n))); + let Some((s, _)) = n.filter(|&(_, n)| HEADER.checked_add(n) == Some(payload.len())) else { + return Err(IntSynthError::InvalidPayload(format!( + "int_synth payload {} B is not {HEADER} + C·S·S with C = {c}, S = 2^{}", payload.len(), payload[10]))); + }; - let mut rgb = vec![0u8; h * w * 3]; - let hw = h * w; - for y in 0..h { - for x in 0..w { - let pi = y * w + x; - for ch_i in 0..3 { - rgb[pi * 3 + ch_i] = cur[ch_i * hw + pi].clamp(0, 255) as u8; + let spec: SynthSpec = serde_json::from_slice(spec_json) + .map_err(|e| IntSynthError::InvalidPayload(format!("int_synth.json parse: {e}")))?; + + // int8 latents → i64. + let mut cur: Vec = payload[HEADER..].iter().map(|&b| b as i8 as i64).collect(); + let (mut cin, mut ch, mut cw) = (c, s, s); + for (i, l) in spec.layers.iter().enumerate() { + if l.in_ch != cin { + return Err(IntSynthError::InvalidPayload(format!( + "int_synth layer {i} in_ch {} != current {cin}", l.in_ch))); + } + let (out, ho, wo) = conv_transpose2d(&cur, cin, ch, cw, l)?; + cur = out; + cin = l.out_ch; + ch = ho; + cw = wo; + } + if cin != 3 || ch != h || cw != w { + return Err(IntSynthError::InvalidPayload(format!( + "int_synth output [{cin},{ch},{cw}] != [3,{h},{w}]"))); + } + + let mut rgb = vec![0u8; h * w * 3]; + let hw = h * w; + for y in 0..h { + for x in 0..w { + let pi = y * w + x; + for ch_i in 0..3 { + rgb[pi * 3 + ch_i] = cur[ch_i * hw + pi].clamp(0, 255) as u8; + } } } + Ok(IntImage { rgb, width: w as u32, height: h as u32 }) } - Ok(IntImage { rgb, width: w as u32, height: h as u32 }) } #[cfg(test)] diff --git a/wai/wai-rs/src/lib.rs b/wai/wai-rs/src/lib.rs index 190b51941..267c46749 100644 --- a/wai/wai-rs/src/lib.rs +++ b/wai/wai-rs/src/lib.rs @@ -57,6 +57,11 @@ pub mod int_synth; // on wasm. #[cfg(feature = "neural_int")] pub mod int_kernels; +// The integer decoders' networks through the integer operator set `wai-ops/1` +// (extensions/integer-operators.md): lowered, loaded under the certificate's profile +// and run by its interpreter, with the fast kernels above behind it. +#[cfg(feature = "neural_int")] +pub mod int_ops; // Integer Gaussian-conditional entropy — the reusable scale→bucket primitive // that makes a conditional codec's entropy decode hardware-deterministic (the diff --git a/wai/wai-rs/tests/int_kernels_geometry.rs b/wai/wai-rs/tests/int_kernels_geometry.rs index 8d7b6e4d7..0db50de0a 100644 --- a/wai/wai-rs/tests/int_kernels_geometry.rs +++ b/wai/wai-rs/tests/int_kernels_geometry.rs @@ -14,9 +14,10 @@ //! The reference computes each of the first three in a handful of operations. Every //! policy must return the same integers (checked against a hand computation), and //! the fast path must not overflow, or allocate or loop in proportion to the stride -//! or the padding, where the reference does not. In the last, the reference panics -//! (or, for int_synth with overflow checks off, returns), and every policy must do -//! the same. The int_audio 1-D transposed convolution has the +//! or the padding, where the reference does not. In the last, the reference panics, +//! and every policy must do the same. int_synth decodes through its lowering +//! (integer-operators §13.1), whose fields are 32 bits and whose run-time plan caps +//! every size it forms at 2^26: where a case is past either, every policy refuses it. The int_audio 1-D transposed convolution has the //! same stride check beside its kernel (`int_audio` unit tests). CI runs this file //! beside `int_kernels_vectors` on every `wai-conformance` leg, with overflow checks //! on and off. @@ -40,13 +41,6 @@ fn same_under_every_policy(what: &str, f: impl F want } -/// Whether this build traps on integer overflow. Detected by performing one, since -/// `cfg!(debug_assertions)` only usually coincides with `overflow-checks`. -#[cfg(target_pointer_width = "64")] -fn overflow_checks() -> bool { - std::panic::catch_unwind(|| std::hint::black_box(i64::MAX) + std::hint::black_box(1)).is_err() -} - /// Run under every policy, catching a panic. The outcome (the value, or that the /// call panicked) must be the same under every policy. Returns the reference /// kernels' outcome, `None` for a panic. The panic message is not compared: a fast @@ -129,8 +123,14 @@ fn a_1x1_input_upsampled_by_any_stride() { let got = same_under_every_policy(&format!("int_synth, stride {s}"), || { wai::int_synth::decode_from_spec(&payload, &spec).map(|img| img.rgb) }); - // centre taps 5, 14, 23 of the three output channels, times 5 - assert_eq!(got, Ok(vec![25, 70, 115]), "int_synth, stride {s}"); + if s <= u32::MAX as usize { + // centre taps 5, 14, 23 of the three output channels, times 5 + assert_eq!(got, Ok(vec![25, 70, 115]), "int_synth, stride {s}"); + } else { + // int_synth decodes through its lowering (integer-operators §13.1), whose + // `WHG1` fields are 32 bits: a stride past them is refused, a planned break. + assert!(got.is_err(), "int_synth, stride {s}: a stride past 32 bits is refused"); + } } } @@ -170,10 +170,20 @@ fn padding_far_beyond_the_kernel() { let p = 1usize << 12; // Transposed: pad 2^12 with output padding 2^13 − 2 restores a 1×1 output that // no tap reaches (it sits 2^12 rows into the uncropped plane), so it is the bias. + // int_synth decodes through its lowering, whose run-time plan caps the uncropped + // output a transposed convolution forms at 2^26 elements (integer-operators §5.3); + // this one is 3 · 8193 · 8193, so the decode is refused, a planned break. One row + // and column less output padding than the cap allows still decodes to the bias. let (payload, spec) = (synth_payload(5), synth_spec(1, p, 2 * p - 2, [10, 20, 30])); let got = same_under_every_policy("int_synth, pad 2^12", || { wai::int_synth::decode_from_spec(&payload, &spec).map(|img| img.rgb) }); + assert!(got.is_err(), "int_synth, pad 2^12: the uncropped output is past the cap"); + let q = 1usize << 11; + let (payload, spec) = (synth_payload(5), synth_spec(1, q, 2 * q - 2, [10, 20, 30])); + let got = same_under_every_policy("int_synth, pad 2^11", || { + wai::int_synth::decode_from_spec(&payload, &spec).map(|img| img.rgb) + }); assert_eq!(got, Ok(vec![10, 20, 30])); // Regular: a 1×1 input, 1×1 kernel, pad 2^12, stride 2^13 — a 2×2 output whose // samples all fall in the padding, so each is the bias. @@ -270,24 +280,22 @@ fn int_synth_where_the_reference_output_size_overflows_i64() { // although every `usize` step fits. With overflow checks on it panics; with them // off the wrapped sum is still 3 and the decode returns. Only latent (1, 1) // reaches the output, through every tap. + // int_synth decodes through its lowering (integer-operators §13.1). Its direct path + // panicked here with overflow checks on, and with them off returned the wrapped + // size's output; through the lowering the decode is refused in every build and under + // every policy: a stride past 32 bits does not fit `WHG1`. A planned break. let s = 1usize << 62; let (payload, spec) = (synth_payload_2x2(3, 3, [1, 2, 3, 4]), synth_spec(s, s, s, [0, 0, 0])); let got = same_outcome_under_every_policy("int_synth, 2×2 latent, stride = pad = out_pad = 2^62", || { wai::int_synth::decode_from_spec(&payload, &spec).map(|img| img.rgb) }); - if overflow_checks() { - assert_eq!(got, None, "the reference panics on its i64 output size"); - } else { - // rgb[(3y + x)·3 + c] = 4 · weight[c][y][x] = 4 · (9c + 3y + x + 1) - let want: Vec = (0..9).flat_map(|px| (0..3).map(move |c| (4 * (9 * c + px + 1)) as u8)).collect(); - assert_eq!(got, Some(Ok(want)), "the wrapped output size is 3"); - } + assert!(matches!(got, Some(Err(_))), "refused, not panicked: {got:?}"); - // The u32 fields: no `i64` step overflows, and int_synth's reference allocates - // no uncropped plane, so it returns. A 4×4 output that no tap reaches: the bias. + // The u32 fields fit `WHG1`, but the uncropped side (2 − 1)·(2^32 − 1) + 3 is past + // the run-time plan's cap of 2^26: refused (the direct path returned the bias). let (payload, spec) = (synth_payload_2x2(4, 4, [1, 2, 3, 4]), synth_spec(S32, P32, 0, [10, 20, 30])); let got = same_outcome_under_every_policy("int_synth, 2×2 latent, u32 stride and pad", || { wai::int_synth::decode_from_spec(&payload, &spec).map(|img| img.rgb) }); - assert_eq!(got, Some(Ok([10u8, 20, 30].repeat(16))), "u32 fields: the bias alone"); + assert!(matches!(got, Some(Err(_))), "u32 fields: refused by the cap: {got:?}"); } diff --git a/wai/wai-rs/tests/int_ops_differential.rs b/wai/wai-rs/tests/int_ops_differential.rs new file mode 100644 index 000000000..e79bbdd64 --- /dev/null +++ b/wai/wai-rs/tests/int_ops_differential.rs @@ -0,0 +1,222 @@ +//! Each integer decoder through the integer operator set against its direct path as it +//! was before (`legacy_int`, test-only): on every committed set and payload, and on +//! random ones, the two must give the same output, or both refuse with the same code. +//! The only inputs on which they may differ are the planned breaks the operator set +//! makes (`extensions/integer-operators.md` §15.3), which are new refusals; each test +//! states the ones its generator can reach and checks that only those differ. +//! +//! The random cases run for a fixed count by default. `WAI_DIFF_SECS` runs each random +//! test for that many seconds instead (the nightly run), with `WAI_DIFF_SEED` as the +//! first seed. +#![cfg(feature = "legacy_int")] + +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +fn wai() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..") +} + +fn read(p: &Path) -> Vec { + std::fs::read(p).unwrap_or_else(|e| panic!("{}: {e}", p.display())) +} + +/// A splitmix64 sequence. +struct Rng(u64); + +impl Rng { + fn next(&mut self) -> u64 { + self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15); + let mut z = self.0; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) + } + fn below(&mut self, n: u64) -> u64 { + self.next() % n + } + /// A value in `[−r, r]`. + fn signed(&mut self, r: i64) -> i64 { + (self.below(2 * r as u64 + 1) as i64) - r + } +} + +/// Run `case` on successive seeds: `n` of them, or for `WAI_DIFF_SECS` seconds. +/// Returns how many ran. +fn cases(name: &str, n: u64, mut case: impl FnMut(&mut Rng, u64)) -> u64 { + let secs: Option = std::env::var("WAI_DIFF_SECS").ok().and_then(|s| s.parse().ok()); + let seed0: u64 = std::env::var("WAI_DIFF_SEED").ok().and_then(|s| s.parse().ok()).unwrap_or(0x0D2C); + let until = secs.map(|s| Instant::now() + Duration::from_secs(s)); + let mut k = 0u64; + loop { + match until { + Some(t) if Instant::now() >= t => break, + None if k >= n => break, + _ => {} + } + let mut r = Rng(seed0 ^ k.wrapping_mul(0xD1B5_4A32_D192_ED03)); + case(&mut r, k); + k += 1; + } + eprintln!("{name}: {k} cases"); + k +} + +// ------------------------------------------------------------------ int_synth (M1) + +/// Every committed `int_synth` set and payload, including the copies the browser demo +/// serves. +fn int_synth_committed() -> Vec<(PathBuf, PathBuf)> { + let mut v = Vec::new(); + for root in ["neural-conformance/vectors", "wai-web/demo/int_synth"] { + let dir = wai().join(root); + let mut names: Vec<_> = std::fs::read_dir(&dir).unwrap().filter_map(|e| e.ok()).map(|e| e.path()).filter(|p| p.join("int_synth.json").exists()).collect(); + names.sort(); + for d in names { + v.push((d.join("int_synth.json"), d.join("latent.bin"))); + } + } + assert!(v.len() >= 7, "every committed int_synth vector: found {}", v.len()); + v +} + +/// The new load rules (§15.3 item 1) affect no committed `int_synth` set or payload: +/// every set lowers and loads under `acc64`, and every payload's reserved byte is 0, +/// its `C` at least 1 and its latents within the cap. The falsification set +/// `overflow_acc64` is the one set made to fail its certificate, and does. +#[test] +fn int_synth_new_rules_affect_no_committed_set_or_payload() { + let mut sets: Vec = int_synth_committed().into_iter().map(|(s, _)| s).collect(); + for s in ["synth", "claim_acc32"] { + sets.push(wai().join(format!("ops-conformance/sets/{s}/int_synth.json"))); + } + for s in &sets { + let set = wai_ops::lower::int_synth(&read(s)).unwrap_or_else(|r| panic!("{}: does not lower: {r:?}", s.display())); + wai::int_ops::load(set.get("synth").unwrap()).unwrap_or_else(|r| panic!("{}: does not load: {r:?}", s.display())); + } + let overflow = wai_ops::lower::int_synth(&read(&wai().join("ops-conformance/sets/overflow_acc64/int_synth.json"))).unwrap(); + assert!(wai::int_ops::load(overflow.get("synth").unwrap()).is_err(), "the falsification set fails acc64"); + for (_, p) in int_synth_committed() { + let b = read(&p); + let c = u16::from_le_bytes([b[8], b[9]]) as usize; + let s = 1usize << b[10]; + assert_eq!(b[11], 0, "{}: reserved byte", p.display()); + assert!(c >= 1 && c * s * s <= 1 << 26, "{}: C and the cap", p.display()); + } +} + +#[test] +fn int_synth_committed_vectors_decode_alike() { + for (s, p) in int_synth_committed() { + let (set, pay) = (read(&s), read(&p)); + let new = wai::int_synth::decode_from_spec(&pay, &set); + let old = wai::int_synth::legacy::decode_from_spec(&pay, &set); + assert!(new.is_ok(), "{}: {new:?}", s.display()); + assert_eq!(new, old, "{}", s.display()); + } +} + +/// A random `int_synth.json` of 1 to 3 layers and a payload for it: kernels 1 to 4, +/// strides 1 to 3, paddings below the kernel, output paddings below the stride, shifts +/// 0 to 24, weights and biases of up to 2^14 and 2^18, latent channels 1 to 3 and +/// sides 1 to 4. The payload's `H` and `W` are the output's sides, or (one case in +/// eight) not. +fn int_synth_case(r: &mut Rng) -> (Vec, Vec) { + let c0 = 1 + r.below(3) as usize; + let s_log2 = r.below(3) as u8; + let (mut c, mut side) = (c0, 1usize << s_log2); + let n_layers = 1 + r.below(3) as usize; + let mut layers = Vec::new(); + for i in 0..n_layers { + let out = if i + 1 == n_layers { 3 } else { 1 + r.below(4) as usize }; + let k = 1 + r.below(4) as usize; + let stride = 1 + r.below(3) as usize; + let pad = r.below(k as u64) as usize; + let out_pad = r.below(stride as u64) as usize; + let shift = r.below(25); + let weight: Vec = (0..c * out * k * k).map(|_| if r.below(4) == 0 { 0 } else { r.signed(1 << 14) }).collect(); + let bias: Vec = (0..out).map(|_| r.signed(1 << 18)).collect(); + layers.push(serde_json::json!({ + "in_ch": c, "out_ch": out, "k": k, "stride": stride, "pad": pad, "out_pad": out_pad, + "weight": weight, "bias": bias, "shift": shift, "relu": r.below(2) == 1, + })); + let next = ((side as i64 - 1) * stride as i64 + k as i64 + out_pad as i64 - 2 * pad as i64).max(0) as usize; + (c, side) = (out, next); + } + let (mut h, mut w) = (side as u32, side as u32); + if r.below(8) == 0 { + h += 1 + r.below(2) as u32; + w = w.saturating_sub(r.below(2) as u32); + } + let mut p = Vec::new(); + p.extend(h.to_le_bytes()); + p.extend(w.to_le_bytes()); + p.extend((c0 as u16).to_le_bytes()); + p.extend([s_log2, 0]); + let n = c0 << (2 * s_log2); + p.extend((0..n).map(|_| r.signed(128).clamp(-128, 127) as i8 as u8)); + (serde_json::to_vec(&serde_json::json!({ "layers": layers })).unwrap(), p) +} + +#[test] +fn int_synth_random_sets_and_payloads_decode_alike() { + let (mut ok, mut refused) = (0u64, 0u64); + cases("int_synth", 3000, |r, k| { + let (set, pay) = int_synth_case(r); + let new = wai::int_synth::decode_from_spec(&pay, &set); + let old = wai::int_synth::legacy::decode_from_spec(&pay, &set); + match (&new, &old) { + (Ok(a), Ok(b)) => { + assert_eq!(a, b, "case {k}"); + ok += 1; + } + // One refusal code (`payload_malformed`) covers every refusal. + (Err(_), Err(_)) => refused += 1, + _ => panic!("case {k}: new {new:?}, legacy {old:?}\nset {}", String::from_utf8_lossy(&set)), + } + }); + eprintln!("int_synth: {ok} decoded alike, {refused} refused by both"); + assert!(ok > refused, "the generator must mostly produce decodable cases"); +} + +/// The planned breaks of §15.3 item 1, each on an otherwise decodable case: the legacy +/// path decodes it (or, for a shift of 64 or more, gives a build-dependent result), +/// and the decoder through the operator set refuses it. +#[test] +fn int_synth_planned_breaks_are_new_refusals() { + let set = |k: u64, stride: u64, shift: u64| { + let w: Vec = vec![1; (3 * k * k) as usize]; + serde_json::to_vec(&serde_json::json!({ "layers": [{ + "in_ch": 1, "out_ch": 3, "k": k, "stride": stride, "pad": 0, "weight": w, "bias": [0, 0, 0], "shift": shift, + }]})).unwrap() + }; + let payload = |h: u32, reserved: u8, c: u16| { + let mut p = Vec::new(); + p.extend(h.to_le_bytes()); + p.extend(h.to_le_bytes()); + p.extend(c.to_le_bytes()); + p.extend([0u8, reserved]); + p.extend(std::iter::repeat_n(5u8, c as usize)); + p + }; + let good = (set(1, 1, 0), payload(1, 0, 1)); + assert!(wai::int_synth::decode_from_spec(&good.1, &good.0).is_ok()); + assert!(wai::int_synth::legacy::decode_from_spec(&good.1, &good.0).is_ok()); + // A reserved byte that is not 0. + let p = payload(1, 7, 1); + assert!(wai::int_synth::legacy::decode_from_spec(&p, &good.0).is_ok()); + assert!(wai::int_synth::decode_from_spec(&p, &good.0).is_err()); + // A shift of 128 or more: refused at the lowering. The legacy shift panics or + // masks, by build. + assert!(wai::int_synth::decode_from_spec(&good.1, &set(1, 1, 128)).is_err()); + // A shift from 64 to 127 has its exact meaning: 5 >> 64 = 0. + assert_eq!(wai::int_synth::decode_from_spec(&good.1, &set(1, 1, 64)).unwrap().rgb, vec![0, 0, 0]); + // A stride of 0 (the legacy output is 1×1 here). + let s0 = set(1, 0, 0); + assert!(wai::int_synth::legacy::decode_from_spec(&good.1, &s0).is_ok()); + assert!(wai::int_synth::decode_from_spec(&good.1, &s0).is_err()); + // A certificate that does not meet acc64. + let overflow = read(&wai().join("ops-conformance/sets/overflow_acc64/int_synth.json")); + let lowered = wai_ops::lower::int_synth(&overflow).unwrap(); + assert!(wai::int_ops::load(lowered.get("synth").unwrap()).is_err()); +} diff --git a/wai/wai-web/Cargo.lock b/wai/wai-web/Cargo.lock index cf84498df..b0801137b 100644 --- a/wai/wai-web/Cargo.lock +++ b/wai/wai-web/Cargo.lock @@ -750,9 +750,14 @@ dependencies = [ "serde", "serde_json", "sha2", + "wai-ops", "wai-quantum", ] +[[package]] +name = "wai-ops" +version = "0.1.0" + [[package]] name = "wai-quantum" version = "0.4.0" -- 2.54.0 (Apple Git-157) From 135c9ad7a9c9add2d218372825b0e4ebc15668f2 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Tue, 13 Oct 2026 02:35:09 -0400 Subject: [PATCH 02/10] D2c: set the WIP draft aside so the tree builds; M1's gates re-land it in order The draft (140de363) switched wai.neural.int_synth onto wai-ops in one step, before its golden, refusal, differential and fast-kernel gates existed, and it did not compile: byte-exact-conformance declared `mod int_codec_golden;`, a file that was never written. Every file the draft touched is restored to main's text here, so wai/ equals main's at 1fa13396 and the tree builds. The gates of the design's migration plan (lowering and certificate, golden equality, refusal parity, differential, fast-kernel parity, switch, delete) then land one commit each, drawing on the draft. Not re-landed by M1, for the milestones they belong to: int_codec_golden (the cross-architecture goldens of wai.audio.int_codec and wai.splat.int_codec, with the int_audio and int_splat includes beside it: M2 and M3), the parsed-network lowering entry points in wai-ops' whm1/wam1/world lowerings (M2 to M6), and the inverse-GDN host kernel (M4). Co-Authored-By: Claude Opus 5.5 --- wai/CHANGELOG.md | 22 --- wai/SPEC.md | 19 +- wai/byte-exact-conformance/Cargo.lock | 5 - wai/byte-exact-conformance/Cargo.toml | 9 - wai/byte-exact-conformance/src/lib.rs | 17 -- wai/extensions/integer-payloads.md | 15 +- wai/wai-ops/src/interp.rs | 56 +----- wai/wai-ops/src/lib.rs | 2 +- wai/wai-ops/src/lower/mod.rs | 2 +- wai/wai-ops/src/lower/whm.rs | 50 ++--- wai/wai-rs/Cargo.lock | 5 - wai/wai-rs/Cargo.toml | 14 +- wai/wai-rs/src/codecs/mod.rs | 29 --- wai/wai-rs/src/codecs/registry.rs | 60 +----- wai/wai-rs/src/int_ops.rs | 221 ---------------------- wai/wai-rs/src/int_synth.rs | 152 ++++------------ wai/wai-rs/src/lib.rs | 5 - wai/wai-rs/tests/int_kernels_geometry.rs | 52 +++--- wai/wai-rs/tests/int_ops_differential.rs | 222 ----------------------- wai/wai-web/Cargo.lock | 5 - 20 files changed, 87 insertions(+), 875 deletions(-) delete mode 100644 wai/wai-rs/src/int_ops.rs delete mode 100644 wai/wai-rs/tests/int_ops_differential.rs diff --git a/wai/CHANGELOG.md b/wai/CHANGELOG.md index f520c9100..bc5858dcf 100644 --- a/wai/CHANGELOG.md +++ b/wai/CHANGELOG.md @@ -6,28 +6,6 @@ wire format may change and such changes are recorded here. ## v0.4.0 — current -### The integer decoders through the integer operator set - -- **Each integer decoder runs its networks through `wai-ops/1`** - ([integer-operators](extensions/integer-operators.md) §13). A decoder lowers its - parameter set to `WHG1` graphs, loads each under `acc64` (load steps 1–8, the last - being the bounds certificate) and runs it through the operator set's interpreter, with - the fast kernels behind it for the convolutions and the inverse GDN - (`wai-rs/src/int_ops.rs`). Every committed output is unchanged, byte for byte, and so - is every committed refusal's code. The direct paths the decoders ran before are kept, - test-only (`legacy_int`), and `tests/int_ops_differential.rs` decodes every committed - vector and random sets and payloads both ways. -- **`wai.neural.int_synth`** decodes through `int_synth/1` (§13.1). The registry gains - its `ops` member (§11): `acc64`, subset `full`, lowering `int_synth/1`. -- **Planned breaks** (SPEC §8, integer-operators §15.3): new refusals of input no - corpus contains. `wai.neural.int_synth` refuses a payload whose reserved byte is not - 0, whose `C` is 0 or whose latents exceed `2^26` values, and a set whose shift is 128 - or more, whose `k` or `stride` is 0, which a cap refuses (a field past 32 bits, a - tensor or a transposed convolution's uncropped output past `2^26` elements), or whose - certificate does not meet `acc64`. A shift from 64 to 127, which a build used to mask - or panic on, now has its exact meaning. A scan of every committed `int_synth` set and - payload finds none affected. - ### wai-quantum 0.4.3: metered runs, placement by run, and the fabric wrapper Additive. The default features, `full`, both implementations of the WebGPU member, and diff --git a/wai/SPEC.md b/wai/SPEC.md index fc56eb3ae..a1c587a72 100644 --- a/wai/SPEC.md +++ b/wai/SPEC.md @@ -619,7 +619,7 @@ registration and makes no conforming envelope non-conforming (§8). |---|---|---|---| | `wai.neural.bmshj2018` | bmshj2018-factorized + rANS (byte-exact CompressAI port) | about 0.26 bpp at 29 dB PSNR (quality 3) | CompressAI | | `wai.neural.mbt2018_mean` | mean-scale hyperprior (Gaussian-conditional entropy) | the conditional-entropy family (arXiv 1809.02736) | `neural/hyperprior.rs` | -| `wai.neural.int_synth` | integer fixed-point synthesis (all-i64 transposed-conv, no float/ONNX) | bit-identical on every machine — soundly backs `intent = replicate` | payload and set: [integer-payloads](extensions/integer-payloads.md) §13; decoded through its lowering, [integer-operators](extensions/integer-operators.md) §13.1 (`int_synth/1`); `int_synth.rs` (integer weights sink-supplied; the reference tools make them by training in float, then quantising after training with per-layer shifts calibrated on measured activations, `tools/wai_int_synth_train.py`. Of the committed vectors only `photo` comes from a trained model, `tools/wai_int_synth_real.py`; `gradient`, `bloom`, `facade` and the cross-architecture golden use seeded integer weights) | +| `wai.neural.int_synth` | integer fixed-point synthesis (all-i64 transposed-conv, no float/ONNX) | bit-identical on every machine — soundly backs `intent = replicate` | `int_synth.rs` (integer weights sink-supplied; the reference tools make them by training in float, then quantising after training with per-layer shifts calibrated on measured activations, `tools/wai_int_synth_train.py`. Of the committed vectors only `photo` comes from a trained model, `tools/wai_int_synth_real.py`; `gradient`, `bloom`, `facade` and the cross-architecture golden use seeded integer weights) | | `wai.neural.int_hyper` | integer fixed-point **conditional** codec — whole bitstream→image path integer (integer hyper-decoder σ → integer-σ entropy → integer synthesis) | the integer-entropy contract fully assembled: a PTQ of mbt2018_mean, bit-identical on every machine, whose integer decode CI holds within 1 dB of the same model's float decode on a pinned photograph | payload `WIH1`; `int_hyper_synth.rs` (tables sink-supplied) | | `wai.neural.int_mlicpp` | integer fixed-point **MLIC++ entropy topology** — `K` channel slices of the latent, each decoded as a checkerboard anchor pass then a non-anchor pass, with an entropy network over concat[latent decoded so far, hyper] re-run before every pass to give σ‖μ; hyper-decoder, entropy network and synthesis all integer | the channel-autoregressive × checkerboard entropy model at **decode-equivalence**: the encoder and every decoder compute the same σ, so the same distribution for every element, at any thread count; bit-identical on every machine. The committed conformance vector has synthetic weights, so it demonstrates decode-equivalence and states no quality figure | `int_mlicpp.rs` (one-file model bundle sink-supplied; `tools/wai_int_mlicpp_export.py`) | | `wai.image.jpegai` | JPEG AI (ISO/IEC 6048-1:2025) codestream → RGB — integer entropy front end (mANS, with an integer scale-index derivation) + 16-bit-PTQ integer hyper-decoder, context-model and synthesis networks + dequantisation, latent refinement, post-filters (the eICCI CNN among them) and colour conversion in IEEE-754 float with a fixed evaluation order. Scope: one configuration — 256×256 4:4:4, `model_id` 0 (tools_0), `beta_displacement_log` 0, the high-operation-point synthesis, the eICCI model pair the weight bundle carries, and the other header conditions in [determinism-tiers](extensions/determinism-tiers.md) §5; both decode paths refuse any other codestream. Within it, a sink without the integer weight bundle, or in a non-default floating-point environment, takes a float ONNX decode instead. Only checkpoint constants are sink-supplied | **entropy-consistency** (registry class `NeuralEntropyExact`): the entropy decode, including the scale-index derivation, is integer on both decode paths, so the decoded symbols reproduce on every machine. The exact decoder also pins one reconstruction to RGB, reproducible across machines under the build and floating-point conditions in [determinism-tiers](extensions/determinism-tiers.md) §5–§6 (it checks the floating-point environment before decoding) — a property of that decoder, not of the capability, so `wai.image.jpegai` does not back `replicate` (§7). On one reference codestream the exact decoder stays within max\|Δ\| ≤ 2 of the reference decoder (recorded max\|Δ\| = 1 on ~4.4% of samples); evidence in [determinism-tiers](extensions/determinism-tiers.md) §6. **Downgraded in v0.4.0:** this row previously claimed decode-equivalence, and the registry classed the capability `StandardDefined` | `jpegai_decode.rs` (+ `jpegai_{bitstream,mans,pipeline,synthesis,icci}.rs`) | @@ -1434,12 +1434,6 @@ registry as `codecs::capability_replicate_class(cap) -> ReplicateClass`: checkerboard entropy model on integer networks, so the σ it re-derives before each of its `2·K` passes is integer too; `wai.video.int_hyper`, `wai.audio.int_codec` and `wai.splat.int_codec` are integer throughout as well.) - A capability of this class whose networks decode through the integer operator set - names it in the registry, in its `ops` member - ([integer-operators](extensions/integer-operators.md) §11): the lowering its sets - are lowered by, the profile every conforming set's certificate meets, and the modes - of its stage operators. A set whose certificate does not meet that profile is - refused. In this revision: `wai.neural.int_synth`. - **`NeuralEntropyExact`** — the entropy decode is integer by construction, so the decoded symbols are byte-identical on every machine; later stages run float. Registered at `entropy-consistency`. An encoder MUST NOT emit `intent = @@ -2314,17 +2308,6 @@ envelope that uses any string a later revision registers. envelope that carried it as a `WAI1` `capability` without `refines`, as a `fallback`, or as a selectable rendition or a companion, and one that bound a companion to it. Such envelopes still parse. -- **Planned breaks.** A capability that comes to decode through the integer operator - set ([integer-operators](extensions/integer-operators.md) §13) refuses some inputs it - decoded before, or decoded differently by build. Each is a new refusal of input no - committed corpus contains, and no committed output changes; integer-operators §15.3 - states them, and the changelog records each when it lands. Landed in this revision: - - `wai.neural.int_synth`: a payload whose reserved byte is not 0, or whose `C` is 0, - or whose latents exceed `2^26` values; a set whose shift is 128 or more, whose - `k` or `stride` is 0, which a cap refuses (a value past 32 bits where `WHG1` - holds 32, a tensor past `2^26` elements, a transposed convolution's uncropped - output past `2^26`), or whose certificate does not meet `acc64`. A shift from 64 - to 127, which a build used to mask or panic on, now has its exact meaning. --- diff --git a/wai/byte-exact-conformance/Cargo.lock b/wai/byte-exact-conformance/Cargo.lock index 6897f57c4..7226ce652 100644 --- a/wai/byte-exact-conformance/Cargo.lock +++ b/wai/byte-exact-conformance/Cargo.lock @@ -182,13 +182,8 @@ dependencies = [ "serde", "serde_json", "sha2", - "wai-ops", ] -[[package]] -name = "wai-ops" -version = "0.1.0" - [[package]] name = "zmij" version = "1.0.23" diff --git a/wai/byte-exact-conformance/Cargo.toml b/wai/byte-exact-conformance/Cargo.toml index 664b4bbe6..a6829ce26 100644 --- a/wai/byte-exact-conformance/Cargo.toml +++ b/wai/byte-exact-conformance/Cargo.toml @@ -22,12 +22,3 @@ unexpected_cfgs = { level = "warn", check-cfg = ["cfg(kani)"] } serde = { version = "1", features = ["derive"] } serde_json = "1" sha2 = "0.10" -# The integer operator set the included decoders run their networks through -# (`wai-rs/src/int_ops.rs`, included below at the crate root). no_std, no dependencies. -wai-ops = { path = "../wai-ops" } - -[features] -# The included decoders keep their direct paths behind this test-only switch -# (wai-rs's feature of the same name). Declared so the included sources' `cfg` names -# it; the goldens here do not use it. -legacy_int = [] diff --git a/wai/byte-exact-conformance/src/lib.rs b/wai/byte-exact-conformance/src/lib.rs index 7a136295b..3ff907d42 100644 --- a/wai/byte-exact-conformance/src/lib.rs +++ b/wai/byte-exact-conformance/src/lib.rs @@ -26,13 +26,6 @@ pub mod int_entropy; #[path = "../../wai-rs/src/int_kernels.rs"] pub mod int_kernels; -// The bridge the integer decoders run their networks through: each parameter set -// lowered to `wai-ops/1` graphs, loaded under its certificate's profile, and run by the -// operator set's interpreter with the fast kernels above behind it. Included so the -// matrix checks that path, the one sinks run. -#[path = "../../wai-rs/src/int_ops.rs"] -pub mod int_ops; - // The merged byte-exact JPEG-AI decoder's entropy stage (crate-root, self-contained — // no shims needed). Included so the cross-arch byte-equality matrix covers the decoder // that `wai.image.jpegai` actually reconstructs with, not only the older `neural/` port. @@ -76,15 +69,6 @@ pub mod int_hyper_synth; #[path = "../../wai-rs/src/int_mlicpp.rs"] pub mod int_mlicpp; -// The learned integer audio and Gaussian-splat attribute codecs (`wai.audio.int_codec`, -// `wai.splat.int_codec`): integer transposed convolutions and IGDN over the networks -// and kernels already included here, so the matrix checks their PCM and attribute -// bytes on every platform. -#[path = "../../wai-rs/src/int_audio.rs"] -pub mod int_audio; -#[path = "../../wai-rs/src/int_splat.rs"] -pub mod int_splat; - // The wire parser of the integer payloads SPEC §5 pins (WIV1, WIH1, WIA1, WIS1). // Self-contained — std's Vec only, nothing from the crate — so the matrix parses the // committed binary payloads with the parser a sink runs. @@ -103,5 +87,4 @@ mod int_video_golden; mod neural_keyframe_golden; mod jpegai_golden; mod int_mlicpp_golden; -mod int_codec_golden; mod staged_golden; diff --git a/wai/extensions/integer-payloads.md b/wai/extensions/integer-payloads.md index 9d84807cb..46b00e96e 100644 --- a/wai/extensions/integer-payloads.md +++ b/wai/extensions/integer-payloads.md @@ -611,14 +611,13 @@ The decode is [integer-operators](integer-operators.md) §13.1's graph on the la Its output must be `[3, H, W]`, and it is presented as row-major HWC bytes. Every refusal of the payload, the set or the decode is `payload_malformed` (§8). -The reference decoder (`int_synth::decode_bytes`) decodes through the lowering. It -checks the payload first: its header, a reserved byte of 0, `C ≥ 1`, `C·S·S ≤ 2^26` -and its length. It then lowers the set and loads the graph under `acc64`, which makes -the set's checks (§13.1 of [integer-operators](integer-operators.md): the layers chain, -the lengths, `k ≥ 1`, `stride ≥ 1`, a shift of at most 127, the caps and the -certificate), and runs it. A shift from 64 to 127 has its exact meaning. These checks -are new refusals of input no corpus contains ([integer-operators](integer-operators.md) -§15.3, SPEC §8 *Planned breaks*). +**Checks that wait for the lowering.** The reference decoder +(`int_synth::decode_bytes`) makes every check of this section except these, which it +makes once the capability decodes through its lowering +([integer-operators](integer-operators.md) §15.3): a reserved byte of 0; `C ≥ 1`; the +caps; `k ≥ 1`, `stride ≥ 1` and a shift of at most 127; and the certificate. Until +then it decodes a payload or set that fails one of them where it can, and with a shift +from 64 to 127 its result depends on how it was built. ## 14. `WIM1` and `WMB1` — `wai.neural.int_mlicpp` diff --git a/wai/wai-ops/src/interp.rs b/wai/wai-ops/src/interp.rs index a8d5c9f91..5383341eb 100644 --- a/wai/wai-ops/src/interp.rs +++ b/wai/wai-ops/src/interp.rs @@ -151,60 +151,17 @@ fn to64(v: i128, at: u32) -> Res { i64::try_from(v).map_err(|_| wide(at)) } -/// A host's own kernels for some operators (§6). A sink may compute an operator's -/// output by other means (another order of the same sums, narrower types where the -/// values fit, threads, vector lanes) provided it gives the same integers; [`run_with`] -/// asks the host first and computes an operator itself only where the host declines. -/// Everything else stays the interpreter's: the plan, every scalar and input check, -/// every tensor's range policy, the state of a sequence and every refusal. -pub trait Kernels { - /// The outputs of `op` on its inputs `ins` (in the operator's input order), each - /// output `k` of the shape `out[k]` (`(C, H, W)`, from the run-time plan); or `None` - /// for the interpreter to compute them. An implementation that returns `Some` - /// returns exactly the integers the interpreter would, so it returns `None` for - /// anything it does not hold to that: an operator whose arithmetic would leave - /// its narrower types, or one the interpreter would refuse. - fn eval(&self, op: &crate::graph::Op, ins: &[&Tensor], out: &[(u32, u32, u32)]) -> Option>; -} - -/// The interpreter's own kernels for every operator: a host that has none. -pub struct Reference; - -impl Kernels for Reference { - #[inline(always)] - fn eval(&self, _: &crate::graph::Op, _: &[&Tensor], _: &[(u32, u32, u32)]) -> Option> { - None - } -} - -/// `rq_o(v)` (§5.1) for output channel `o`: the requantisation a host kernel applies to -/// the exact sum `v` it formed, so that its outputs are the interpreter's. `None` -/// where a value leaves `i128`, which no certified graph forms. -pub fn rq(rq: &Rq, o: usize, v: i128) -> Option { - rq_apply(rq, o, v, 0, &mut Quiet) -} - /// Run one decode of `g`, a graph that passed every load step ([`crate::graph::load`]). /// `inputs` holds the data inputs, in input-table order; each state input is taken /// from `seq` (§10.3). `scalars` holds every scalar's value. Returns the graph's /// outputs, in output-table order. A refused decode leaves `seq` as it was. pub fn run(g: &Loaded, inputs: &[Tensor], scalars: &[i64], seq: &mut Sequence) -> Res> { - run_inner(g, inputs, scalars, seq, &mut Quiet, &Reference) -} - -/// [`run`], with the host's kernels `k` asked first for each operator ([`Kernels`]). -/// The outputs and refusals are [`run`]'s, provided `k` keeps its contract. -pub fn run_with(g: &Loaded, inputs: &[Tensor], scalars: &[i64], seq: &mut Sequence, k: &K) -> Res> { - run_inner(g, inputs, scalars, seq, &mut Quiet, k) + run_observed(g, inputs, scalars, seq, &mut Quiet) } /// [`run`], reporting every named intermediate and producer result to `obs` /// ([`Observe`]). The outputs and refusals are [`run`]'s. pub fn run_observed(g: &Loaded, inputs: &[Tensor], scalars: &[i64], seq: &mut Sequence, obs: &mut O) -> Res> { - run_inner(g, inputs, scalars, seq, obs, &Reference) -} - -fn run_inner(g: &Loaded, inputs: &[Tensor], scalars: &[i64], seq: &mut Sequence, obs: &mut O, k: &K) -> Res> { let g: &Graph = g.graph(); // The inputs, data and state, in input-table order. let n_data = g.inputs.iter().filter(|i| i.kind == 0).count(); @@ -247,16 +204,7 @@ fn run_inner(g: &Loaded, inputs: &[Tensor], sca vals[rec.tensor as usize] = Some(t); } for (i, op) in g.ops.iter().enumerate() { - let host = { - let ins: Vec<&Tensor> = op.ins.iter().map(|&t| get(&vals, t)).collect(); - let shapes: Vec<(u32, u32, u32)> = - op.outs.iter().map(|&t| (g.tensors[t as usize].channels, hw[t as usize].0 as u32, hw[t as usize].1 as u32)).collect(); - k.eval(op, &ins, &shapes) - }; - let outs = match host { - Some(o) => o, - None => eval(g, op, i as u32, &vals, &hw, scalars, obs)?, - }; + let outs = eval(g, op, i as u32, &vals, &hw, scalars, obs)?; for (k, ten) in outs.iter().enumerate() { for &v in &ten.data { obs.see(i as u32, OUT[k], v as i128); diff --git a/wai/wai-ops/src/lib.rs b/wai/wai-ops/src/lib.rs index 598c589e1..5df059449 100644 --- a/wai/wai-ops/src/lib.rs +++ b/wai/wai-ops/src/lib.rs @@ -49,4 +49,4 @@ pub mod xint; pub use bounds::Profile; pub use code::{Code, Refusal, Site}; pub use graph::{Graph, Loaded}; -pub use interp::{Kernels, Sequence, Tensor, run, run_with}; +pub use interp::{Sequence, Tensor, run}; diff --git a/wai/wai-ops/src/lower/mod.rs b/wai/wai-ops/src/lower/mod.rs index 8adba41fe..702abbf7f 100644 --- a/wai/wai-ops/src/lower/mod.rs +++ b/wai/wai-ops/src/lower/mod.rs @@ -16,7 +16,7 @@ mod whm; pub use jpegai::jpegai; pub use synth::int_synth; -pub use whm::{Net, NetLayer, int_hyper, parse_wam1, parse_whm1, splat, wam1, wam1_net, whm1, whm1_net, wmb1, world, world_nets}; +pub use whm::{Net, NetLayer, int_hyper, parse_wam1, parse_whm1, splat, wam1, whm1, wmb1, world}; #[cfg(kani)] pub(crate) use whm::{p_conv, p_igdn, p_leaky}; diff --git a/wai/wai-ops/src/lower/whm.rs b/wai/wai-ops/src/lower/whm.rs index 65efbee1c..d3833782a 100644 --- a/wai/wai-ops/src/lower/whm.rs +++ b/wai/wai-ops/src/lower/whm.rs @@ -312,16 +312,7 @@ fn net_graph(net: &Net, output_step: bool) -> Res { /// `whm1/1`: one `WHM1` network, with the RGB8 step when `output_step` is set. pub fn whm1(bytes: &[u8], output_step: bool) -> Res { - whm1_net(&parse_whm1(bytes)?, output_step) -} - -/// [`whm1`] of a network already parsed (a decoder that holds its networks parsed -/// lowers them from that form, which [`parse_whm1`] of their bytes gives). -pub fn whm1_net(net: &Net, output_step: bool) -> Res { - if net.audio { - return refuse(); - } - net_graph(net, output_step) + net_graph(&parse_whm1(bytes)?, output_step) } /// `whm1/1` for `wai.neural.int_hyper` and the keyframe of `wai.video.int_hyper`: @@ -337,21 +328,14 @@ pub fn splat(g_s: &[u8]) -> Res { /// `wam1/1` (§13.3): the `WAM1` synthesis with the PCM step. pub fn wam1(bytes: &[u8]) -> Res { - wam1_net(&parse_wam1(bytes)?) -} - -/// [`wam1`] of a network already parsed ([`parse_wam1`]). -pub fn wam1_net(net: &Net) -> Res { - if !net.audio { - return refuse(); - } - header_ok(net)?; - let c0 = in_channels(net)?; + let net = parse_wam1(bytes)?; + header_ok(&net)?; + let c0 = in_channels(&net)?; let mut b = Builder::new(); let first = net.layers.first().ok_or(()).or_else(|_| refuse())?; - let x0 = b.input(x_record(net, first, c0, true)?, 0, 0); + let x0 = b.input(x_record(&net, first, c0, true)?, 0, 0); let t_out = b.scalar(1, 1 << 26); - let x = layers(&mut b, net, x0)?; + let x = layers(&mut b, &net, x0)?; let c = b.channels(x); // The range in which v·32767 + 2^(afrac−1) fits i64. let lo = -((1i128 << 63) / 32767) as i64; // ⌈−2^63 / 32767⌉ @@ -395,20 +379,14 @@ pub fn wmb1(bytes: &[u8]) -> Res { /// `world/1` (§13.5, informative): the transition `T` with `A` action planes, then /// the synthesis `g_s` with the RGB8 step. `c` is the latent's channel count. pub fn world(t: &[u8], g_s: &[u8], c: u32) -> Res { - world_nets(&parse_whm1(t)?, &parse_whm1(g_s)?, c) -} - -/// [`world`] of two networks already parsed ([`parse_whm1`]). -pub fn world_nets(tn: &Net, gn: &Net, c: u32) -> Res { - if tn.audio || gn.audio { - return refuse(); - } - header_ok(tn)?; - header_ok(gn)?; + let tn = parse_whm1(t)?; + let gn = parse_whm1(g_s)?; + header_ok(&tn)?; + header_ok(&gn)?; if tn.afrac != gn.afrac { return refuse(); } - let cin = in_channels(tn)?; + let cin = in_channels(&tn)?; if c == 0 || cin <= c { return refuse(); } @@ -421,11 +399,11 @@ pub fn world_nets(tn: &Net, gn: &Net, c: u32) -> Res { parts.push(b.op(opcode::FILL, vec![z], TensorRec::derived(1), Params::Fill { kind: 1, value: s as i64 })); } let first = tn.layers.first().ok_or(()).or_else(|_| refuse())?; - let x0 = b.op(opcode::CONCAT, parts, x_record(tn, first, cin, true)?, Params::None); - let x = layers(&mut b, tn, x0)?; + let x0 = b.op(opcode::CONCAT, parts, x_record(&tn, first, cin, true)?, Params::None); + let x = layers(&mut b, &tn, x0)?; let one = 1i64 << tn.afrac; let cc = b.channels(x); let out = b.op(opcode::CLAMP, vec![x], TensorRec::derived(cc), Params::Clamp { lo: -one, hi: one }); let tg = b.finish(vec![out])?; - Ok(Lowered { roles: vec![("T", tg), ("g_s", net_graph(gn, true)?)] }) + Ok(Lowered { roles: vec![("T", tg), ("g_s", net_graph(&gn, true)?)] }) } diff --git a/wai/wai-rs/Cargo.lock b/wai/wai-rs/Cargo.lock index 6c9d3e3be..721e47a05 100644 --- a/wai/wai-rs/Cargo.lock +++ b/wai/wai-rs/Cargo.lock @@ -4451,17 +4451,12 @@ dependencies = [ "serde", "serde_json", "sha2 0.10.9", - "wai-ops", "wai-quantum", "xz2", "zstd", "zstd-safe", ] -[[package]] -name = "wai-ops" -version = "0.1.0" - [[package]] name = "wai-quantum" version = "0.4.3" diff --git a/wai/wai-rs/Cargo.toml b/wai/wai-rs/Cargo.toml index 91dd00228..6d7a75d85 100644 --- a/wai/wai-rs/Cargo.toml +++ b/wai/wai-rs/Cargo.toml @@ -97,13 +97,6 @@ num-bigint = { version = "0.4", optional = true } # oer: exact decimal arithm # verifier checks S < L itself (§3.1 step 3). curve25519-dalek = { version = "4", optional = true } -# ---- the integer operator set (behind `neural_int`) ---- -# `wai-ops/1` (extensions/integer-operators.md): the integer decoders lower their -# parameter sets to WHG1 graphs, load them under their capability's profile (the -# bounds certificate) and run them through its interpreter, with this crate's fast -# kernels behind it (src/int_ops.rs). no_std, no dependencies of its own. -wai-ops = { path = "../wai-ops", optional = true } - # ---- quantum (optional, behind the `quantum*` features) ---- # The quantum stack now lives in its own dependency-light crate so it can be used # without the media/codec chain. `wai` re-exports it unchanged. @@ -134,12 +127,7 @@ ffi = [] # construction — so it sits behind its own light feature, checkable in a CI # matrix with no ONNX Runtime present. `neural` pulls it in. blake3 is for the # conformance corpus's content hash (the same hash the other classes freeze). -neural_int = ["dep:blake3", "dep:wai-ops"] -# Test-only: the integer decoders' direct paths as they were before they ran through -# the integer operator set (`int_synth::legacy`, …). The differential tests decode -# every committed vector and random sets and payloads both ways and require the same -# output or the same refusal (tests/int_ops_differential.rs). No sink enables it. -legacy_int = ["neural_int"] +neural_int = ["dep:blake3"] # Deterministic integer generative synthesis (`wai.generate.*`): byte-exact # generation pinned to (model, prompt, seed, steps) — the create-side of the # integer contract. Pure i64, no ONNX/float (serde_json is already a base dep), diff --git a/wai/wai-rs/src/codecs/mod.rs b/wai/wai-rs/src/codecs/mod.rs index 9b941602c..d8f0c00fc 100644 --- a/wai/wai-rs/src/codecs/mod.rs +++ b/wai/wai-rs/src/codecs/mod.rs @@ -1330,35 +1330,6 @@ pub fn capability_replicate_class(cap: &str) -> Option { }) } -/// The integer operator set a capability's decode runs through -/// (`extensions/integer-operators.md` §11): the registry's `ops` member. `profile` is the -/// profile every conforming parameter set of the capability meets, and a set whose -/// certificate does not meet it is refused; `subset` is `full` or `npu`; `lowering` -/// names the §13 lowering the capability's sets are lowered by; `rans64` and `bucket` -/// fix the modes of the stage operators its driver uses (§5E). Strengthening a profile -/// or a subset narrows the sets a capability accepts, a breaking change (SPEC §8). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct OpsMember { - pub profile: &'static str, - pub subset: &'static str, - pub lowering: &'static str, - /// `rans64_decode`'s `(table, escape, end)` (E1), where the driver decodes one. - pub rans64: Option<(&'static str, &'static str, &'static str)>, - /// `bucket`'s rule (E3), where the driver buckets a scale. - pub bucket: Option<&'static str>, -} - -/// The `ops` member of `cap`, for a capability decoded through the integer operator -/// set; `None` for any other. A capability gains one when its decoder runs its -/// networks through their lowering (`extensions/integer-operators.md` §15.1). -pub fn capability_ops(cap: &str) -> Option { - let ops = |lowering, rans64, bucket| Some(OpsMember { profile: "acc64", subset: "full", lowering, rans64, bucket }); - match cap { - CAP_NEURAL_INT_SYNTH => ops("int_synth/1", None, None), - _ => None, - } -} - /// Whether a capability's **neural** decode is integer-exact by construction — /// the integer-entropy contract. Isolates exactly the neural codecs WAI itself /// guarantees byte-identical on every machine, so a deployer can require diff --git a/wai/wai-rs/src/codecs/registry.rs b/wai/wai-rs/src/codecs/registry.rs index 4043894b0..ce6f4678e 100644 --- a/wai/wai-rs/src/codecs/registry.rs +++ b/wai/wai-rs/src/codecs/registry.rs @@ -564,9 +564,6 @@ pub struct Registration { /// See [`ROWS`]: a designation, or `None` for the edition current at `registered`. pub edition: Option<&'static str>, pub registered: &'static str, - /// The integer operator set the capability decodes through, if it does - /// (`extensions/integer-operators.md` §11). - pub ops: Option, } /// Every registered string — payload, companion, derivation and record — sorted @@ -588,7 +585,6 @@ pub fn registrations() -> Vec { spec_section: section, edition, registered, - ops: super::capability_ops(cap), }) .collect(); for op in derivation::derivation_operations() { @@ -606,7 +602,6 @@ pub fn registrations() -> Vec { spec_section: S_PRIOR, edition: Some("ISO/IEC 23001-16:2021"), registered: DERIVATIONS_REGISTERED, - ops: None, }); } v.sort_by(|a, b| a.capability.cmp(b.capability)); @@ -751,7 +746,7 @@ pub fn capabilities_json() -> String { let caps: Vec = registrations() .iter() .map(|r| { - let mut v = json!({ + json!({ "capability": r.capability, "kind": r.kind.label(), "status": r.status.label(), @@ -764,11 +759,7 @@ pub fn capabilities_json() -> String { "spec_section": r.spec_section, "edition": r.edition, "registered": r.registered, - }); - if let Some(o) = r.ops { - v["ops"] = ops_json(&o); - } - v + }) }) .collect(); let mut ids: Vec<&Identifier> = IDENTIFIERS.iter().collect(); @@ -799,24 +790,6 @@ pub fn capabilities_json() -> String { })) } -/// The registry's `ops` member (`extensions/integer-operators.md` §11), its members in -/// that section's order. -fn ops_json(o: &super::OpsMember) -> serde_json::Value { - use serde_json::{json, Map, Value}; - let mut v = json!({ "set": "wai-ops/1", "profile": o.profile, "subset": o.subset, "lowering": o.lowering }); - let mut stage = Map::new(); - if let Some((table, escape, end)) = o.rans64 { - stage.insert("rans64".into(), json!({ "table": table, "escape": escape, "end": end })); - } - if let Some(b) = o.bucket { - stage.insert("bucket".into(), Value::from(b)); - } - if !stage.is_empty() { - v["stage"] = Value::Object(stage); - } - v -} - fn nullable_enum(labels: &[&str]) -> serde_json::Value { let mut e: Vec = labels.iter().map(|l| serde_json::Value::from(*l)).collect(); e.push(serde_json::Value::Null); @@ -872,35 +845,6 @@ pub fn capabilities_schema() -> String { "type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$", "description": "The date of the first commit of this repository that registers the string; the specification entered the repository on 2026-06-12." - }, - "ops": { - "type": "object", - "description": "The integer operator set the capability decodes through (extensions/integer-operators.md §11). Absent for a capability that does not.", - "additionalProperties": false, - "required": ["set", "profile", "subset", "lowering"], - "properties": { - "set": { "const": "wai-ops/1" }, - "profile": { "enum": ["acc32", "acc64"] }, - "subset": { "enum": ["full", "npu"] }, - "lowering": { "type": "string" }, - "stage": { - "type": "object", - "additionalProperties": false, - "properties": { - "rans64": { - "type": "object", - "additionalProperties": false, - "required": ["table", "escape", "end"], - "properties": { - "table": { "enum": ["t4097", "t2"] }, - "escape": { "enum": ["lenient", "strict"] }, - "end": { "enum": ["canonical", "final_only"] } - } - }, - "bucket": { "enum": ["ceil", "floor"] } - } - } - } } } } diff --git a/wai/wai-rs/src/int_ops.rs b/wai/wai-rs/src/int_ops.rs deleted file mode 100644 index 1ff118e61..000000000 --- a/wai/wai-rs/src/int_ops.rs +++ /dev/null @@ -1,221 +0,0 @@ -//! The integer decoders' networks through `wai-ops/1`, the integer operator set -//! (`extensions/integer-operators.md`). -//! -//! A decoder lowers its parameter set to `WHG1` graphs (§13), loads each graph under -//! its capability's profile — load steps 1–8 of §10.5, the last of which is the bounds -//! pass whose certificate must meet that profile (§8.6) — and runs it through the -//! operator set's interpreter ([`wai_ops::run_with`]). The interpreter owns the plan, -//! every range check and every refusal; this module only lends it this crate's fast -//! kernels ([`crate::int_kernels`]) for the operators they cover, under the current -//! [`crate::int_kernels::Policy`]: -//! -//! - under [`KernelPath::Reference`](crate::int_kernels::KernelPath) the interpreter -//! computes every operator itself, so a decode is the operator set's reference; -//! - under [`KernelPath::Fast`](crate::int_kernels::KernelPath) a convolution, a -//! transposed convolution and an inverse GDN run on the fast kernels, which form the -//! same exact sums (`int_kernels`' equivalence rule) and finish each output with the -//! operator set's own requantisation ([`wai_ops::interp::rq`]). Where a fast kernel -//! declines (operands past its narrowed types, geometry it does not cover), the -//! interpreter computes the operator. -//! -//! Either way the integers are the interpreter's (§6), which `tests/int_kernels_vectors.rs` -//! checks on every committed vector and the tests below check operator by operator. - -use wai_ops::graph::{Graph, Loaded, Op, Params}; -use wai_ops::interp::{Kernels, Sequence, Tensor}; -use wai_ops::{Code, Profile, Refusal}; - -use crate::int_kernels::{policy, ConvGeom, KernelPath}; - -/// Load a lowered graph as a sink does: from its canonical bytes, under `acc64`, the -/// profile every lowering of §13 declares (§11). Steps 1–8 of §10.5; a refusal is -/// `model_mismatch`, or `too_large` for a size. -pub fn load(g: &Graph) -> Result { - wai_ops::graph::load(&g.to_bytes(), Profile::Acc64) -} - -/// One decode of a loaded graph with one data input and no state: its first output. -pub fn run1(g: &Loaded, x: Tensor, scalars: &[i64]) -> Result { - let mut out = wai_ops::run_with(g, &[x], scalars, &mut Sequence::new(), &Host)?; - Ok(out.swap_remove(0)) -} - -/// Whether a refusal is about the values a decode presented (a `check` range, a -/// scalar out of its range), rather than the set or the shapes. -pub fn is_range(r: &Refusal) -> bool { - r.code == Code::FieldRange -} - -/// This crate's fast kernels, lent to the interpreter (see the module docs). -pub struct Host; - -impl Kernels for Host { - fn eval(&self, op: &Op, ins: &[&Tensor], out: &[(u32, u32, u32)]) -> Option> { - if policy().path != KernelPath::Fast { - return None; - } - let x = ins[0]; - let &(oc, oh, ow) = out.first()?; - let (cin, h, w) = (x.c as usize, x.h as usize, x.w as usize); - let u = |v: &u32| *v as usize; - let data = match &op.p { - Params::Conv { cout, kh, kw, sh, sw, ph, pw, groups, rq, w: wt, .. } => { - let g = ConvGeom::conv(cin, h, w, u(cout), u(groups), u(kh), u(kw), u(sh), u(sw), u(ph), u(pw), x.data.len(), wt.len())?; - conv(&g, &x.data, wt, rq, (oh, ow))? - } - Params::ConvT { cout, kh, kw, sh, sw, ph, pw, oph, opw, rq, w: wt, .. } => { - let g = ConvGeom::transposed(cin, h, w, u(cout), u(kh), u(kw), u(sh), u(sw), u(ph), u(pw), u(oph), u(opw), x.data.len(), wt.len())?; - conv(&g, &x.data, wt, rq, (oh, ow))? - } - Params::Igdn { gf, frac, gamma, beta, .. } => crate::int_kernels::igdn(&x.data, cin, h * w, *frac, gamma, beta, *gf)?, - _ => return None, - }; - Some(vec![Tensor::new(oc, oh, ow, data)]) - } -} - -/// A (transposed) convolution on the fast kernel, each output finished with the -/// operator set's requantisation. `None` where the kernel declines, where its sizes are -/// not the plan's, or where a requantised value leaves `i64` (which no certified graph -/// forms: the interpreter then computes the operator, and refuses it). -fn conv(g: &ConvGeom, x: &[i64], wt: &[i64], rq: &wai_ops::graph::Rq, (oh, ow): (u32, u32)) -> Option> { - use std::sync::atomic::{AtomicBool, Ordering}; - if (g.ho, g.wo) != (oh as usize, ow as usize) { - return None; - } - let wide = AtomicBool::new(false); - let out = crate::int_kernels::conv_acc_i128(g, x, wt, |oc, acc| match wai_ops::interp::rq(rq, oc, acc).map(i64::try_from) { - Some(Ok(v)) => v, - _ => { - wide.store(true, Ordering::Relaxed); - 0 - } - })?; - (!wide.load(Ordering::Relaxed)).then_some(out) -} - -/// The fast kernels behind the interpreter against the interpreter alone, operator by -/// operator, on randomised graphs: every output element must be the same integer under -/// every fast policy, and a refusal the same refusal. This is the per-operator half of -/// the equivalence the decoders rely on; the whole-decoder half is -/// `tests/int_kernels_vectors.rs`. -#[cfg(test)] -mod equivalence { - use super::*; - use crate::int_kernels::testkit::{Rng, FAST}; - use crate::int_kernels::{with_policy, Policy}; - use wai_ops::graph::{BiasKind, InputRec, Round, Rq, TensorRec, opcode, CAP}; - - fn graph(x: TensorRec, code: u8, cout: u32, p: Params) -> Graph { - Graph { - max_elems: CAP as u32, - scalars: vec![], - inputs: vec![InputRec { tensor: 0, max_h: 0, max_w: 0, kind: 0, src: 0, init: 0 }], - outputs: vec![1], - tensors: vec![x, TensorRec::derived(cout)], - ops: vec![Op { code, ins: vec![0], outs: vec![1], p }], - } - } - - fn rq(g: &mut Rng, cout: u32, round: Round, bias_kind: BiasKind, mult: bool) -> Rq { - let n = cout as usize; - let mult_v: Vec = if mult { g.vec(n, 1 << 20, 0) } else { vec![] }; - let bias: Vec = if bias_kind == BiasKind::None { vec![] } else { g.vec(n, 1 << 24, 10) }; - Rq { - round, - bias_kind, - m_kind: if mult { 2 } else { 0 }, - s_kind: 1, - m_bytes: if mult { 8 } else { 0 }, - b_bytes: if bias_kind == BiasKind::None { 0 } else { 8 }, - mult: mult_v, - shift: vec![g.below(40) as u8], - bias, - } - } - - /// Run `g` on `x` under the interpreter alone and under every fast policy; the - /// results must agree. Returns whether the graph loaded (some random graphs fail - /// their certificate, which is then the same refusal under every policy). - fn agree(g: &Graph, x: &Tensor) -> bool { - let Ok(l) = load(g) else { return false }; - let want = with_policy(Policy::reference(), || run1(&l, x.clone(), &[])); - for p in FAST { - let got = with_policy(p, || run1(&l, x.clone(), &[])); - assert_eq!(got, want, "{p:?} on {:?}", g.ops[0].code); - } - true - } - - #[test] - fn convolutions_equal_the_interpreter() { - let mut r = Rng(0x0D2C_0001); - let mut loaded = 0; - for case in 0..400 { - let (k, s) = (1 + r.below(5), 1 + r.below(3)); - let (cin, cout) = (1 + r.below(5) as u32, 1 + r.below(6) as u32); - let (h, w) = (1 + r.below(6) as u32, 1 + r.below(8) as u32); - let transposed = case % 2 == 1; - let round = [Round::Floor, Round::HalfUp, Round::HalfEven][r.below(3)]; - let bias_kind = [BiasKind::None, BiasKind::Pre, BiasKind::Post][r.below(3)]; - let with_mult = r.below(2) == 1; - let q = rq(&mut r, cout, round, bias_kind, with_mult); - let xmax = [127i64, 1 << 12, 1 << 20][r.below(3)]; - let wt = r.vec(cin as usize * cout as usize * k * k, 1 << 14, 10); - let (code, p) = if transposed { - let pad = r.below(k); - let op = if s > 1 { r.below(s) } else { 0 }; - (opcode::CONV_TRANSPOSE, Params::ConvT { - cout, kh: k as u32, kw: k as u32, sh: s as u32, sw: s as u32, ph: pad as u32, pw: pad as u32, - oph: op as u32, opw: op as u32, w_bytes: 8, rq: q, w: wt, - }) - } else { - let pad = r.below(k); - (opcode::CONV, Params::Conv { - cout, kh: k as u32, kw: k as u32, sh: s as u32, sw: s as u32, ph: pad as u32, pw: pad as u32, - groups: 1, w_bytes: 8, rq: q, w: wt, - }) - }; - let g = graph(TensorRec::check(cin, -xmax, xmax), code, cout, p); - let x = Tensor::new(cin, h, w, r.vec((cin * h * w) as usize, xmax, 30)); - loaded += agree(&g, &x) as u32; - } - assert!(loaded > 300, "only {loaded} of 400 random graphs loaded"); - } - - #[test] - fn inverse_gdn_equals_the_interpreter() { - let mut r = Rng(0x0D2C_0002); - let mut loaded = 0; - for _ in 0..200 { - let c = 1 + r.below(6) as u32; - let (h, w) = (1 + r.below(5) as u32, 1 + r.below(7) as u32); - let (gf, frac) = (r.below(20) as u32, 1 + r.below(20) as u32); - let gamma: Vec = r.vec((c * c) as usize, 1 << 16, 20).into_iter().map(i64::abs).collect(); - let beta: Vec = r.vec(c as usize, 1 << 30, 0).into_iter().map(|b| b.abs() + 1).collect(); - let xmax = [1i64 << 10, 1 << 20, 1 << 31][r.below(3)]; - let p = Params::Igdn { gf, frac, g_bytes: 4, gamma, beta }; - let g = graph(TensorRec::check(c, -xmax, xmax), opcode::IGDN, c, p); - let x = Tensor::new(c, h, w, r.vec((c * h * w) as usize, xmax, 20)); - loaded += agree(&g, &x) as u32; - } - assert!(loaded > 150, "only {loaded} of 200 random graphs loaded"); - } - - /// A value outside an input's `check` range is refused by the interpreter before any - /// kernel runs, under every policy alike. - #[test] - fn a_check_range_refuses_before_the_kernel() { - let p = Params::Conv { - cout: 1, kh: 1, kw: 1, sh: 1, sw: 1, ph: 0, pw: 0, groups: 1, w_bytes: 1, - rq: Rq::plain(Round::Floor, 0), w: vec![3], - }; - let g = graph(TensorRec::check(1, -10, 10), opcode::CONV, 1, p); - let l = load(&g).unwrap(); - for pol in [Policy::reference(), Policy::fast(1), Policy::fast(0)] { - assert_eq!(with_policy(pol, || run1(&l, Tensor::new(1, 1, 2, vec![10, -10]), &[])).unwrap().data, vec![30, -30]); - let r = with_policy(pol, || run1(&l, Tensor::new(1, 1, 2, vec![10, 11]), &[])).unwrap_err(); - assert!(is_range(&r), "{pol:?}: {r:?}"); - } - } -} diff --git a/wai/wai-rs/src/int_synth.rs b/wai/wai-rs/src/int_synth.rs index fd06c9624..409c4f94c 100644 --- a/wai/wai-rs/src/int_synth.rs +++ b/wai/wai-rs/src/int_synth.rs @@ -25,7 +25,7 @@ //! thing that makes cross-hardware bit-exactness real — lives here and is exact //! whatever the weights. //! -//! Wire format (little-endian), `extensions/integer-payloads.md` §13: +//! Wire format (little-endian): //! u32 H, u32 W, u16 C, u8 S_log2, u8 _reserved (12 B) //! C*S*S bytes int8 latents, channel-major //! @@ -34,16 +34,9 @@ //! weight:[i32; in*out*k*k] (layout ic,oc,ky,kx), //! bias:[i32; out_ch], shift:u32, relu:bool }, … ] } //! The final layer MUST output 3 channels at H×W; values are clamped to [0,255]. -//! -//! The decode is the graph `synth` of the set's lowering `int_synth/1` -//! (`extensions/integer-operators.md` §13.1), loaded under `acc64` and run through the -//! integer operator set ([`crate::int_ops`]). The direct path it replaced is kept, -//! test-only, as [`legacy`] (`legacy_int`): the reference the lowering was checked -//! against, decoder against decoder. use std::path::Path; -#[cfg(any(test, feature = "legacy_int"))] use serde::Deserialize; const HEADER: usize = 12; @@ -73,7 +66,6 @@ pub struct IntImage { pub height: u32, } -#[cfg(any(test, feature = "legacy_int"))] #[derive(Debug, Clone, Deserialize)] struct LayerSpec { in_ch: usize, @@ -95,7 +87,6 @@ struct LayerSpec { relu: bool, } -#[cfg(any(test, feature = "legacy_int"))] #[derive(Debug, Clone, Deserialize)] struct SynthSpec { layers: Vec, @@ -106,7 +97,6 @@ struct SynthSpec { /// ([`crate::int_kernels`]) unless the current [`crate::int_kernels::Policy`] selects /// the reference; both give the same integer for every output element, and every /// error case is left to the reference so errors are reported exactly as before. -#[cfg(any(test, feature = "legacy_int"))] fn conv_transpose2d( input: &[i64], cin: usize, @@ -147,7 +137,6 @@ fn conv_transpose2d( /// where the reference's size arithmetic fails. `ConvGeom::transposed`'s bound on /// the uncropped plane already keeps every term far below `i64::MAX`; this check /// states the rule where the reference's arithmetic is, independent of that cap. -#[cfg(any(test, feature = "legacy_int"))] fn reference_output_side(n: usize, l: &LayerSpec) -> Option { let v = (n as i64) .checked_sub(1)? @@ -159,7 +148,6 @@ fn reference_output_side(n: usize, l: &LayerSpec) -> Option { } /// The scalar reference kernel the fast path is checked against, kept verbatim. -#[cfg(any(test, feature = "legacy_int"))] mod reference { use super::{IntSynthError, LayerSpec}; @@ -271,39 +259,7 @@ pub fn decode_bytes(payload: &[u8], spec_json: &[u8]) -> Result Result { - let (h, w, c, s) = header(payload)?; - let refused = |what: &str, r: wai_ops::Refusal| IntSynthError::InvalidPayload(format!("int_synth {what}: {} at {:?}", r.code.as_str(), r.at)); - let set = wai_ops::lower::int_synth(spec_json).map_err(|r| refused("int_synth.json does not lower", r))?; - let g = crate::int_ops::load(set.get("synth").expect("int_synth/1 lowers to the graph synth")).map_err(|r| refused("set does not load", r))?; - let latents: Vec = payload[HEADER..].iter().map(|&b| b as i8 as i64).collect(); - let out = crate::int_ops::run1(&g, wai_ops::Tensor::new(c, s, s, latents), &[]).map_err(|r| refused("decode", r))?; - if (out.c, out.h as usize, out.w as usize) != (3, h, w) { - return Err(IntSynthError::InvalidPayload(format!( - "int_synth output [{},{},{}] != [3,{h},{w}]", out.c, out.h, out.w))); - } - // `clamp(0, 255)` ends the graph, so every value is a byte. - let hw = h * w; - let mut rgb = vec![0u8; hw * 3]; - for (k, &v) in out.data.iter().enumerate() { - rgb[(k % hw) * 3 + k / hw] = v as u8; - } - Ok(IntImage { rgb, width: w as u32, height: h as u32 }) -} - -/// The payload's header (`extensions/integer-payloads.md` §13): `(H, W, C, S)`, after -/// its checks. The side is `2^S_log2`; `C·S·S` is formed without overflow and is at -/// most `2^26`, and the payload is exactly `12 + C·S·S` bytes. -fn header(payload: &[u8]) -> Result<(usize, usize, u32, u32), IntSynthError> { if payload.len() < HEADER { return Err(IntSynthError::InvalidPayload(format!( "int_synth payload {} B < {HEADER} B header", payload.len()))); @@ -311,86 +267,50 @@ fn header(payload: &[u8]) -> Result<(usize, usize, u32, u32), IntSynthError> { let h = u32::from_le_bytes(payload[0..4].try_into().unwrap()) as usize; let w = u32::from_le_bytes(payload[4..8].try_into().unwrap()) as usize; let c = u16::from_le_bytes(payload[8..10].try_into().unwrap()) as usize; - if payload[11] != 0 { - return Err(IntSynthError::InvalidPayload("int_synth payload's reserved byte is not 0".into())); - } - if c == 0 { - return Err(IntSynthError::InvalidPayload("int_synth payload has no latent channels".into())); - } - const CAP: usize = 1 << 26; + // The side is 2^payload[10]. A shift of the word's width or more panics with + // overflow checks on and masks without them, so the same bytes would decode two + // ways; and C·S·S must be formed without overflow. Neither can match a payload's + // length when it overflows, so both are refused here as a length mismatch. let n = 1usize.checked_shl(payload[10] as u32) - .and_then(|s| c.checked_mul(s)?.checked_mul(s).map(|n| (s, n))) - .filter(|&(_, n)| n <= CAP); + .and_then(|s| c.checked_mul(s)?.checked_mul(s).map(|n| (s, n))); let Some((s, _)) = n.filter(|&(_, n)| HEADER.checked_add(n) == Some(payload.len())) else { return Err(IntSynthError::InvalidPayload(format!( - "int_synth payload {} B is not {HEADER} + C·S·S with C = {c}, S = 2^{}, C·S·S ≤ 2^26", payload.len(), payload[10]))); + "int_synth payload {} B is not {HEADER} + C·S·S with C = {c}, S = 2^{}", payload.len(), payload[10]))); }; - Ok((h, w, c as u32, s as u32)) -} -/// The decode as it was before it ran through the integer operator set: the payload -/// and set read directly, each layer's transposed convolution, bias, shift and ReLU -/// computed by [`conv_transpose2d`]. Test-only (`legacy_int`): the reference the -/// lowering is checked against, decoder against decoder, on every committed vector -/// and on random sets and payloads (`tests/int_ops_differential.rs`). -#[cfg(any(test, feature = "legacy_int"))] -pub mod legacy { - use super::*; + let spec: SynthSpec = serde_json::from_slice(spec_json) + .map_err(|e| IntSynthError::InvalidPayload(format!("int_synth.json parse: {e}")))?; - /// The legacy decode of [`super::decode_from_spec`]'s arguments. - pub fn decode_from_spec(payload: &[u8], spec_json: &[u8]) -> Result { - if payload.len() < HEADER { + // int8 latents → i64. + let mut cur: Vec = payload[HEADER..].iter().map(|&b| b as i8 as i64).collect(); + let (mut cin, mut ch, mut cw) = (c, s, s); + for (i, l) in spec.layers.iter().enumerate() { + if l.in_ch != cin { return Err(IntSynthError::InvalidPayload(format!( - "int_synth payload {} B < {HEADER} B header", payload.len()))); + "int_synth layer {i} in_ch {} != current {cin}", l.in_ch))); } - let h = u32::from_le_bytes(payload[0..4].try_into().unwrap()) as usize; - let w = u32::from_le_bytes(payload[4..8].try_into().unwrap()) as usize; - let c = u16::from_le_bytes(payload[8..10].try_into().unwrap()) as usize; - // The side is 2^payload[10]. A shift of the word's width or more panics with - // overflow checks on and masks without them, so the same bytes would decode two - // ways; and C·S·S must be formed without overflow. Neither can match a payload's - // length when it overflows, so both are refused here as a length mismatch. - let n = 1usize.checked_shl(payload[10] as u32) - .and_then(|s| c.checked_mul(s)?.checked_mul(s).map(|n| (s, n))); - let Some((s, _)) = n.filter(|&(_, n)| HEADER.checked_add(n) == Some(payload.len())) else { - return Err(IntSynthError::InvalidPayload(format!( - "int_synth payload {} B is not {HEADER} + C·S·S with C = {c}, S = 2^{}", payload.len(), payload[10]))); - }; - - let spec: SynthSpec = serde_json::from_slice(spec_json) - .map_err(|e| IntSynthError::InvalidPayload(format!("int_synth.json parse: {e}")))?; - - // int8 latents → i64. - let mut cur: Vec = payload[HEADER..].iter().map(|&b| b as i8 as i64).collect(); - let (mut cin, mut ch, mut cw) = (c, s, s); - for (i, l) in spec.layers.iter().enumerate() { - if l.in_ch != cin { - return Err(IntSynthError::InvalidPayload(format!( - "int_synth layer {i} in_ch {} != current {cin}", l.in_ch))); - } - let (out, ho, wo) = conv_transpose2d(&cur, cin, ch, cw, l)?; - cur = out; - cin = l.out_ch; - ch = ho; - cw = wo; - } - if cin != 3 || ch != h || cw != w { - return Err(IntSynthError::InvalidPayload(format!( - "int_synth output [{cin},{ch},{cw}] != [3,{h},{w}]"))); - } - - let mut rgb = vec![0u8; h * w * 3]; - let hw = h * w; - for y in 0..h { - for x in 0..w { - let pi = y * w + x; - for ch_i in 0..3 { - rgb[pi * 3 + ch_i] = cur[ch_i * hw + pi].clamp(0, 255) as u8; - } - } - } - Ok(IntImage { rgb, width: w as u32, height: h as u32 }) + let (out, ho, wo) = conv_transpose2d(&cur, cin, ch, cw, l)?; + cur = out; + cin = l.out_ch; + ch = ho; + cw = wo; } + if cin != 3 || ch != h || cw != w { + return Err(IntSynthError::InvalidPayload(format!( + "int_synth output [{cin},{ch},{cw}] != [3,{h},{w}]"))); + } + + let mut rgb = vec![0u8; h * w * 3]; + let hw = h * w; + for y in 0..h { + for x in 0..w { + let pi = y * w + x; + for ch_i in 0..3 { + rgb[pi * 3 + ch_i] = cur[ch_i * hw + pi].clamp(0, 255) as u8; + } + } + } + Ok(IntImage { rgb, width: w as u32, height: h as u32 }) } #[cfg(test)] diff --git a/wai/wai-rs/src/lib.rs b/wai/wai-rs/src/lib.rs index 1badac768..ccef4b0f9 100644 --- a/wai/wai-rs/src/lib.rs +++ b/wai/wai-rs/src/lib.rs @@ -57,11 +57,6 @@ pub mod int_synth; // on wasm. #[cfg(feature = "neural_int")] pub mod int_kernels; -// The integer decoders' networks through the integer operator set `wai-ops/1` -// (extensions/integer-operators.md): lowered, loaded under the certificate's profile -// and run by its interpreter, with the fast kernels above behind it. -#[cfg(feature = "neural_int")] -pub mod int_ops; // Integer Gaussian-conditional entropy — the reusable scale→bucket primitive // that makes a conditional codec's entropy decode hardware-deterministic (the diff --git a/wai/wai-rs/tests/int_kernels_geometry.rs b/wai/wai-rs/tests/int_kernels_geometry.rs index 0db50de0a..8d7b6e4d7 100644 --- a/wai/wai-rs/tests/int_kernels_geometry.rs +++ b/wai/wai-rs/tests/int_kernels_geometry.rs @@ -14,10 +14,9 @@ //! The reference computes each of the first three in a handful of operations. Every //! policy must return the same integers (checked against a hand computation), and //! the fast path must not overflow, or allocate or loop in proportion to the stride -//! or the padding, where the reference does not. In the last, the reference panics, -//! and every policy must do the same. int_synth decodes through its lowering -//! (integer-operators §13.1), whose fields are 32 bits and whose run-time plan caps -//! every size it forms at 2^26: where a case is past either, every policy refuses it. The int_audio 1-D transposed convolution has the +//! or the padding, where the reference does not. In the last, the reference panics +//! (or, for int_synth with overflow checks off, returns), and every policy must do +//! the same. The int_audio 1-D transposed convolution has the //! same stride check beside its kernel (`int_audio` unit tests). CI runs this file //! beside `int_kernels_vectors` on every `wai-conformance` leg, with overflow checks //! on and off. @@ -41,6 +40,13 @@ fn same_under_every_policy(what: &str, f: impl F want } +/// Whether this build traps on integer overflow. Detected by performing one, since +/// `cfg!(debug_assertions)` only usually coincides with `overflow-checks`. +#[cfg(target_pointer_width = "64")] +fn overflow_checks() -> bool { + std::panic::catch_unwind(|| std::hint::black_box(i64::MAX) + std::hint::black_box(1)).is_err() +} + /// Run under every policy, catching a panic. The outcome (the value, or that the /// call panicked) must be the same under every policy. Returns the reference /// kernels' outcome, `None` for a panic. The panic message is not compared: a fast @@ -123,14 +129,8 @@ fn a_1x1_input_upsampled_by_any_stride() { let got = same_under_every_policy(&format!("int_synth, stride {s}"), || { wai::int_synth::decode_from_spec(&payload, &spec).map(|img| img.rgb) }); - if s <= u32::MAX as usize { - // centre taps 5, 14, 23 of the three output channels, times 5 - assert_eq!(got, Ok(vec![25, 70, 115]), "int_synth, stride {s}"); - } else { - // int_synth decodes through its lowering (integer-operators §13.1), whose - // `WHG1` fields are 32 bits: a stride past them is refused, a planned break. - assert!(got.is_err(), "int_synth, stride {s}: a stride past 32 bits is refused"); - } + // centre taps 5, 14, 23 of the three output channels, times 5 + assert_eq!(got, Ok(vec![25, 70, 115]), "int_synth, stride {s}"); } } @@ -170,20 +170,10 @@ fn padding_far_beyond_the_kernel() { let p = 1usize << 12; // Transposed: pad 2^12 with output padding 2^13 − 2 restores a 1×1 output that // no tap reaches (it sits 2^12 rows into the uncropped plane), so it is the bias. - // int_synth decodes through its lowering, whose run-time plan caps the uncropped - // output a transposed convolution forms at 2^26 elements (integer-operators §5.3); - // this one is 3 · 8193 · 8193, so the decode is refused, a planned break. One row - // and column less output padding than the cap allows still decodes to the bias. let (payload, spec) = (synth_payload(5), synth_spec(1, p, 2 * p - 2, [10, 20, 30])); let got = same_under_every_policy("int_synth, pad 2^12", || { wai::int_synth::decode_from_spec(&payload, &spec).map(|img| img.rgb) }); - assert!(got.is_err(), "int_synth, pad 2^12: the uncropped output is past the cap"); - let q = 1usize << 11; - let (payload, spec) = (synth_payload(5), synth_spec(1, q, 2 * q - 2, [10, 20, 30])); - let got = same_under_every_policy("int_synth, pad 2^11", || { - wai::int_synth::decode_from_spec(&payload, &spec).map(|img| img.rgb) - }); assert_eq!(got, Ok(vec![10, 20, 30])); // Regular: a 1×1 input, 1×1 kernel, pad 2^12, stride 2^13 — a 2×2 output whose // samples all fall in the padding, so each is the bias. @@ -280,22 +270,24 @@ fn int_synth_where_the_reference_output_size_overflows_i64() { // although every `usize` step fits. With overflow checks on it panics; with them // off the wrapped sum is still 3 and the decode returns. Only latent (1, 1) // reaches the output, through every tap. - // int_synth decodes through its lowering (integer-operators §13.1). Its direct path - // panicked here with overflow checks on, and with them off returned the wrapped - // size's output; through the lowering the decode is refused in every build and under - // every policy: a stride past 32 bits does not fit `WHG1`. A planned break. let s = 1usize << 62; let (payload, spec) = (synth_payload_2x2(3, 3, [1, 2, 3, 4]), synth_spec(s, s, s, [0, 0, 0])); let got = same_outcome_under_every_policy("int_synth, 2×2 latent, stride = pad = out_pad = 2^62", || { wai::int_synth::decode_from_spec(&payload, &spec).map(|img| img.rgb) }); - assert!(matches!(got, Some(Err(_))), "refused, not panicked: {got:?}"); + if overflow_checks() { + assert_eq!(got, None, "the reference panics on its i64 output size"); + } else { + // rgb[(3y + x)·3 + c] = 4 · weight[c][y][x] = 4 · (9c + 3y + x + 1) + let want: Vec = (0..9).flat_map(|px| (0..3).map(move |c| (4 * (9 * c + px + 1)) as u8)).collect(); + assert_eq!(got, Some(Ok(want)), "the wrapped output size is 3"); + } - // The u32 fields fit `WHG1`, but the uncropped side (2 − 1)·(2^32 − 1) + 3 is past - // the run-time plan's cap of 2^26: refused (the direct path returned the bias). + // The u32 fields: no `i64` step overflows, and int_synth's reference allocates + // no uncropped plane, so it returns. A 4×4 output that no tap reaches: the bias. let (payload, spec) = (synth_payload_2x2(4, 4, [1, 2, 3, 4]), synth_spec(S32, P32, 0, [10, 20, 30])); let got = same_outcome_under_every_policy("int_synth, 2×2 latent, u32 stride and pad", || { wai::int_synth::decode_from_spec(&payload, &spec).map(|img| img.rgb) }); - assert!(matches!(got, Some(Err(_))), "u32 fields: refused by the cap: {got:?}"); + assert_eq!(got, Some(Ok([10u8, 20, 30].repeat(16))), "u32 fields: the bias alone"); } diff --git a/wai/wai-rs/tests/int_ops_differential.rs b/wai/wai-rs/tests/int_ops_differential.rs deleted file mode 100644 index e79bbdd64..000000000 --- a/wai/wai-rs/tests/int_ops_differential.rs +++ /dev/null @@ -1,222 +0,0 @@ -//! Each integer decoder through the integer operator set against its direct path as it -//! was before (`legacy_int`, test-only): on every committed set and payload, and on -//! random ones, the two must give the same output, or both refuse with the same code. -//! The only inputs on which they may differ are the planned breaks the operator set -//! makes (`extensions/integer-operators.md` §15.3), which are new refusals; each test -//! states the ones its generator can reach and checks that only those differ. -//! -//! The random cases run for a fixed count by default. `WAI_DIFF_SECS` runs each random -//! test for that many seconds instead (the nightly run), with `WAI_DIFF_SEED` as the -//! first seed. -#![cfg(feature = "legacy_int")] - -use std::path::{Path, PathBuf}; -use std::time::{Duration, Instant}; - -fn wai() -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..") -} - -fn read(p: &Path) -> Vec { - std::fs::read(p).unwrap_or_else(|e| panic!("{}: {e}", p.display())) -} - -/// A splitmix64 sequence. -struct Rng(u64); - -impl Rng { - fn next(&mut self) -> u64 { - self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15); - let mut z = self.0; - z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); - z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); - z ^ (z >> 31) - } - fn below(&mut self, n: u64) -> u64 { - self.next() % n - } - /// A value in `[−r, r]`. - fn signed(&mut self, r: i64) -> i64 { - (self.below(2 * r as u64 + 1) as i64) - r - } -} - -/// Run `case` on successive seeds: `n` of them, or for `WAI_DIFF_SECS` seconds. -/// Returns how many ran. -fn cases(name: &str, n: u64, mut case: impl FnMut(&mut Rng, u64)) -> u64 { - let secs: Option = std::env::var("WAI_DIFF_SECS").ok().and_then(|s| s.parse().ok()); - let seed0: u64 = std::env::var("WAI_DIFF_SEED").ok().and_then(|s| s.parse().ok()).unwrap_or(0x0D2C); - let until = secs.map(|s| Instant::now() + Duration::from_secs(s)); - let mut k = 0u64; - loop { - match until { - Some(t) if Instant::now() >= t => break, - None if k >= n => break, - _ => {} - } - let mut r = Rng(seed0 ^ k.wrapping_mul(0xD1B5_4A32_D192_ED03)); - case(&mut r, k); - k += 1; - } - eprintln!("{name}: {k} cases"); - k -} - -// ------------------------------------------------------------------ int_synth (M1) - -/// Every committed `int_synth` set and payload, including the copies the browser demo -/// serves. -fn int_synth_committed() -> Vec<(PathBuf, PathBuf)> { - let mut v = Vec::new(); - for root in ["neural-conformance/vectors", "wai-web/demo/int_synth"] { - let dir = wai().join(root); - let mut names: Vec<_> = std::fs::read_dir(&dir).unwrap().filter_map(|e| e.ok()).map(|e| e.path()).filter(|p| p.join("int_synth.json").exists()).collect(); - names.sort(); - for d in names { - v.push((d.join("int_synth.json"), d.join("latent.bin"))); - } - } - assert!(v.len() >= 7, "every committed int_synth vector: found {}", v.len()); - v -} - -/// The new load rules (§15.3 item 1) affect no committed `int_synth` set or payload: -/// every set lowers and loads under `acc64`, and every payload's reserved byte is 0, -/// its `C` at least 1 and its latents within the cap. The falsification set -/// `overflow_acc64` is the one set made to fail its certificate, and does. -#[test] -fn int_synth_new_rules_affect_no_committed_set_or_payload() { - let mut sets: Vec = int_synth_committed().into_iter().map(|(s, _)| s).collect(); - for s in ["synth", "claim_acc32"] { - sets.push(wai().join(format!("ops-conformance/sets/{s}/int_synth.json"))); - } - for s in &sets { - let set = wai_ops::lower::int_synth(&read(s)).unwrap_or_else(|r| panic!("{}: does not lower: {r:?}", s.display())); - wai::int_ops::load(set.get("synth").unwrap()).unwrap_or_else(|r| panic!("{}: does not load: {r:?}", s.display())); - } - let overflow = wai_ops::lower::int_synth(&read(&wai().join("ops-conformance/sets/overflow_acc64/int_synth.json"))).unwrap(); - assert!(wai::int_ops::load(overflow.get("synth").unwrap()).is_err(), "the falsification set fails acc64"); - for (_, p) in int_synth_committed() { - let b = read(&p); - let c = u16::from_le_bytes([b[8], b[9]]) as usize; - let s = 1usize << b[10]; - assert_eq!(b[11], 0, "{}: reserved byte", p.display()); - assert!(c >= 1 && c * s * s <= 1 << 26, "{}: C and the cap", p.display()); - } -} - -#[test] -fn int_synth_committed_vectors_decode_alike() { - for (s, p) in int_synth_committed() { - let (set, pay) = (read(&s), read(&p)); - let new = wai::int_synth::decode_from_spec(&pay, &set); - let old = wai::int_synth::legacy::decode_from_spec(&pay, &set); - assert!(new.is_ok(), "{}: {new:?}", s.display()); - assert_eq!(new, old, "{}", s.display()); - } -} - -/// A random `int_synth.json` of 1 to 3 layers and a payload for it: kernels 1 to 4, -/// strides 1 to 3, paddings below the kernel, output paddings below the stride, shifts -/// 0 to 24, weights and biases of up to 2^14 and 2^18, latent channels 1 to 3 and -/// sides 1 to 4. The payload's `H` and `W` are the output's sides, or (one case in -/// eight) not. -fn int_synth_case(r: &mut Rng) -> (Vec, Vec) { - let c0 = 1 + r.below(3) as usize; - let s_log2 = r.below(3) as u8; - let (mut c, mut side) = (c0, 1usize << s_log2); - let n_layers = 1 + r.below(3) as usize; - let mut layers = Vec::new(); - for i in 0..n_layers { - let out = if i + 1 == n_layers { 3 } else { 1 + r.below(4) as usize }; - let k = 1 + r.below(4) as usize; - let stride = 1 + r.below(3) as usize; - let pad = r.below(k as u64) as usize; - let out_pad = r.below(stride as u64) as usize; - let shift = r.below(25); - let weight: Vec = (0..c * out * k * k).map(|_| if r.below(4) == 0 { 0 } else { r.signed(1 << 14) }).collect(); - let bias: Vec = (0..out).map(|_| r.signed(1 << 18)).collect(); - layers.push(serde_json::json!({ - "in_ch": c, "out_ch": out, "k": k, "stride": stride, "pad": pad, "out_pad": out_pad, - "weight": weight, "bias": bias, "shift": shift, "relu": r.below(2) == 1, - })); - let next = ((side as i64 - 1) * stride as i64 + k as i64 + out_pad as i64 - 2 * pad as i64).max(0) as usize; - (c, side) = (out, next); - } - let (mut h, mut w) = (side as u32, side as u32); - if r.below(8) == 0 { - h += 1 + r.below(2) as u32; - w = w.saturating_sub(r.below(2) as u32); - } - let mut p = Vec::new(); - p.extend(h.to_le_bytes()); - p.extend(w.to_le_bytes()); - p.extend((c0 as u16).to_le_bytes()); - p.extend([s_log2, 0]); - let n = c0 << (2 * s_log2); - p.extend((0..n).map(|_| r.signed(128).clamp(-128, 127) as i8 as u8)); - (serde_json::to_vec(&serde_json::json!({ "layers": layers })).unwrap(), p) -} - -#[test] -fn int_synth_random_sets_and_payloads_decode_alike() { - let (mut ok, mut refused) = (0u64, 0u64); - cases("int_synth", 3000, |r, k| { - let (set, pay) = int_synth_case(r); - let new = wai::int_synth::decode_from_spec(&pay, &set); - let old = wai::int_synth::legacy::decode_from_spec(&pay, &set); - match (&new, &old) { - (Ok(a), Ok(b)) => { - assert_eq!(a, b, "case {k}"); - ok += 1; - } - // One refusal code (`payload_malformed`) covers every refusal. - (Err(_), Err(_)) => refused += 1, - _ => panic!("case {k}: new {new:?}, legacy {old:?}\nset {}", String::from_utf8_lossy(&set)), - } - }); - eprintln!("int_synth: {ok} decoded alike, {refused} refused by both"); - assert!(ok > refused, "the generator must mostly produce decodable cases"); -} - -/// The planned breaks of §15.3 item 1, each on an otherwise decodable case: the legacy -/// path decodes it (or, for a shift of 64 or more, gives a build-dependent result), -/// and the decoder through the operator set refuses it. -#[test] -fn int_synth_planned_breaks_are_new_refusals() { - let set = |k: u64, stride: u64, shift: u64| { - let w: Vec = vec![1; (3 * k * k) as usize]; - serde_json::to_vec(&serde_json::json!({ "layers": [{ - "in_ch": 1, "out_ch": 3, "k": k, "stride": stride, "pad": 0, "weight": w, "bias": [0, 0, 0], "shift": shift, - }]})).unwrap() - }; - let payload = |h: u32, reserved: u8, c: u16| { - let mut p = Vec::new(); - p.extend(h.to_le_bytes()); - p.extend(h.to_le_bytes()); - p.extend(c.to_le_bytes()); - p.extend([0u8, reserved]); - p.extend(std::iter::repeat_n(5u8, c as usize)); - p - }; - let good = (set(1, 1, 0), payload(1, 0, 1)); - assert!(wai::int_synth::decode_from_spec(&good.1, &good.0).is_ok()); - assert!(wai::int_synth::legacy::decode_from_spec(&good.1, &good.0).is_ok()); - // A reserved byte that is not 0. - let p = payload(1, 7, 1); - assert!(wai::int_synth::legacy::decode_from_spec(&p, &good.0).is_ok()); - assert!(wai::int_synth::decode_from_spec(&p, &good.0).is_err()); - // A shift of 128 or more: refused at the lowering. The legacy shift panics or - // masks, by build. - assert!(wai::int_synth::decode_from_spec(&good.1, &set(1, 1, 128)).is_err()); - // A shift from 64 to 127 has its exact meaning: 5 >> 64 = 0. - assert_eq!(wai::int_synth::decode_from_spec(&good.1, &set(1, 1, 64)).unwrap().rgb, vec![0, 0, 0]); - // A stride of 0 (the legacy output is 1×1 here). - let s0 = set(1, 0, 0); - assert!(wai::int_synth::legacy::decode_from_spec(&good.1, &s0).is_ok()); - assert!(wai::int_synth::decode_from_spec(&good.1, &s0).is_err()); - // A certificate that does not meet acc64. - let overflow = read(&wai().join("ops-conformance/sets/overflow_acc64/int_synth.json")); - let lowered = wai_ops::lower::int_synth(&overflow).unwrap(); - assert!(wai::int_ops::load(lowered.get("synth").unwrap()).is_err()); -} diff --git a/wai/wai-web/Cargo.lock b/wai/wai-web/Cargo.lock index 4e0f163a5..9862ac0f3 100644 --- a/wai/wai-web/Cargo.lock +++ b/wai/wai-web/Cargo.lock @@ -1221,14 +1221,9 @@ dependencies = [ "serde", "serde_json", "sha2", - "wai-ops", "wai-quantum", ] -[[package]] -name = "wai-ops" -version = "0.1.0" - [[package]] name = "wai-quantum" version = "0.4.3" -- 2.54.0 (Apple Git-157) From c2cac5b28a9acd3e7efd6f3d8dd2cb6921193a11 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Tue, 13 Oct 2026 02:40:50 -0400 Subject: [PATCH 03/10] D2c M1 gate 1: int_synth's lowering and certificate, as this crate loads them wai-rs depends on wai-ops (behind neural_int) and gains src/int_ops.rs, the bridge every integer decoder will load its lowered graphs through: from their canonical bytes, under acc64, load steps 1-8 with the bounds certificate. tests/int_synth_ops.rs: every committed int_synth set (the neural-conformance corpus and the ops-conformance set) lowers through int_synth/1 to the committed graph, the committed certificate is that graph's, and loading it gives the certificate's verdict: acc64 for all, acc32 for facade, gradient, bloom and the small set, not for photo, as the design predicted. The browser demo's sets are byte copies of the corpus's. The two falsification sets fail the profile they claim: overflow_acc64 is refused at load step 8, claim_acc32 loads under acc64 and does not meet acc32. The independent checker re-lowers and re-certifies every set byte for byte (wai-ops.yml, unchanged). CI: the fast-kernels steps (both profiles) name the new test; wai-conformance, wai-web-wasm and wai-neural-gates fire on wai-ops changes. wai-pack, wai-rs and wai-web lockfiles gain wai-ops. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/wai-conformance.yml | 11 ++- .github/workflows/wai-neural-gates.yml | 6 ++ .github/workflows/wai-web-wasm.yml | 4 + wai/wai-pack/Cargo.lock | 5 + wai/wai-rs/Cargo.lock | 5 + wai/wai-rs/Cargo.toml | 14 ++- wai/wai-rs/src/int_ops.rs | 21 +++++ wai/wai-rs/src/lib.rs | 5 + wai/wai-rs/tests/int_synth_ops.rs | 125 +++++++++++++++++++++++++ wai/wai-web/Cargo.lock | 5 + 10 files changed, 198 insertions(+), 3 deletions(-) create mode 100644 wai/wai-rs/src/int_ops.rs create mode 100644 wai/wai-rs/tests/int_synth_ops.rs diff --git a/.github/workflows/wai-conformance.yml b/.github/workflows/wai-conformance.yml index de128ba15..ea173c1e7 100644 --- a/.github/workflows/wai-conformance.yml +++ b/.github/workflows/wai-conformance.yml @@ -39,6 +39,8 @@ on: # wai-rs path-depends on wai-quantum, and the quantum corpora below are # verified through it: a change there changes what this workflow checks. - 'wai/wai-quantum/**' + # The integer decoders that decode through the integer operator set depend on it. + - 'wai/wai-ops/**' - 'wai/*-conformance/**' - 'wai/byte-exact-conformance/**' # The registry export and the two library tests that read files outside @@ -74,6 +76,8 @@ on: # wai-rs path-depends on wai-quantum, and the quantum corpora below are # verified through it: a change there changes what this workflow checks. - 'wai/wai-quantum/**' + # The integer decoders that decode through the integer operator set depend on it. + - 'wai/wai-ops/**' - 'wai/*-conformance/**' - 'wai/byte-exact-conformance/**' # The registry export and the two library tests that read files outside @@ -204,6 +208,9 @@ jobs: # different things in each: with overflow checks on (dev) that the fast path panics # where the reference panics, with them off (release) that it returns the # same integers. All pure Rust, so every leg runs them. + # tests/int_synth_ops.rs holds wai.neural.int_synth to its lowering through the + # integer operator set (extensions/integer-operators.md §13.1): every committed set + # lowers to its committed graph and loads with its certificate's verdict. # tests/int_mlicpp.rs decodes the committed wai.neural.int_mlicpp vector against # its pinned hashes (image, every scale bucket, every symbol) under every kernel # policy and thread count, and checks that damaged payloads are refused rather than @@ -250,10 +257,10 @@ jobs: # nothing; tests/generate.rs holds the same lists to a second statement of form v0. - name: Fast integer kernels ≡ reference kernels (overflow checks on) working-directory: wai/wai-rs - run: cargo test --no-default-features --features neural_int,video_int,audio_int,world_int,splat_int,generate,score,staged,stage_measure --lib --test int_kernels_vectors --test int_kernels_geometry --test int_mlicpp --test int_mlicpp_preflight --test int_audio --test int_splat --test generate --test cli_generate_score --test int_payload --test int_payload_preflight --test int_payload_cost --test int_video_enc_memory --test cli_stage --test staged_resources --test stage_measure --test stage_measure_bounds + run: cargo test --no-default-features --features neural_int,video_int,audio_int,world_int,splat_int,generate,score,staged,stage_measure --lib --test int_kernels_vectors --test int_kernels_geometry --test int_synth_ops --test int_mlicpp --test int_mlicpp_preflight --test int_audio --test int_splat --test generate --test cli_generate_score --test int_payload --test int_payload_preflight --test int_payload_cost --test int_video_enc_memory --test cli_stage --test staged_resources --test stage_measure --test stage_measure_bounds - name: Fast integer kernels ≡ reference kernels (release, overflow checks off) working-directory: wai/wai-rs - run: cargo test --release --no-default-features --features neural_int,video_int,audio_int,world_int,splat_int,generate,score,staged,stage_measure --lib --test int_kernels_vectors --test int_kernels_geometry --test int_mlicpp --test int_mlicpp_preflight --test int_audio --test int_splat --test generate --test cli_generate_score --test int_payload --test int_payload_preflight --test int_payload_cost --test int_video_enc_memory --test cli_stage --test staged_resources --test stage_measure --test stage_measure_bounds + run: cargo test --release --no-default-features --features neural_int,video_int,audio_int,world_int,splat_int,generate,score,staged,stage_measure --lib --test int_kernels_vectors --test int_kernels_geometry --test int_synth_ops --test int_mlicpp --test int_mlicpp_preflight --test int_audio --test int_splat --test generate --test cli_generate_score --test int_payload --test int_payload_preflight --test int_payload_cost --test int_video_enc_memory --test cli_stage --test staged_resources --test stage_measure --test stage_measure_bounds # The integer decode path builds under each of its features alone, so a sink can take # one medium without the others, and for wasm32 (a browser sink); `int_payload` arms a # medium only under its feature, and none needs `neural`. So does the staged-measurement diff --git a/.github/workflows/wai-neural-gates.yml b/.github/workflows/wai-neural-gates.yml index 0e0b5e1b3..fbd817473 100644 --- a/.github/workflows/wai-neural-gates.yml +++ b/.github/workflows/wai-neural-gates.yml @@ -68,6 +68,9 @@ on: - 'wai/wai-rs/tests/ffi_neural_pin.rs' - 'wai/wai-rs/tests/neural_cost_limit.rs' - 'wai/wai-rs/src/int_synth.rs' + # The integer operator set the integer decoders load their sets through. + - 'wai/wai-rs/src/int_ops.rs' + - 'wai/wai-ops/**' - 'wai/wai-rs/src/ort_session.rs' - 'wai/neural-conformance/vectors/**' - 'wai/pin-conformance/**' @@ -105,6 +108,9 @@ on: - 'wai/wai-rs/tests/ffi_neural_pin.rs' - 'wai/wai-rs/tests/neural_cost_limit.rs' - 'wai/wai-rs/src/int_synth.rs' + # The integer operator set the integer decoders load their sets through. + - 'wai/wai-rs/src/int_ops.rs' + - 'wai/wai-ops/**' - 'wai/wai-rs/src/ort_session.rs' - 'wai/neural-conformance/vectors/**' - 'wai/pin-conformance/**' diff --git a/.github/workflows/wai-web-wasm.yml b/.github/workflows/wai-web-wasm.yml index 7df9361ad..b7ad7982b 100644 --- a/.github/workflows/wai-web-wasm.yml +++ b/.github/workflows/wai-web-wasm.yml @@ -59,6 +59,8 @@ on: paths: - 'wai/wai-web/**' - 'wai/wai-rs/**' + # wai-rs's integer decoders depend on the integer operator set. + - 'wai/wai-ops/**' - 'wai/wai-quantum/**' # quantum::tests and quantum_toolchain::tests read the quantum corpora. - 'wai/quantum-conformance/**' @@ -74,6 +76,8 @@ on: paths: - 'wai/wai-web/**' - 'wai/wai-rs/**' + # wai-rs's integer decoders depend on the integer operator set. + - 'wai/wai-ops/**' - 'wai/wai-quantum/**' # quantum::tests and quantum_toolchain::tests read the quantum corpora. - 'wai/quantum-conformance/**' diff --git a/wai/wai-pack/Cargo.lock b/wai/wai-pack/Cargo.lock index 7e2ff5f8e..89a3f8fbc 100644 --- a/wai/wai-pack/Cargo.lock +++ b/wai/wai-pack/Cargo.lock @@ -680,9 +680,14 @@ dependencies = [ "serde", "serde_json", "sha2", + "wai-ops", "wai-quantum", ] +[[package]] +name = "wai-ops" +version = "0.1.0" + [[package]] name = "wai-pack" version = "0.1.0" diff --git a/wai/wai-rs/Cargo.lock b/wai/wai-rs/Cargo.lock index 721e47a05..6c9d3e3be 100644 --- a/wai/wai-rs/Cargo.lock +++ b/wai/wai-rs/Cargo.lock @@ -4451,12 +4451,17 @@ dependencies = [ "serde", "serde_json", "sha2 0.10.9", + "wai-ops", "wai-quantum", "xz2", "zstd", "zstd-safe", ] +[[package]] +name = "wai-ops" +version = "0.1.0" + [[package]] name = "wai-quantum" version = "0.4.3" diff --git a/wai/wai-rs/Cargo.toml b/wai/wai-rs/Cargo.toml index 6d7a75d85..2f288e45a 100644 --- a/wai/wai-rs/Cargo.toml +++ b/wai/wai-rs/Cargo.toml @@ -102,6 +102,13 @@ curve25519-dalek = { version = "4", optional = true } # without the media/codec chain. `wai` re-exports it unchanged. wai-quantum = { version = "0.4.1", path = "../wai-quantum", optional = true, default-features = false } +# ---- the integer operator set (behind `neural_int`) ---- +# `wai-ops/1` (extensions/integer-operators.md): an integer decoder that decodes +# through it lowers its parameter set to WHG1 graphs and loads them under its +# capability's profile, the bounds certificate included (src/int_ops.rs). no_std, no +# dependencies of its own. +wai-ops = { path = "../wai-ops", optional = true } + [features] default = ["codecs"] # The zeroth-condition codec wrappers (image/audio/video/text) + the C @@ -127,7 +134,7 @@ ffi = [] # construction — so it sits behind its own light feature, checkable in a CI # matrix with no ONNX Runtime present. `neural` pulls it in. blake3 is for the # conformance corpus's content hash (the same hash the other classes freeze). -neural_int = ["dep:blake3"] +neural_int = ["dep:blake3", "dep:wai-ops"] # Deterministic integer generative synthesis (`wai.generate.*`): byte-exact # generation pinned to (model, prompt, seed, steps) — the create-side of the # integer contract. Pure i64, no ONNX/float (serde_json is already a base dep), @@ -734,6 +741,11 @@ name = "int_kernels_geometry" path = "tests/int_kernels_geometry.rs" required-features = ["neural_int"] +[[test]] +name = "int_synth_ops" +path = "tests/int_synth_ops.rs" +required-features = ["neural_int"] + [[test]] name = "int_mlicpp" path = "tests/int_mlicpp.rs" diff --git a/wai/wai-rs/src/int_ops.rs b/wai/wai-rs/src/int_ops.rs new file mode 100644 index 000000000..af01b50fd --- /dev/null +++ b/wai/wai-rs/src/int_ops.rs @@ -0,0 +1,21 @@ +//! The integer decoders' networks through `wai-ops/1`, the integer operator set +//! (`extensions/integer-operators.md`). +//! +//! A decoder lowers its parameter set to `WHG1` graphs (§13) and loads each graph as a +//! sink does, from its canonical bytes, under its capability's profile: load steps 1–8 +//! of §10.5, the last of which is the bounds pass, whose certificate must meet that +//! profile (§8.6). A set whose certificate does not meet it is refused. + +use wai_ops::graph::{Graph, Loaded}; +use wai_ops::{Profile, Refusal}; + +/// The profile of every capability that decodes through the operator set +/// (`extensions/integer-operators.md` §11): `acc64`, which every lowering of §13 +/// declares. A set may meet `acc32` as well; its certificate shows that. +pub const PROFILE: Profile = Profile::Acc64; + +/// Load a lowered graph as a sink does: from its canonical bytes, under [`PROFILE`]. +/// A refusal is `model_mismatch`, or `too_large` for a size (§10.5). +pub fn load(g: &Graph) -> Result { + wai_ops::graph::load(&g.to_bytes(), PROFILE) +} diff --git a/wai/wai-rs/src/lib.rs b/wai/wai-rs/src/lib.rs index ccef4b0f9..db4614d8d 100644 --- a/wai/wai-rs/src/lib.rs +++ b/wai/wai-rs/src/lib.rs @@ -57,6 +57,11 @@ pub mod int_synth; // on wasm. #[cfg(feature = "neural_int")] pub mod int_kernels; +// The integer decoders' networks through the integer operator set `wai-ops/1` +// (extensions/integer-operators.md): each parameter set lowered to WHG1 graphs and +// loaded under its capability's profile, the bounds certificate included. +#[cfg(feature = "neural_int")] +pub mod int_ops; // Integer Gaussian-conditional entropy — the reusable scale→bucket primitive // that makes a conditional codec's entropy decode hardware-deterministic (the diff --git a/wai/wai-rs/tests/int_synth_ops.rs b/wai/wai-rs/tests/int_synth_ops.rs new file mode 100644 index 000000000..af166581b --- /dev/null +++ b/wai/wai-rs/tests/int_synth_ops.rs @@ -0,0 +1,125 @@ +//! `wai.neural.int_synth` through the integer operator set: its lowering `int_synth/1` +//! (`extensions/integer-operators.md` §13.1) and the certificate of every committed +//! set (§8.5, §14.3), as this crate loads them (`int_ops`). +//! +//! The lowered graphs and certificates are committed under `ops-conformance/certificates/`, +//! where `wai-verify` and the independent checker (`tools/wai_ops_check.py`) each +//! reproduce them byte for byte (`wai-ops.yml`). Here the lowering this crate runs must +//! write those same bytes, and the load a sink makes must give the verdict the +//! certificate records. + +use std::path::PathBuf; + +use serde_json::Value; +use wai_ops::{Code, Profile, Site}; + +const CAP: &str = "wai.neural.int_synth"; + +fn wai() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..") +} + +fn read(rel: &str) -> Vec { + std::fs::read(wai().join(rel)).unwrap_or_else(|e| panic!("{rel}: {e}")) +} + +fn json(rel: &str) -> Value { + serde_json::from_slice(&read(rel)).unwrap_or_else(|e| panic!("{rel}: {e}")) +} + +fn sha256(b: &[u8]) -> String { + wai_ops::sha256::sha256(b).iter().map(|x| format!("{x:02x}")).collect() +} + +/// The `int_synth` sets an index of `ops-conformance/certificates/` lists: +/// `(name, set file, set digest, lowered graph file)`, each relative to `wai/`. +fn indexed(index: &str) -> Vec<(String, String, String, String)> { + json(index)["sets"] + .as_array() + .unwrap() + .iter() + .filter(|s| s["capability"] == CAP) + .map(|s| { + assert_eq!(s["lowering"], "int_synth/1"); + let roles = s["roles"].as_array().unwrap(); + assert_eq!(roles.len(), 1, "int_synth/1 lowers to one graph, synth"); + ( + s["name"].as_str().unwrap().to_string(), + s["members"]["int_synth.json"].as_str().unwrap().to_string(), + s["set_sha256"].as_str().unwrap().to_string(), + roles[0].as_str().unwrap().to_string(), + ) + }) + .collect() +} + +/// The certificate of the set with digest `d`. +fn certificate(d: &str) -> Value { + json(&format!("ops-conformance/certificates/{CAP}/{d}.json")) +} + +/// Gate 1 of the migration: every committed `int_synth` set lowers to its committed +/// graph, the certificate beside it is that graph's, and loading the graph under the +/// capability's profile gives the certificate's verdict. The neural-conformance corpus +/// meets `acc64`; facade, gradient and bloom meet `acc32` too, and photo does not. +#[test] +fn every_committed_set_lowers_to_its_committed_graph_and_verdict() { + let sets = indexed("ops-conformance/certificates/index.json"); + let mut names: Vec<&str> = sets.iter().map(|s| s.0.as_str()).collect(); + names.sort(); + assert_eq!(names, ["bloom", "facade", "gradient", "photo", "synth"], "the committed int_synth sets"); + for (name, set, digest, role) in &sets { + let bytes = read(set); + assert_eq!(&sha256(&bytes), digest, "{name}: the set's digest"); + let lowered = wai_ops::lower::int_synth(&bytes).unwrap_or_else(|r| panic!("{name}: does not lower: {r:?}")); + let roles: Vec<&str> = lowered.roles.iter().map(|(r, _)| *r).collect(); + assert_eq!(roles, ["synth"], "{name}"); + let g = lowered.get("synth").unwrap(); + let graph = g.to_bytes(); + assert!(graph == read(role), "{name}: the lowering differs from the committed {role}"); + let cert = certificate(digest); + assert_eq!(cert["set_sha256"].as_str(), Some(digest.as_str()), "{name}"); + assert_eq!(cert["graphs"][0]["name"], "synth", "{name}"); + assert_eq!(cert["graphs"][0]["graph_sha256"].as_str(), Some(sha256(&graph).as_str()), "{name}: the certificate is the graph's"); + let loaded = wai::int_ops::load(g).unwrap_or_else(|r| panic!("{name}: refused under acc64: {r:?}")); + assert_eq!(cert["verdict"]["acc64"], true, "{name}"); + assert_eq!(Value::Bool(loaded.meets(Profile::Acc32)), cert["verdict"]["acc32"], "{name}: the verdict on acc32"); + let acc32 = matches!(name.as_str(), "facade" | "gradient" | "bloom" | "synth"); + assert_eq!(loaded.meets(Profile::Acc32), acc32, "{name}: acc32 as integer-operators' design predicted"); + } + // The browser demo serves byte copies of the corpus's sets. + for name in ["bloom", "facade", "gradient", "photo"] { + let copy = sha256(&read(&format!("wai-web/demo/int_synth/{name}/int_synth.json"))); + assert!(sets.iter().any(|s| s.2 == copy), "wai-web/demo/int_synth/{name}: not a committed set"); + } +} + +/// The two falsification sets of §14.3. `overflow_acc64`'s accumulator passes i64 by +/// one unit, so the capability's load refuses it at load step 8; `claim_acc32`'s +/// passes i32 by one unit, so it loads under `acc64` and does not meet `acc32`. +#[test] +fn the_falsification_sets_fail_the_profile_they_claim() { + let sets = indexed("ops-conformance/certificates/falsification.json"); + let mut names: Vec<&str> = sets.iter().map(|s| s.0.as_str()).collect(); + names.sort(); + assert_eq!(names, ["claim_acc32", "overflow_acc64"]); + for (name, set, digest, role) in &sets { + let bytes = read(set); + assert_eq!(&sha256(&bytes), digest, "{name}"); + let lowered = wai_ops::lower::int_synth(&bytes).unwrap(); + let g = lowered.get("synth").unwrap(); + assert!(g.to_bytes() == read(role), "{name}: the lowering differs from the committed {role}"); + let cert = certificate(digest); + match name.as_str() { + "overflow_acc64" => { + assert_eq!(cert["verdict"]["acc64"], false); + let r = wai::int_ops::load(g).expect_err("the overflow set is refused"); + assert_eq!((r.code, r.at), (Code::ModelMismatch, Site::Load(8))); + } + _ => { + assert_eq!((cert["verdict"]["acc64"].as_bool(), cert["verdict"]["acc32"].as_bool()), (Some(true), Some(false))); + assert!(!wai::int_ops::load(g).expect("acc64").meets(Profile::Acc32)); + } + } + } +} diff --git a/wai/wai-web/Cargo.lock b/wai/wai-web/Cargo.lock index 9862ac0f3..4e0f163a5 100644 --- a/wai/wai-web/Cargo.lock +++ b/wai/wai-web/Cargo.lock @@ -1221,9 +1221,14 @@ dependencies = [ "serde", "serde_json", "sha2", + "wai-ops", "wai-quantum", ] +[[package]] +name = "wai-ops" +version = "0.1.0" + [[package]] name = "wai-quantum" version = "0.4.3" -- 2.54.0 (Apple Git-157) From 2ae2a439028fd173923672d0986d18f20f493639 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Tue, 13 Oct 2026 02:42:16 -0400 Subject: [PATCH 04/10] D2c M1 gate 1: the test asserts each set's verdict before its committed bytes So that a set changed past its acc32 bound reads as the verdict that flipped, not as a digest that moved. Co-Authored-By: Claude Opus 5.5 --- wai/wai-rs/tests/int_synth_ops.rs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/wai/wai-rs/tests/int_synth_ops.rs b/wai/wai-rs/tests/int_synth_ops.rs index af166581b..875510dff 100644 --- a/wai/wai-rs/tests/int_synth_ops.rs +++ b/wai/wai-rs/tests/int_synth_ops.rs @@ -70,22 +70,24 @@ fn every_committed_set_lowers_to_its_committed_graph_and_verdict() { assert_eq!(names, ["bloom", "facade", "gradient", "photo", "synth"], "the committed int_synth sets"); for (name, set, digest, role) in &sets { let bytes = read(set); - assert_eq!(&sha256(&bytes), digest, "{name}: the set's digest"); let lowered = wai_ops::lower::int_synth(&bytes).unwrap_or_else(|r| panic!("{name}: does not lower: {r:?}")); let roles: Vec<&str> = lowered.roles.iter().map(|(r, _)| *r).collect(); assert_eq!(roles, ["synth"], "{name}"); let g = lowered.get("synth").unwrap(); + // The verdict first: the load a sink makes, then the profile each set meets. + let loaded = wai::int_ops::load(g).unwrap_or_else(|r| panic!("{name}: refused under acc64: {r:?}")); + let acc32 = matches!(name.as_str(), "facade" | "gradient" | "bloom" | "synth"); + assert_eq!(loaded.meets(Profile::Acc32), acc32, "{name}: whether it meets acc32"); + // Then the committed bytes: the set, its lowered graph and its certificate. + assert_eq!(&sha256(&bytes), digest, "{name}: the set's digest"); let graph = g.to_bytes(); assert!(graph == read(role), "{name}: the lowering differs from the committed {role}"); let cert = certificate(digest); assert_eq!(cert["set_sha256"].as_str(), Some(digest.as_str()), "{name}"); assert_eq!(cert["graphs"][0]["name"], "synth", "{name}"); assert_eq!(cert["graphs"][0]["graph_sha256"].as_str(), Some(sha256(&graph).as_str()), "{name}: the certificate is the graph's"); - let loaded = wai::int_ops::load(g).unwrap_or_else(|r| panic!("{name}: refused under acc64: {r:?}")); assert_eq!(cert["verdict"]["acc64"], true, "{name}"); - assert_eq!(Value::Bool(loaded.meets(Profile::Acc32)), cert["verdict"]["acc32"], "{name}: the verdict on acc32"); - let acc32 = matches!(name.as_str(), "facade" | "gradient" | "bloom" | "synth"); - assert_eq!(loaded.meets(Profile::Acc32), acc32, "{name}: acc32 as integer-operators' design predicted"); + assert_eq!(Value::Bool(loaded.meets(Profile::Acc32)), cert["verdict"]["acc32"], "{name}: the certificate's verdict on acc32"); } // The browser demo serves byte copies of the corpus's sets. for name in ["bloom", "facade", "gradient", "photo"] { -- 2.54.0 (Apple Git-157) From 25de4ab356adc7dca62cc96a4ffcc086b4de135c Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Tue, 13 Oct 2026 02:50:33 -0400 Subject: [PATCH 05/10] D2c M1 gate 2: int_synth's decode through its lowering equals every golden MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit int_synth gains its decode through the integer operator set, int_synth::ops: the payload's header checked (integer-payloads §13, the reserved byte, C >= 1 and the 2^26 cap among them), the set lowered by int_synth/1, the graph loaded under acc64 with its bounds certificate and run by the operator set's interpreter (int_ops::run1), the [3, H, W] output laid out as RGB8. The direct decode moves, unchanged, into int_synth::legacy, and decode_from_spec still calls it: the switch is gate 6. Golden equality, on every committed golden: - tests/int_synth_ops.rs: each neural-conformance vector decodes through the lowering to the direct decode's bytes, its SHA-256 sidecar, its BLAKE3 rgb_hash and sizes, and photo's Python integer-forward reference; the browser demo's payloads and digests are byte copies of the corpus's. Both profiles, on every wai-conformance leg. - byte-exact-conformance: the cross-architecture int_synth golden (84e76529...) through the lowering as well, on every leg of the byte-equality matrix. The crate includes int_ops.rs and depends on wai-ops; the matrix fires on both. - wai-ops' own goldens already decode the corpus through the interpreter on every native leg and under WASI (wai-ops.yml). Co-Authored-By: Claude Opus 5.5 --- .github/workflows/wai-byte-equality.yml | 8 ++ wai/byte-exact-conformance/Cargo.lock | 5 + wai/byte-exact-conformance/Cargo.toml | 3 + wai/byte-exact-conformance/src/golden.rs | 48 +++++-- wai/byte-exact-conformance/src/lib.rs | 6 + wai/wai-rs/src/int_ops.rs | 11 +- wai/wai-rs/src/int_synth.rs | 169 +++++++++++++++++------ wai/wai-rs/tests/int_synth_ops.rs | 36 +++++ 8 files changed, 227 insertions(+), 59 deletions(-) diff --git a/.github/workflows/wai-byte-equality.yml b/.github/workflows/wai-byte-equality.yml index 60d5c3f92..7b6aea5f0 100644 --- a/.github/workflows/wai-byte-equality.yml +++ b/.github/workflows/wai-byte-equality.yml @@ -69,6 +69,10 @@ on: # The fast kernels int_synth, int_transform and int_hyper_synth dispatch to # by default: the goldens here decode through them. - 'wai/wai-rs/src/int_kernels.rs' + # The integer operator set and the bridge to it: the goldens decode int_synth + # through its lowering too. + - 'wai/wai-rs/src/int_ops.rs' + - 'wai/wai-ops/**' - 'wai/wai-rs/tests/vectors/int_video/**' - 'wai/wai-rs/tests/vectors/int_hyper/**' - 'wai/wai-rs/tests/vectors/int_mlicpp/**' @@ -98,6 +102,10 @@ on: # The fast kernels int_synth, int_transform and int_hyper_synth dispatch to # by default: the goldens here decode through them. - 'wai/wai-rs/src/int_kernels.rs' + # The integer operator set and the bridge to it: the goldens decode int_synth + # through its lowering too. + - 'wai/wai-rs/src/int_ops.rs' + - 'wai/wai-ops/**' - 'wai/wai-rs/tests/vectors/int_video/**' - 'wai/wai-rs/tests/vectors/int_hyper/**' - 'wai/wai-rs/tests/vectors/int_mlicpp/**' diff --git a/wai/byte-exact-conformance/Cargo.lock b/wai/byte-exact-conformance/Cargo.lock index 7226ce652..6897f57c4 100644 --- a/wai/byte-exact-conformance/Cargo.lock +++ b/wai/byte-exact-conformance/Cargo.lock @@ -182,8 +182,13 @@ dependencies = [ "serde", "serde_json", "sha2", + "wai-ops", ] +[[package]] +name = "wai-ops" +version = "0.1.0" + [[package]] name = "zmij" version = "1.0.23" diff --git a/wai/byte-exact-conformance/Cargo.toml b/wai/byte-exact-conformance/Cargo.toml index a6829ce26..fdda025e1 100644 --- a/wai/byte-exact-conformance/Cargo.toml +++ b/wai/byte-exact-conformance/Cargo.toml @@ -22,3 +22,6 @@ unexpected_cfgs = { level = "warn", check-cfg = ["cfg(kani)"] } serde = { version = "1", features = ["derive"] } serde_json = "1" sha2 = "0.10" +# The integer operator set the included decoders run their networks through +# (`wai-rs/src/int_ops.rs`, included at the crate root). no_std, no dependencies. +wai-ops = { path = "../wai-ops" } diff --git a/wai/byte-exact-conformance/src/golden.rs b/wai/byte-exact-conformance/src/golden.rs index 770dfb978..037e09dfa 100644 --- a/wai/byte-exact-conformance/src/golden.rs +++ b/wai/byte-exact-conformance/src/golden.rs @@ -84,16 +84,14 @@ impl Lcg { } } -/// Integer fixed-point synthesis end-to-end: a constructed 2-layer integer -/// model + an 8×8 int8 latent → 32×32 RGB whose SHA-256 must equal the golden -/// constant on EVERY architecture (the integer-exact `replicate` guarantee). -#[test] -fn int_synth_output_hash_is_golden() { - // Produced by the reference run (macOS arm64); every other platform must - // reproduce it bit-for-bit. - const GOLDEN_SHA256: &str = - "84e76529e360a98c3867d13af0a156fe31e8e8773a3f688b2ca4f63a91860cad"; +/// The int_synth golden's SHA-256: produced by the reference run (macOS arm64); every +/// other platform must reproduce it bit-for-bit. +const INT_SYNTH_GOLDEN_SHA256: &str = + "84e76529e360a98c3867d13af0a156fe31e8e8773a3f688b2ca4f63a91860cad"; +/// The int_synth golden's input: a constructed 2-layer integer model (`int_synth.json`) +/// and a payload of 6×8×8 int8 latents for a 32×32 output. +fn int_synth_golden_case() -> (serde_json::Value, Vec) { let (c_in, s) = (6usize, 8usize); let mut rng = Lcg(0x5EED_CAFE); let layer = |rng: &mut Lcg, in_ch: usize, out_ch: usize, shift: u32, relu: bool| { @@ -109,10 +107,6 @@ fn int_synth_output_hash_is_golden() { layer(&mut rng, 8, 3, 3, false), ]}); - let dir = std::env::temp_dir().join("wai_byte_exact_golden"); - std::fs::create_dir_all(&dir).unwrap(); - std::fs::write(dir.join("int_synth.json"), serde_json::to_vec(&spec).unwrap()).unwrap(); - let mut payload = Vec::new(); payload.extend_from_slice(&32u32.to_le_bytes()); // H payload.extend_from_slice(&32u32.to_le_bytes()); // W @@ -122,13 +116,39 @@ fn int_synth_output_hash_is_golden() { for _ in 0..c_in * s * s { payload.push((rng.next_i32(-128, 127) as i8) as u8); } + (spec, payload) +} + +/// Integer fixed-point synthesis end-to-end: a constructed 2-layer integer +/// model + an 8×8 int8 latent → 32×32 RGB whose SHA-256 must equal the golden +/// constant on EVERY architecture (the integer-exact `replicate` guarantee). +#[test] +fn int_synth_output_hash_is_golden() { + let (spec, payload) = int_synth_golden_case(); + let dir = std::env::temp_dir().join("wai_byte_exact_golden"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("int_synth.json"), serde_json::to_vec(&spec).unwrap()).unwrap(); let img = int_synth::decode(&payload, &dir.join("model")).expect("decode"); assert_eq!((img.width, img.height), (32, 32)); let hash = format!("{:x}", Sha256::digest(&img.rgb)); let _ = std::fs::remove_dir_all(&dir); assert_eq!( - hash, GOLDEN_SHA256, + hash, INT_SYNTH_GOLDEN_SHA256, "int_synth output diverged on this platform — the integer-exact contract is broken" ); } + +/// The same golden through the integer operator set: the set's lowering `int_synth/1` +/// (integer-operators §13.1), loaded under `acc64` with its bounds certificate and run +/// by the operator set's interpreter, must give the same bytes on every architecture. +#[test] +fn int_synth_through_its_lowering_is_golden() { + let (spec, payload) = int_synth_golden_case(); + let img = int_synth::ops::decode_from_spec(&payload, &serde_json::to_vec(&spec).unwrap()).expect("decode"); + assert_eq!((img.width, img.height), (32, 32)); + assert_eq!( + format!("{:x}", Sha256::digest(&img.rgb)), INT_SYNTH_GOLDEN_SHA256, + "int_synth through its lowering diverged on this platform" + ); +} diff --git a/wai/byte-exact-conformance/src/lib.rs b/wai/byte-exact-conformance/src/lib.rs index 3ff907d42..121040768 100644 --- a/wai/byte-exact-conformance/src/lib.rs +++ b/wai/byte-exact-conformance/src/lib.rs @@ -26,6 +26,12 @@ pub mod int_entropy; #[path = "../../wai-rs/src/int_kernels.rs"] pub mod int_kernels; +// The bridge the integer decoders load their lowered graphs through and run them by +// (`wai-ops/1`, the integer operator set): int_synth's decode through its lowering +// (`int_synth::ops`) calls it, so the matrix checks that decode too. +#[path = "../../wai-rs/src/int_ops.rs"] +pub mod int_ops; + // The merged byte-exact JPEG-AI decoder's entropy stage (crate-root, self-contained — // no shims needed). Included so the cross-arch byte-equality matrix covers the decoder // that `wai.image.jpegai` actually reconstructs with, not only the older `neural/` port. diff --git a/wai/wai-rs/src/int_ops.rs b/wai/wai-rs/src/int_ops.rs index af01b50fd..c37673c41 100644 --- a/wai/wai-rs/src/int_ops.rs +++ b/wai/wai-rs/src/int_ops.rs @@ -4,9 +4,12 @@ //! A decoder lowers its parameter set to `WHG1` graphs (§13) and loads each graph as a //! sink does, from its canonical bytes, under its capability's profile: load steps 1–8 //! of §10.5, the last of which is the bounds pass, whose certificate must meet that -//! profile (§8.6). A set whose certificate does not meet it is refused. +//! profile (§8.6). A set whose certificate does not meet it is refused. It then runs +//! each decode through the operator set's interpreter, which owns the run-time plan, +//! every range check and every refusal (§4). use wai_ops::graph::{Graph, Loaded}; +use wai_ops::interp::{Sequence, Tensor}; use wai_ops::{Profile, Refusal}; /// The profile of every capability that decodes through the operator set @@ -19,3 +22,9 @@ pub const PROFILE: Profile = Profile::Acc64; pub fn load(g: &Graph) -> Result { wai_ops::graph::load(&g.to_bytes(), PROFILE) } + +/// One decode of a loaded graph with one data input and no state: its first output. +pub fn run1(g: &Loaded, x: Tensor, scalars: &[i64]) -> Result { + let mut out = wai_ops::run(g, &[x], scalars, &mut Sequence::new())?; + Ok(out.swap_remove(0)) +} diff --git a/wai/wai-rs/src/int_synth.rs b/wai/wai-rs/src/int_synth.rs index 409c4f94c..884a1b5ab 100644 --- a/wai/wai-rs/src/int_synth.rs +++ b/wai/wai-rs/src/int_synth.rs @@ -25,7 +25,7 @@ //! thing that makes cross-hardware bit-exactness real — lives here and is exact //! whatever the weights. //! -//! Wire format (little-endian): +//! Wire format (little-endian), `extensions/integer-payloads.md` §13: //! u32 H, u32 W, u16 C, u8 S_log2, u8 _reserved (12 B) //! C*S*S bytes int8 latents, channel-major //! @@ -34,6 +34,12 @@ //! weight:[i32; in*out*k*k] (layout ic,oc,ky,kx), //! bias:[i32; out_ch], shift:u32, relu:bool }, … ] } //! The final layer MUST output 3 channels at H×W; values are clamped to [0,255]. +//! +//! Two decodes of the same payload and set: [`legacy`], the direct path (each layer's +//! transposed convolution, bias, shift and ReLU computed here), and [`ops`], the graph +//! `synth` of the set's lowering `int_synth/1` (`extensions/integer-operators.md` +//! §13.1), loaded under `acc64` with its bounds certificate and run through the integer +//! operator set ([`crate::int_ops`]). [`decode_from_spec`] is [`legacy`]'s. use std::path::Path; @@ -260,57 +266,132 @@ pub fn decode_bytes(payload: &[u8], spec_json: &[u8]) -> Result Result { - if payload.len() < HEADER { - return Err(IntSynthError::InvalidPayload(format!( - "int_synth payload {} B < {HEADER} B header", payload.len()))); - } - let h = u32::from_le_bytes(payload[0..4].try_into().unwrap()) as usize; - let w = u32::from_le_bytes(payload[4..8].try_into().unwrap()) as usize; - let c = u16::from_le_bytes(payload[8..10].try_into().unwrap()) as usize; - // The side is 2^payload[10]. A shift of the word's width or more panics with - // overflow checks on and masks without them, so the same bytes would decode two - // ways; and C·S·S must be formed without overflow. Neither can match a payload's - // length when it overflows, so both are refused here as a length mismatch. - let n = 1usize.checked_shl(payload[10] as u32) - .and_then(|s| c.checked_mul(s)?.checked_mul(s).map(|n| (s, n))); - let Some((s, _)) = n.filter(|&(_, n)| HEADER.checked_add(n) == Some(payload.len())) else { - return Err(IntSynthError::InvalidPayload(format!( - "int_synth payload {} B is not {HEADER} + C·S·S with C = {c}, S = 2^{}", payload.len(), payload[10]))); - }; + legacy::decode_from_spec(payload, spec_json) +} - let spec: SynthSpec = serde_json::from_slice(spec_json) - .map_err(|e| IntSynthError::InvalidPayload(format!("int_synth.json parse: {e}")))?; +/// The direct decode: the payload and set read here, each layer's transposed +/// convolution, bias, shift and ReLU computed by [`conv_transpose2d`]. +pub mod legacy { + use super::*; - // int8 latents → i64. - let mut cur: Vec = payload[HEADER..].iter().map(|&b| b as i8 as i64).collect(); - let (mut cin, mut ch, mut cw) = (c, s, s); - for (i, l) in spec.layers.iter().enumerate() { - if l.in_ch != cin { + /// The direct decode of [`super::decode_from_spec`]'s arguments. + pub fn decode_from_spec(payload: &[u8], spec_json: &[u8]) -> Result { + if payload.len() < HEADER { return Err(IntSynthError::InvalidPayload(format!( - "int_synth layer {i} in_ch {} != current {cin}", l.in_ch))); + "int_synth payload {} B < {HEADER} B header", payload.len()))); } - let (out, ho, wo) = conv_transpose2d(&cur, cin, ch, cw, l)?; - cur = out; - cin = l.out_ch; - ch = ho; - cw = wo; - } - if cin != 3 || ch != h || cw != w { - return Err(IntSynthError::InvalidPayload(format!( - "int_synth output [{cin},{ch},{cw}] != [3,{h},{w}]"))); - } + let h = u32::from_le_bytes(payload[0..4].try_into().unwrap()) as usize; + let w = u32::from_le_bytes(payload[4..8].try_into().unwrap()) as usize; + let c = u16::from_le_bytes(payload[8..10].try_into().unwrap()) as usize; + // The side is 2^payload[10]. A shift of the word's width or more panics with + // overflow checks on and masks without them, so the same bytes would decode two + // ways; and C·S·S must be formed without overflow. Neither can match a payload's + // length when it overflows, so both are refused here as a length mismatch. + let n = 1usize.checked_shl(payload[10] as u32) + .and_then(|s| c.checked_mul(s)?.checked_mul(s).map(|n| (s, n))); + let Some((s, _)) = n.filter(|&(_, n)| HEADER.checked_add(n) == Some(payload.len())) else { + return Err(IntSynthError::InvalidPayload(format!( + "int_synth payload {} B is not {HEADER} + C·S·S with C = {c}, S = 2^{}", payload.len(), payload[10]))); + }; - let mut rgb = vec![0u8; h * w * 3]; - let hw = h * w; - for y in 0..h { - for x in 0..w { - let pi = y * w + x; - for ch_i in 0..3 { - rgb[pi * 3 + ch_i] = cur[ch_i * hw + pi].clamp(0, 255) as u8; + let spec: SynthSpec = serde_json::from_slice(spec_json) + .map_err(|e| IntSynthError::InvalidPayload(format!("int_synth.json parse: {e}")))?; + + // int8 latents → i64. + let mut cur: Vec = payload[HEADER..].iter().map(|&b| b as i8 as i64).collect(); + let (mut cin, mut ch, mut cw) = (c, s, s); + for (i, l) in spec.layers.iter().enumerate() { + if l.in_ch != cin { + return Err(IntSynthError::InvalidPayload(format!( + "int_synth layer {i} in_ch {} != current {cin}", l.in_ch))); + } + let (out, ho, wo) = conv_transpose2d(&cur, cin, ch, cw, l)?; + cur = out; + cin = l.out_ch; + ch = ho; + cw = wo; + } + if cin != 3 || ch != h || cw != w { + return Err(IntSynthError::InvalidPayload(format!( + "int_synth output [{cin},{ch},{cw}] != [3,{h},{w}]"))); + } + + let mut rgb = vec![0u8; h * w * 3]; + let hw = h * w; + for y in 0..h { + for x in 0..w { + let pi = y * w + x; + for ch_i in 0..3 { + rgb[pi * 3 + ch_i] = cur[ch_i * hw + pi].clamp(0, 255) as u8; + } } } + Ok(IntImage { rgb, width: w as u32, height: h as u32 }) + } +} + +/// The decode through the integer operator set: the graph `synth` of the set's lowering +/// `int_synth/1` (`extensions/integer-operators.md` §13.1), loaded under `acc64`, the +/// bounds certificate included, and run on the latents ([`crate::int_ops`]). +pub mod ops { + use super::{IntImage, IntSynthError, HEADER}; + + /// Decode a payload against its `int_synth.json` through the integer operator set. + /// + /// The payload is checked first (`extensions/integer-payloads.md` §13): its header, a + /// reserved byte of 0, `C ≥ 1`, `C·S·S ≤ 2^26` and its exact length. Then the set is + /// lowered (integer-operators §13.1, whose checks include `k ≥ 1`, `stride ≥ 1` and a + /// shift of at most 127) and loaded under `acc64`: load steps 1–8, the last being the + /// bounds certificate. Then the graph runs on the latents, through the operator set's + /// run-time plan with its caps, and its output must be `[3, H, W]`. Every refusal is + /// [`IntSynthError::InvalidPayload`] (`payload_malformed`). + pub fn decode_from_spec(payload: &[u8], spec_json: &[u8]) -> Result { + let (h, w, c, s) = header(payload)?; + let refused = |what: &str, r: wai_ops::Refusal| IntSynthError::InvalidPayload(format!("int_synth {what}: {} at {:?}", r.code.as_str(), r.at)); + let set = wai_ops::lower::int_synth(spec_json).map_err(|r| refused("int_synth.json does not lower", r))?; + let g = crate::int_ops::load(set.get("synth").expect("int_synth/1 lowers to the graph synth")).map_err(|r| refused("set does not load", r))?; + let latents: Vec = payload[HEADER..].iter().map(|&b| b as i8 as i64).collect(); + let out = crate::int_ops::run1(&g, wai_ops::Tensor::new(c, s, s, latents), &[]).map_err(|r| refused("decode", r))?; + if (out.c, out.h as usize, out.w as usize) != (3, h, w) { + return Err(IntSynthError::InvalidPayload(format!( + "int_synth output [{},{},{}] != [3,{h},{w}]", out.c, out.h, out.w))); + } + // `clamp(0, 255)` ends the graph, so every value is a byte. + let hw = h * w; + let mut rgb = vec![0u8; hw * 3]; + for (k, &v) in out.data.iter().enumerate() { + rgb[(k % hw) * 3 + k / hw] = v as u8; + } + Ok(IntImage { rgb, width: w as u32, height: h as u32 }) + } + + /// The payload's header (`extensions/integer-payloads.md` §13): `(H, W, C, S)`, after + /// its checks. The side is `2^S_log2`; `C·S·S` is formed without overflow and is at + /// most `2^26`, and the payload is exactly `12 + C·S·S` bytes. + fn header(payload: &[u8]) -> Result<(usize, usize, u32, u32), IntSynthError> { + if payload.len() < HEADER { + return Err(IntSynthError::InvalidPayload(format!( + "int_synth payload {} B < {HEADER} B header", payload.len()))); + } + let h = u32::from_le_bytes(payload[0..4].try_into().unwrap()) as usize; + let w = u32::from_le_bytes(payload[4..8].try_into().unwrap()) as usize; + let c = u16::from_le_bytes(payload[8..10].try_into().unwrap()) as usize; + if payload[11] != 0 { + return Err(IntSynthError::InvalidPayload("int_synth payload's reserved byte is not 0".into())); + } + if c == 0 { + return Err(IntSynthError::InvalidPayload("int_synth payload has no latent channels".into())); + } + const CAP: usize = 1 << 26; + let n = 1usize.checked_shl(payload[10] as u32) + .and_then(|s| c.checked_mul(s)?.checked_mul(s).map(|n| (s, n))) + .filter(|&(_, n)| n <= CAP); + let Some((s, _)) = n.filter(|&(_, n)| HEADER.checked_add(n) == Some(payload.len())) else { + return Err(IntSynthError::InvalidPayload(format!( + "int_synth payload {} B is not {HEADER} + C·S·S with C = {c}, S = 2^{}, C·S·S ≤ 2^26", payload.len(), payload[10]))); + }; + Ok((h, w, c as u32, s as u32)) } - Ok(IntImage { rgb, width: w as u32, height: h as u32 }) } #[cfg(test)] diff --git a/wai/wai-rs/tests/int_synth_ops.rs b/wai/wai-rs/tests/int_synth_ops.rs index 875510dff..e11b0c77b 100644 --- a/wai/wai-rs/tests/int_synth_ops.rs +++ b/wai/wai-rs/tests/int_synth_ops.rs @@ -7,6 +7,10 @@ //! reproduce them byte for byte (`wai-ops.yml`). Here the lowering this crate runs must //! write those same bytes, and the load a sink makes must give the verdict the //! certificate records. +//! +//! The decode through the lowering (`int_synth::ops`) must give, on every committed +//! vector, the bytes of the direct decode (`int_synth::legacy`) and the vector's +//! committed digests. use std::path::PathBuf; @@ -125,3 +129,35 @@ fn the_falsification_sets_fail_the_profile_they_claim() { } } } + +/// Gate 2 of the migration: golden equality. Every committed `int_synth` vector decodes +/// through the lowering to the bytes the direct decode gives, and to every digest the +/// corpus commits for it: the SHA-256 sidecar, the BLAKE3 `rgb_hash` with its sizes, +/// and photo's reference RGB from the Python integer forward. The browser demo serves +/// byte copies of the corpus's payloads and digests. +#[test] +fn every_committed_vector_decodes_through_the_lowering_to_its_golden() { + let hashes = json("neural-conformance/expected_hashes.json"); + let sidecar = json("neural-conformance/sha256.json"); + assert!(read("wai-web/demo/int_synth/expected_hashes.json") == read("neural-conformance/expected_hashes.json")); + let mut n = 0; + for (name, want) in hashes.as_object().unwrap() { + let dir = format!("neural-conformance/vectors/{name}"); + let (payload, set) = (read(&format!("{dir}/latent.bin")), read(&format!("{dir}/int_synth.json"))); + assert!(payload == read(&format!("wai-web/demo/int_synth/{name}/latent.bin")), "{name}: the demo's copy"); + let ops = wai::int_synth::ops::decode_from_spec(&payload, &set).unwrap_or_else(|e| panic!("{name}: {e}")); + let direct = wai::int_synth::legacy::decode_from_spec(&payload, &set).unwrap_or_else(|e| panic!("{name}: {e}")); + assert!(ops == direct, "{name}: the decode through the lowering differs from the direct decode"); + assert_eq!((ops.width as u64, ops.height as u64), (want["width"].as_u64().unwrap(), want["height"].as_u64().unwrap()), "{name}"); + assert_eq!(sha256(&ops.rgb), sidecar[name]["sha256"].as_str().unwrap(), "{name}: the SHA-256 sidecar"); + let mut h = blake3::Hasher::new(); + h.update(b"wai:int-synth-rgb\x01"); + h.update(&ops.rgb); + assert_eq!(h.finalize().to_hex().as_str(), want["rgb_hash"].as_str().unwrap(), "{name}: rgb_hash"); + if let Ok(r) = std::fs::read(wai().join(format!("{dir}/ref_rgb.bin"))) { + assert!(ops.rgb == r, "{name}: the Python integer-forward reference"); + } + n += 1; + } + assert_eq!(n, 4, "the four committed int_synth vectors"); +} -- 2.54.0 (Apple Git-157) From f3927f755d60ffd4f825bf1bea1406dd224a935f Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Tue, 13 Oct 2026 03:06:21 -0400 Subject: [PATCH 06/10] D2c M1 gate 3: refusal parity, int_synth's registry ops member and SPEC citations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refusal parity (tests/int_synth_ops.rs): one fixture for each refusal the direct decode makes (a payload short or long, a side of 2^64 or 2^255, C*S*S past the word, an output that is not [3, H, W], an H of 0, a first in_ch that is not C, a set that is not JSON or has no layers, a malformed layer, a negative, fractional or non-boolean field, a weight or bias count off by one, a value past i32, layers that do not chain, an output side of 0). Each is refused by both decodes, and a sink's int_payload dispatch reports it as payload_malformed, the one code integer-payloads §13 gives. The lowered graph's input check [-128, 127] decodes at both ends and refuses one past each with field_range at input 0. The registry's ops member (integer-operators §11): codecs::OpsMember and capability_ops, Registration::ops, the export's ops member and its schema (built in its own function: the schema literal had reached json!'s recursion limit). wai.neural.int_synth gets {set wai-ops/1, profile acc64, subset full, lowering int_synth/1}; a unit test pins it as the only member, on a NeuralIntegerExact capability. registry/ regenerated by `wai registry export`. SPEC §5's row cites integer-payloads §13 and integer-operators §13.1; SPEC §7 says a NeuralIntegerExact capability that decodes through the operator set names it in its ops member. REGISTRY.md lists the member; CHANGELOG entry. int_ops::PROFILE is private: as a public constant, cbindgen wrote an opaque Profile typedef into include/wai.h. Co-Authored-By: Claude Opus 5.5 --- wai/CHANGELOG.md | 10 +++ wai/REGISTRY.md | 2 +- wai/SPEC.md | 8 +- wai/registry/capabilities.json | 8 +- wai/registry/capabilities.schema.json | 72 +++++++++++++++ wai/wai-rs/src/codecs/mod.rs | 29 ++++++ wai/wai-rs/src/codecs/registry.rs | 93 ++++++++++++++++++- wai/wai-rs/src/int_ops.rs | 2 +- wai/wai-rs/tests/int_synth_ops.rs | 123 ++++++++++++++++++++++++++ 9 files changed, 340 insertions(+), 7 deletions(-) diff --git a/wai/CHANGELOG.md b/wai/CHANGELOG.md index bc5858dcf..88d5a5a08 100644 --- a/wai/CHANGELOG.md +++ b/wai/CHANGELOG.md @@ -6,6 +6,16 @@ wire format may change and such changes are recorded here. ## v0.4.0 — current +### `wai.neural.int_synth` through the integer operator set + +- **Its `ops` member in the registry** ([integer-operators](extensions/integer-operators.md) + §11). `registry/capabilities.json` gives `wai.neural.int_synth` the member + `{"set": "wai-ops/1", "profile": "acc64", "subset": "full", "lowering": + "int_synth/1"}`, and the schema describes the member. SPEC §5's row cites the payload + and set ([integer-payloads](extensions/integer-payloads.md) §13) and the lowering + (integer-operators §13.1), and SPEC §7 says that a `NeuralIntegerExact` capability + that decodes through the operator set names it in its `ops` member. + ### wai-quantum 0.4.3: metered runs, placement by run, and the fabric wrapper Additive. The default features, `full`, both implementations of the WebGPU member, and diff --git a/wai/REGISTRY.md b/wai/REGISTRY.md index 785fdbfa8..21dec382e 100644 --- a/wai/REGISTRY.md +++ b/wai/REGISTRY.md @@ -8,7 +8,7 @@ together with the class functions of [`wai-rs/src/codecs/mod.rs`](wai-rs/src/cod | file | what | |---|---| -| [`registry/capabilities.json`](registry/capabilities.json) | every registered string with its kind, status, media class, role, fidelity tier, licence class, replicate class, set membership, registering section, edition and registration date; the named identifiers that are not capabilities; the unregistered labels; the wire identifiers | +| [`registry/capabilities.json`](registry/capabilities.json) | every registered string with its kind, status, media class, role, fidelity tier, licence class, replicate class, set membership, registering section, edition and registration date, and, for a capability that decodes through the integer operator set, its `ops` member ([integer-operators](extensions/integer-operators.md) §11); the named identifiers that are not capabilities; the unregistered labels; the wire identifiers | | [`registry/capabilities.schema.json`](registry/capabilities.schema.json) | JSON Schema (2020-12) for that file; its enums are the label sets below | | [`vocab/v2.ttl`](vocab/v2.ttl), [`vocab/v2.jsonld`](vocab/v2.jsonld), [`vocab/v2.shapes.ttl`](vocab/v2.shapes.ttl) | the same registry as RDF, a JSON-LD context for manifests, and a SHACL shape that checks a manifest's capability and fallback against it, only the form of its media, and its parameter-set pins (`vocab/README.md`); a violation for a string the registry does not hold means the string is not registered in this revision, not that the envelope fails to conform or cannot be dispatched (SPEC §4, §8) | | the table at the end of this file | the same registry, for reading | diff --git a/wai/SPEC.md b/wai/SPEC.md index a1c587a72..3b514437e 100644 --- a/wai/SPEC.md +++ b/wai/SPEC.md @@ -619,7 +619,7 @@ registration and makes no conforming envelope non-conforming (§8). |---|---|---|---| | `wai.neural.bmshj2018` | bmshj2018-factorized + rANS (byte-exact CompressAI port) | about 0.26 bpp at 29 dB PSNR (quality 3) | CompressAI | | `wai.neural.mbt2018_mean` | mean-scale hyperprior (Gaussian-conditional entropy) | the conditional-entropy family (arXiv 1809.02736) | `neural/hyperprior.rs` | -| `wai.neural.int_synth` | integer fixed-point synthesis (all-i64 transposed-conv, no float/ONNX) | bit-identical on every machine — soundly backs `intent = replicate` | `int_synth.rs` (integer weights sink-supplied; the reference tools make them by training in float, then quantising after training with per-layer shifts calibrated on measured activations, `tools/wai_int_synth_train.py`. Of the committed vectors only `photo` comes from a trained model, `tools/wai_int_synth_real.py`; `gradient`, `bloom`, `facade` and the cross-architecture golden use seeded integer weights) | +| `wai.neural.int_synth` | integer fixed-point synthesis (all-i64 transposed-conv, no float/ONNX) | bit-identical on every machine — soundly backs `intent = replicate` | payload and set: [integer-payloads](extensions/integer-payloads.md) §13; decoded through its lowering, [integer-operators](extensions/integer-operators.md) §13.1 (`int_synth/1`); `int_synth.rs` (integer weights sink-supplied; the reference tools make them by training in float, then quantising after training with per-layer shifts calibrated on measured activations, `tools/wai_int_synth_train.py`. Of the committed vectors only `photo` comes from a trained model, `tools/wai_int_synth_real.py`; `gradient`, `bloom`, `facade` and the cross-architecture golden use seeded integer weights) | | `wai.neural.int_hyper` | integer fixed-point **conditional** codec — whole bitstream→image path integer (integer hyper-decoder σ → integer-σ entropy → integer synthesis) | the integer-entropy contract fully assembled: a PTQ of mbt2018_mean, bit-identical on every machine, whose integer decode CI holds within 1 dB of the same model's float decode on a pinned photograph | payload `WIH1`; `int_hyper_synth.rs` (tables sink-supplied) | | `wai.neural.int_mlicpp` | integer fixed-point **MLIC++ entropy topology** — `K` channel slices of the latent, each decoded as a checkerboard anchor pass then a non-anchor pass, with an entropy network over concat[latent decoded so far, hyper] re-run before every pass to give σ‖μ; hyper-decoder, entropy network and synthesis all integer | the channel-autoregressive × checkerboard entropy model at **decode-equivalence**: the encoder and every decoder compute the same σ, so the same distribution for every element, at any thread count; bit-identical on every machine. The committed conformance vector has synthetic weights, so it demonstrates decode-equivalence and states no quality figure | `int_mlicpp.rs` (one-file model bundle sink-supplied; `tools/wai_int_mlicpp_export.py`) | | `wai.image.jpegai` | JPEG AI (ISO/IEC 6048-1:2025) codestream → RGB — integer entropy front end (mANS, with an integer scale-index derivation) + 16-bit-PTQ integer hyper-decoder, context-model and synthesis networks + dequantisation, latent refinement, post-filters (the eICCI CNN among them) and colour conversion in IEEE-754 float with a fixed evaluation order. Scope: one configuration — 256×256 4:4:4, `model_id` 0 (tools_0), `beta_displacement_log` 0, the high-operation-point synthesis, the eICCI model pair the weight bundle carries, and the other header conditions in [determinism-tiers](extensions/determinism-tiers.md) §5; both decode paths refuse any other codestream. Within it, a sink without the integer weight bundle, or in a non-default floating-point environment, takes a float ONNX decode instead. Only checkpoint constants are sink-supplied | **entropy-consistency** (registry class `NeuralEntropyExact`): the entropy decode, including the scale-index derivation, is integer on both decode paths, so the decoded symbols reproduce on every machine. The exact decoder also pins one reconstruction to RGB, reproducible across machines under the build and floating-point conditions in [determinism-tiers](extensions/determinism-tiers.md) §5–§6 (it checks the floating-point environment before decoding) — a property of that decoder, not of the capability, so `wai.image.jpegai` does not back `replicate` (§7). On one reference codestream the exact decoder stays within max\|Δ\| ≤ 2 of the reference decoder (recorded max\|Δ\| = 1 on ~4.4% of samples); evidence in [determinism-tiers](extensions/determinism-tiers.md) §6. **Downgraded in v0.4.0:** this row previously claimed decode-equivalence, and the registry classed the capability `StandardDefined` | `jpegai_decode.rs` (+ `jpegai_{bitstream,mans,pipeline,synthesis,icci}.rs`) | @@ -1434,6 +1434,12 @@ registry as `codecs::capability_replicate_class(cap) -> ReplicateClass`: checkerboard entropy model on integer networks, so the σ it re-derives before each of its `2·K` passes is integer too; `wai.video.int_hyper`, `wai.audio.int_codec` and `wai.splat.int_codec` are integer throughout as well.) + A capability of this class whose networks decode through the integer operator set + names it in the registry, in its `ops` member + ([integer-operators](extensions/integer-operators.md) §11): the lowering its sets + are lowered by, the profile every conforming set's certificate meets, and the modes + of its stage operators. A set whose certificate does not meet that profile is + refused. In this revision: `wai.neural.int_synth`. - **`NeuralEntropyExact`** — the entropy decode is integer by construction, so the decoded symbols are byte-identical on every machine; later stages run float. Registered at `entropy-consistency`. An encoder MUST NOT emit `intent = diff --git a/wai/registry/capabilities.json b/wai/registry/capabilities.json index 59e0b6d5d..8b2b6cb73 100644 --- a/wai/registry/capabilities.json +++ b/wai/registry/capabilities.json @@ -739,7 +739,13 @@ "sets": [], "spec_section": "§5 Neural capabilities", "edition": null, - "registered": "2026-06-13" + "registered": "2026-06-13", + "ops": { + "set": "wai-ops/1", + "profile": "acc64", + "subset": "full", + "lowering": "int_synth/1" + } }, { "capability": "wai.neural.mbt2018_mean", diff --git a/wai/registry/capabilities.schema.json b/wai/registry/capabilities.schema.json index 7ab4f36ff..7f540e95b 100644 --- a/wai/registry/capabilities.schema.json +++ b/wai/registry/capabilities.schema.json @@ -132,6 +132,78 @@ "type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$", "description": "The date of the first commit of this repository that registers the string; the specification entered the repository on 2026-06-12." + }, + "ops": { + "type": "object", + "description": "The integer operator set the capability decodes through (extensions/integer-operators.md §11). Absent for a capability that does not.", + "additionalProperties": false, + "required": [ + "set", + "profile", + "subset", + "lowering" + ], + "properties": { + "set": { + "const": "wai-ops/1" + }, + "profile": { + "enum": [ + "acc32", + "acc64" + ] + }, + "subset": { + "enum": [ + "full", + "npu" + ] + }, + "lowering": { + "type": "string" + }, + "stage": { + "type": "object", + "additionalProperties": false, + "properties": { + "rans64": { + "type": "object", + "additionalProperties": false, + "required": [ + "table", + "escape", + "end" + ], + "properties": { + "table": { + "enum": [ + "t4097", + "t2" + ] + }, + "escape": { + "enum": [ + "lenient", + "strict" + ] + }, + "end": { + "enum": [ + "canonical", + "final_only" + ] + } + } + }, + "bucket": { + "enum": [ + "ceil", + "floor" + ] + } + } + } + } } } } diff --git a/wai/wai-rs/src/codecs/mod.rs b/wai/wai-rs/src/codecs/mod.rs index d8f0c00fc..9b941602c 100644 --- a/wai/wai-rs/src/codecs/mod.rs +++ b/wai/wai-rs/src/codecs/mod.rs @@ -1330,6 +1330,35 @@ pub fn capability_replicate_class(cap: &str) -> Option { }) } +/// The integer operator set a capability's decode runs through +/// (`extensions/integer-operators.md` §11): the registry's `ops` member. `profile` is the +/// profile every conforming parameter set of the capability meets, and a set whose +/// certificate does not meet it is refused; `subset` is `full` or `npu`; `lowering` +/// names the §13 lowering the capability's sets are lowered by; `rans64` and `bucket` +/// fix the modes of the stage operators its driver uses (§5E). Strengthening a profile +/// or a subset narrows the sets a capability accepts, a breaking change (SPEC §8). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct OpsMember { + pub profile: &'static str, + pub subset: &'static str, + pub lowering: &'static str, + /// `rans64_decode`'s `(table, escape, end)` (E1), where the driver decodes one. + pub rans64: Option<(&'static str, &'static str, &'static str)>, + /// `bucket`'s rule (E3), where the driver buckets a scale. + pub bucket: Option<&'static str>, +} + +/// The `ops` member of `cap`, for a capability decoded through the integer operator +/// set; `None` for any other. A capability gains one when its decoder runs its +/// networks through their lowering (`extensions/integer-operators.md` §15.1). +pub fn capability_ops(cap: &str) -> Option { + let ops = |lowering, rans64, bucket| Some(OpsMember { profile: "acc64", subset: "full", lowering, rans64, bucket }); + match cap { + CAP_NEURAL_INT_SYNTH => ops("int_synth/1", None, None), + _ => None, + } +} + /// Whether a capability's **neural** decode is integer-exact by construction — /// the integer-entropy contract. Isolates exactly the neural codecs WAI itself /// guarantees byte-identical on every machine, so a deployer can require diff --git a/wai/wai-rs/src/codecs/registry.rs b/wai/wai-rs/src/codecs/registry.rs index ce6f4678e..f22f8389e 100644 --- a/wai/wai-rs/src/codecs/registry.rs +++ b/wai/wai-rs/src/codecs/registry.rs @@ -564,6 +564,9 @@ pub struct Registration { /// See [`ROWS`]: a designation, or `None` for the edition current at `registered`. pub edition: Option<&'static str>, pub registered: &'static str, + /// The integer operator set the capability decodes through, if it does + /// (`extensions/integer-operators.md` §11). + pub ops: Option, } /// Every registered string — payload, companion, derivation and record — sorted @@ -585,6 +588,7 @@ pub fn registrations() -> Vec { spec_section: section, edition, registered, + ops: super::capability_ops(cap), }) .collect(); for op in derivation::derivation_operations() { @@ -602,6 +606,7 @@ pub fn registrations() -> Vec { spec_section: S_PRIOR, edition: Some("ISO/IEC 23001-16:2021"), registered: DERIVATIONS_REGISTERED, + ops: None, }); } v.sort_by(|a, b| a.capability.cmp(b.capability)); @@ -746,7 +751,7 @@ pub fn capabilities_json() -> String { let caps: Vec = registrations() .iter() .map(|r| { - json!({ + let mut v = json!({ "capability": r.capability, "kind": r.kind.label(), "status": r.status.label(), @@ -759,7 +764,11 @@ pub fn capabilities_json() -> String { "spec_section": r.spec_section, "edition": r.edition, "registered": r.registered, - }) + }); + if let Some(o) = r.ops { + v["ops"] = ops_json(&o); + } + v }) .collect(); let mut ids: Vec<&Identifier> = IDENTIFIERS.iter().collect(); @@ -790,12 +799,63 @@ pub fn capabilities_json() -> String { })) } +/// The registry's `ops` member (`extensions/integer-operators.md` §11), its members in +/// that section's order. +fn ops_json(o: &super::OpsMember) -> serde_json::Value { + use serde_json::{json, Map, Value}; + let mut v = json!({ "set": "wai-ops/1", "profile": o.profile, "subset": o.subset, "lowering": o.lowering }); + let mut stage = Map::new(); + if let Some((table, escape, end)) = o.rans64 { + stage.insert("rans64".into(), json!({ "table": table, "escape": escape, "end": end })); + } + if let Some(b) = o.bucket { + stage.insert("bucket".into(), Value::from(b)); + } + if !stage.is_empty() { + v["stage"] = Value::Object(stage); + } + v +} + fn nullable_enum(labels: &[&str]) -> serde_json::Value { let mut e: Vec = labels.iter().map(|l| serde_json::Value::from(*l)).collect(); e.push(serde_json::Value::Null); serde_json::json!({ "enum": e }) } +/// The schema of a capability's `ops` member (`extensions/integer-operators.md` §11). +fn ops_schema() -> serde_json::Value { + serde_json::json!({ + "type": "object", + "description": "The integer operator set the capability decodes through (extensions/integer-operators.md §11). Absent for a capability that does not.", + "additionalProperties": false, + "required": ["set", "profile", "subset", "lowering"], + "properties": { + "set": { "const": "wai-ops/1" }, + "profile": { "enum": ["acc32", "acc64"] }, + "subset": { "enum": ["full", "npu"] }, + "lowering": { "type": "string" }, + "stage": { + "type": "object", + "additionalProperties": false, + "properties": { + "rans64": { + "type": "object", + "additionalProperties": false, + "required": ["table", "escape", "end"], + "properties": { + "table": { "enum": ["t4097", "t2"] }, + "escape": { "enum": ["lenient", "strict"] }, + "end": { "enum": ["canonical", "final_only"] } + } + }, + "bucket": { "enum": ["ceil", "floor"] } + } + } + } + }) +} + /// `wai/registry/capabilities.schema.json` (JSON Schema 2020-12). Its enums are /// the registry's label sets. pub fn capabilities_schema() -> String { @@ -845,7 +905,8 @@ pub fn capabilities_schema() -> String { "type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$", "description": "The date of the first commit of this repository that registers the string; the specification entered the repository on 2026-06-12." - } + }, + "ops": ops_schema() } } }, @@ -1356,6 +1417,32 @@ mod tests { assert!(md == want, "the generated table in wai/REGISTRY.md differs from the registry: run `wai registry export`"); } + /// The `ops` members (`extensions/integer-operators.md` §11). Only a + /// `NeuralIntegerExact` capability carries one, every one is at `acc64` with the + /// full subset and names a lowering of §13, and the export writes exactly these. + /// In this revision `wai.neural.int_synth` is the one capability that decodes + /// through the operator set. + #[test] + fn ops_members_name_the_operator_set_a_capability_decodes_through() { + let regs = registrations(); + let with: Vec<&str> = regs.iter().filter(|r| r.ops.is_some()).map(|r| r.capability).collect(); + assert_eq!(with, [CAP_NEURAL_INT_SYNTH]); + for r in regs.iter().filter(|r| r.ops.is_some()) { + let o = r.ops.unwrap(); + assert_eq!(r.replicate, Some(ReplicateClass::NeuralIntegerExact), "{}", r.capability); + assert_eq!((o.profile, o.subset), ("acc64", "full"), "{}", r.capability); + assert!(["int_synth/1", "whm1/1", "wam1/1", "wmb1/1", "world/1", "jpegai-int/1"].contains(&o.lowering), "{}", r.capability); + } + let export: serde_json::Value = serde_json::from_str(&read("registry/capabilities.json")).unwrap(); + for c in export["capabilities"].as_array().unwrap() { + let want = match c["capability"].as_str().unwrap() { + CAP_NEURAL_INT_SYNTH => serde_json::json!({ "set": "wai-ops/1", "profile": "acc64", "subset": "full", "lowering": "int_synth/1" }), + _ => serde_json::Value::Null, + }; + assert_eq!(c["ops"], want, "{}", c["capability"]); + } + } + /// The schema's enums are the label sets the registry uses. #[test] fn registry_schema_enums_equal_labels() { diff --git a/wai/wai-rs/src/int_ops.rs b/wai/wai-rs/src/int_ops.rs index c37673c41..a70183ed2 100644 --- a/wai/wai-rs/src/int_ops.rs +++ b/wai/wai-rs/src/int_ops.rs @@ -15,7 +15,7 @@ use wai_ops::{Profile, Refusal}; /// The profile of every capability that decodes through the operator set /// (`extensions/integer-operators.md` §11): `acc64`, which every lowering of §13 /// declares. A set may meet `acc32` as well; its certificate shows that. -pub const PROFILE: Profile = Profile::Acc64; +const PROFILE: Profile = Profile::Acc64; /// Load a lowered graph as a sink does: from its canonical bytes, under [`PROFILE`]. /// A refusal is `model_mismatch`, or `too_large` for a size (§10.5). diff --git a/wai/wai-rs/tests/int_synth_ops.rs b/wai/wai-rs/tests/int_synth_ops.rs index e11b0c77b..096663f71 100644 --- a/wai/wai-rs/tests/int_synth_ops.rs +++ b/wai/wai-rs/tests/int_synth_ops.rs @@ -11,10 +11,13 @@ //! The decode through the lowering (`int_synth::ops`) must give, on every committed //! vector, the bytes of the direct decode (`int_synth::legacy`) and the vector's //! committed digests. +//! Every refusal of the direct decode is a refusal of the decode through the lowering, +//! with the same code: integer-payloads §13 gives one, `payload_malformed`. use std::path::PathBuf; use serde_json::Value; +use wai_ops::interp::Tensor; use wai_ops::{Code, Profile, Site}; const CAP: &str = "wai.neural.int_synth"; @@ -161,3 +164,123 @@ fn every_committed_vector_decodes_through_the_lowering_to_its_golden() { } assert_eq!(n, 4, "the four committed int_synth vectors"); } + +/// A payload of `C` channels at side `2^s_log2` for an `H`×`W` output: the latents are +/// `fill`, and `extra` bytes are added (negative: removed) at the end. +fn payload(h: u32, w: u32, c: u16, s_log2: u8, fill: u8, extra: i64) -> Vec { + let mut p = Vec::new(); + p.extend(h.to_le_bytes()); + p.extend(w.to_le_bytes()); + p.extend(c.to_le_bytes()); + p.extend([s_log2, 0]); + let n = (c as usize) << (2 * s_log2 as usize); + p.resize((12 + n as i64 + extra) as usize, fill); + p +} + +/// One layer of `int_synth.json`, every weight `w`. +fn layer(in_ch: u64, out_ch: u64, k: u64, stride: u64, pad: u64, w: i64) -> Value { + serde_json::json!({ + "in_ch": in_ch, "out_ch": out_ch, "k": k, "stride": stride, "pad": pad, + "weight": vec![w; (in_ch * out_ch * k * k) as usize], "bias": vec![1; out_ch as usize], "shift": 1, "relu": true, + }) +} + +fn set(layers: Vec) -> Vec { + serde_json::to_vec(&serde_json::json!({ "layers": layers })).unwrap() +} + +/// The fixtures: one for each refusal the direct decode makes (integer-payloads §13), +/// each beside a payload and set it decodes. One layer, 1 → 3 channels, k 2, stride 2, +/// on a 1×1 latent: a 2×2 output. +fn refusal_fixtures() -> Vec<(&'static str, Vec, Vec)> { + let good_set = set(vec![layer(1, 3, 2, 2, 0, 3)]); + let good = payload(2, 2, 1, 0, 9, 0); + let edit = |f: &dyn Fn(&mut Value)| { + let mut v: Value = serde_json::from_slice(&good_set).unwrap(); + f(&mut v); + serde_json::to_vec(&v).unwrap() + }; + let p = |what, pay: Vec| (what, pay, good_set.clone()); + let q = |what, s: Vec| (what, good.clone(), s); + vec![ + p("no payload", vec![]), + p("a payload shorter than its header", good[..11].to_vec()), + p("a latent short", payload(2, 2, 1, 0, 9, -1)), + p("a byte past the latents", payload(2, 2, 1, 0, 9, 1)), + p("a side of 2^64", { let mut v = good.clone(); v[10] = 64; v }), + p("a side of 2^255", { let mut v = good.clone(); v[10] = 255; v }), + p("C·S·S past the word", { let mut v = payload(2, 2, 1, 0, 9, 0); v[8..10].copy_from_slice(&u16::MAX.to_le_bytes()); v[10] = 31; v }), + p("an output taller than H", payload(1, 2, 1, 0, 9, 0)), + p("an output narrower than W", payload(2, 3, 1, 0, 9, 0)), + p("H of 0", payload(0, 2, 1, 0, 9, 0)), + p("first in_ch is not C", payload(2, 2, 2, 0, 9, 0)), + q("a set that is not JSON", b"{".to_vec()), + q("a set with no layers member", b"{}".to_vec()), + q("layers that is not a list", br#"{"layers":{}}"#.to_vec()), + q("a layer that is not an object", br#"{"layers":[1]}"#.to_vec()), + q("a layer without k", edit(&|v| { v["layers"][0].as_object_mut().unwrap().remove("k"); })), + q("a negative stride", edit(&|v| v["layers"][0]["stride"] = (-2).into())), + q("a k with a fraction", edit(&|v| v["layers"][0]["k"] = 2.5.into())), + q("a negative shift", edit(&|v| v["layers"][0]["shift"] = (-1).into())), + q("a relu that is not a boolean", edit(&|v| v["layers"][0]["relu"] = 1.into())), + q("a weight short", edit(&|v| { v["layers"][0]["weight"].as_array_mut().unwrap().pop(); })), + q("a bias past out_ch", edit(&|v| v["layers"][0]["bias"].as_array_mut().unwrap().push(0.into()))), + q("a weight past i32", edit(&|v| v["layers"][0]["weight"][0] = (1i64 << 31).into())), + q("a bias below i32", edit(&|v| v["layers"][0]["bias"][0] = (-(1i64 << 31) - 1).into())), + q("layers that do not chain", set(vec![layer(1, 2, 1, 1, 0, 1), layer(3, 3, 2, 2, 0, 1)])), + q("four output channels", set(vec![layer(1, 4, 2, 2, 0, 3)])), + q("an output side of 0", set(vec![layer(1, 3, 2, 2, 1, 3)])), + ] +} + +/// The code a sink reports for a decode of `wai.neural.int_synth` (`int_payload`, the +/// integer decode path the dispatch takes). +fn code(payload: &[u8], set: &[u8]) -> Result<(), &'static str> { + let params = wai::int_payload::resolve(CAP, None, &[wai::pin::HeldBytes::File(set)], None).expect("an unpinned set resolves"); + wai::int_payload::decode_int(CAP, payload, ¶ms).map(|_| ()).map_err(|e| e.code()) +} + +/// Gate 3 of the migration: refusal parity. Each fixture, refused by the direct decode, +/// is refused by the decode through the lowering, and a sink reports it as +/// `payload_malformed`, the one code integer-payloads §13 gives. Beside them, the +/// payload and set they are edited from decode both ways. +#[test] +fn every_refusal_of_the_direct_decode_is_refused_through_the_lowering() { + let good = (payload(2, 2, 1, 0, 9, 0), set(vec![layer(1, 3, 2, 2, 0, 3)])); + let a = wai::int_synth::ops::decode_from_spec(&good.0, &good.1).expect("the fixtures' base decodes"); + assert_eq!(Ok(a.clone()), wai::int_synth::legacy::decode_from_spec(&good.0, &good.1)); + assert_eq!(a.rgb, [14u8; 12], "(9·3 + 1) >> 1 at every sample"); + assert_eq!(code(&good.0, &good.1), Ok(())); + let fixtures = refusal_fixtures(); + for (what, pay, s) in &fixtures { + let direct = wai::int_synth::legacy::decode_from_spec(pay, s); + let ops = wai::int_synth::ops::decode_from_spec(pay, s); + assert!(direct.is_err(), "{what}: the direct decode refuses it: {direct:?}"); + assert!(ops.is_err(), "{what}: the decode through the lowering refuses it: {ops:?}"); + assert_eq!(code(pay, s), Err("payload_malformed"), "{what}"); + } +} + +/// The lowered graph's one `check` range, its input's `[−128, 127]` (§13.0's driver +/// contract), at its ends and one past them: a payload's latents are signed bytes, so +/// only a graph-level decode reaches past them, and the graph refuses it (`field_range`, +/// at input 0). +#[test] +fn the_latent_range_refuses_one_past_each_end() { + let lowered = wai_ops::lower::int_synth(&read("neural-conformance/vectors/gradient/int_synth.json")).unwrap(); + let g = wai::int_ops::load(lowered.get("synth").unwrap()).unwrap(); + let (c, s) = (4u32, 8u32); + let x = |v: i64| { + let mut d = vec![0i64; (c * s * s) as usize]; + d[37] = v; + Tensor::new(c, s, s, d) + }; + for v in [-128, 127] { + assert!(wai::int_ops::run1(&g, x(v), &[]).is_ok(), "{v} is a latent"); + } + for v in [-129, 128] { + let r = wai::int_ops::run1(&g, x(v), &[]).expect_err("one past the latent range"); + assert_eq!((r.code, r.at), (Code::FieldRange, Site::Input(0)), "{v}"); + } +} -- 2.54.0 (Apple Git-157) From 89e00eaa8f0658bc33f83af904d1f9a52fae5faf Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Tue, 13 Oct 2026 07:26:01 -0400 Subject: [PATCH 07/10] D2c M1 gate 4: int_synth through wai-ops against its direct decode, differentially MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tests/int_ops_differential.rs (feature legacy_int, test-only; a [[test]] entry with required-features): - an oracle that names, from the payload and the set alone, the planned break of integer-operators §15.3 a case falls under: a reserved byte that is not 0, C of 0, latents past 2^26, no layers, a field past 32 bits, a shift of 128 or more, k or stride of 0, out_ch of 0, an uncropped output or a tensor past 2^26, a certificate that does not meet acc64 (lowered and loaded), and a shift from 64 to 127 (exact now, by build before); - random sets and payloads (1-3 layers, kernels 1-4, strides 1-3, paddings and output paddings mostly in range, shifts 0-24, weights to 2^4, 2^10, 2^14 and, one set in eight, 2^20; latents a quarter at their ends; outputs one in eight off the payload's H and W; reserved bytes one in thirty-two not 0): outside a planned break both decodes give the same bytes or both refuse, and the direct decode never panics; under one, the decode through the operator set refuses; - each planned break shown on a payload and set the direct decode decodes, the operator set refusing it (or, for a shift of 64, giving it its exact meaning). Among them two the extension's text does not yet list: a set with no layers, and a layer with out_ch 0. Fixed count (3000 cases) on every push in wai-conformance's fast-kernel steps, both profiles (legacy_int at the end of the feature list, where gate tooling that greps for the step still finds it). WAI_DIFF_SECS / WAI_DIFF_SEED run it by time and seed: wai-ops-differential.yml does, an hour per profile, nightly on linux, seeded by the date. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/wai-conformance.yml | 12 +- .github/workflows/wai-ops-differential.yml | 63 +++++ wai/wai-rs/Cargo.toml | 10 + wai/wai-rs/tests/int_ops_differential.rs | 298 +++++++++++++++++++++ 4 files changed, 380 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/wai-ops-differential.yml create mode 100644 wai/wai-rs/tests/int_ops_differential.rs diff --git a/.github/workflows/wai-conformance.yml b/.github/workflows/wai-conformance.yml index f35f244b3..4b9bd5b6e 100644 --- a/.github/workflows/wai-conformance.yml +++ b/.github/workflows/wai-conformance.yml @@ -210,7 +210,13 @@ jobs: # same integers. All pure Rust, so every leg runs them. # tests/int_synth_ops.rs holds wai.neural.int_synth to its lowering through the # integer operator set (extensions/integer-operators.md §13.1): every committed set - # lowers to its committed graph and loads with its certificate's verdict. + # lowers to its committed graph and loads with its certificate's verdict, decodes + # through it to every committed golden, and refuses whatever the direct decode + # refuses. tests/int_ops_differential.rs (feature legacy_int: the direct decode, + # test-only) decodes random sets and payloads both ways: the same output or both + # refused, except on the planned breaks of integer-operators §15.3, where the decode + # through the operator set refuses. wai-ops-differential.yml runs it for an hour in + # each profile, nightly. # tests/int_mlicpp.rs decodes the committed wai.neural.int_mlicpp vector against # its pinned hashes (image, every scale bucket, every symbol) under every kernel # policy and thread count, and checks that damaged payloads are refused rather than @@ -263,10 +269,10 @@ jobs: # build most of a minute. - name: Fast integer kernels ≡ reference kernels (overflow checks on) working-directory: wai/wai-rs - run: cargo test --no-default-features --features neural_int,video_int,audio_int,world_int,splat_int,generate,score,staged,stage_measure --lib --test int_kernels_vectors --test int_kernels_geometry --test int_synth_ops --test int_mlicpp --test int_mlicpp_preflight --test int_audio --test int_splat --test generate --test cli_generate_score --test int_payload --test int_payload_preflight --test int_payload_cost --test int_video_enc_memory --test cli_stage --test staged_resources --test stage_measure --test stage_measure_bounds + run: cargo test --no-default-features --features neural_int,video_int,audio_int,world_int,splat_int,generate,score,staged,stage_measure,legacy_int --lib --test int_kernels_vectors --test int_kernels_geometry --test int_synth_ops --test int_ops_differential --test int_mlicpp --test int_mlicpp_preflight --test int_audio --test int_splat --test generate --test cli_generate_score --test int_payload --test int_payload_preflight --test int_payload_cost --test int_video_enc_memory --test cli_stage --test staged_resources --test stage_measure --test stage_measure_bounds - name: Fast integer kernels ≡ reference kernels (release, overflow checks off) working-directory: wai/wai-rs - run: cargo test --release --no-default-features --features neural_int,video_int,audio_int,world_int,splat_int,generate,score,staged,stage_measure --lib --test int_kernels_vectors --test int_kernels_geometry --test int_synth_ops --test int_mlicpp --test int_mlicpp_preflight --test int_audio --test int_splat --test generate --test cli_generate_score --test int_payload --test int_payload_preflight --test int_payload_cost --test int_video_enc_memory --test cli_stage --test staged_resources --test stage_measure --test stage_measure_bounds --test manifest_parse_memory --test manifest_reader_equivalence + run: cargo test --release --no-default-features --features neural_int,video_int,audio_int,world_int,splat_int,generate,score,staged,stage_measure,legacy_int --lib --test int_kernels_vectors --test int_kernels_geometry --test int_synth_ops --test int_ops_differential --test int_mlicpp --test int_mlicpp_preflight --test int_audio --test int_splat --test generate --test cli_generate_score --test int_payload --test int_payload_preflight --test int_payload_cost --test int_video_enc_memory --test cli_stage --test staged_resources --test stage_measure --test stage_measure_bounds --test manifest_parse_memory --test manifest_reader_equivalence # The integer decode path builds under each of its features alone, so a sink can take # one medium without the others, and for wasm32 (a browser sink); `int_payload` arms a # medium only under its feature, and none needs `neural`. So does the staged-measurement diff --git a/.github/workflows/wai-ops-differential.yml b/.github/workflows/wai-ops-differential.yml new file mode 100644 index 000000000..2ab257513 --- /dev/null +++ b/.github/workflows/wai-ops-differential.yml @@ -0,0 +1,63 @@ +name: wai-ops differential (nightly) + +# Each integer decoder that decodes through the integer operator set +# (wai/extensions/integer-operators.md) against its direct decode (the test-only +# `legacy_int` feature), on random parameter sets and payloads, for an hour under each +# build profile: with overflow checks on and with them off. Outside the planned breaks +# of integer-operators §15.3 the two decode to the same bytes or both refuse, and the +# direct decode never panics; under a planned break the decode through the operator set +# refuses (wai/wai-rs/tests/int_ops_differential.rs). Today: wai.neural.int_synth. +# +# Each night starts from its own seed, the date, which the log prints; a failing case +# names its seed, and `WAI_DIFF_SEED=` re-runs it. The same tests run for a fixed +# count on every push, in wai-conformance.yml's fast-kernel steps. + +# One run per branch at a time. A push supersedes the run before it. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +on: + schedule: + - cron: '41 2 * * *' + # A change to this file runs it for two minutes per profile, so a broken job is seen + # before the night. + pull_request: + paths: + - '.github/workflows/wai-ops-differential.yml' + +jobs: + differential: + name: differential · ${{ matrix.profile }} + runs-on: linux + timeout-minutes: 120 + strategy: + fail-fast: false + matrix: + profile: [debug, release] + steps: + - uses: actions/checkout@v4 + + # The docker image ships no Rust. + - name: Install Rust 1.98.0 + shell: bash + run: | + export PATH="$HOME/.cargo/bin:$PATH" + command -v rustup >/dev/null 2>&1 || \ + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain 1.98.0 + rustup toolchain install 1.98.0 --profile minimal + rustup default 1.98.0 + echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" + + - name: Through the operator set against the direct decode, random sets and payloads + working-directory: wai/wai-rs + shell: bash + env: + WAI_DIFF_SECS: ${{ github.event_name == 'schedule' && '3600' || '120' }} + run: | + set -euo pipefail + P="" + if [ "${{ matrix.profile }}" = release ]; then P="--release"; fi + export WAI_DIFF_SEED="$(date -u +%Y%m%d)" + echo "WAI_DIFF_SECS=$WAI_DIFF_SECS WAI_DIFF_SEED=$WAI_DIFF_SEED" + cargo test $P --locked --no-default-features --features legacy_int --test int_ops_differential -- --nocapture diff --git a/wai/wai-rs/Cargo.toml b/wai/wai-rs/Cargo.toml index 2f288e45a..26bfab576 100644 --- a/wai/wai-rs/Cargo.toml +++ b/wai/wai-rs/Cargo.toml @@ -135,6 +135,11 @@ ffi = [] # matrix with no ONNX Runtime present. `neural` pulls it in. blake3 is for the # conformance corpus's content hash (the same hash the other classes freeze). neural_int = ["dep:blake3", "dep:wai-ops"] +# Test-only: the integer decoders' direct decodes, as they were before they decoded +# through the integer operator set (`int_synth::legacy`). The differential tests decode +# random sets and payloads both ways and require the same output or the same refusal +# outside the planned breaks (tests/int_ops_differential.rs). No sink enables it. +legacy_int = ["neural_int"] # Deterministic integer generative synthesis (`wai.generate.*`): byte-exact # generation pinned to (model, prompt, seed, steps) — the create-side of the # integer contract. Pure i64, no ONNX/float (serde_json is already a base dep), @@ -741,6 +746,11 @@ name = "int_kernels_geometry" path = "tests/int_kernels_geometry.rs" required-features = ["neural_int"] +[[test]] +name = "int_ops_differential" +path = "tests/int_ops_differential.rs" +required-features = ["legacy_int"] + [[test]] name = "int_synth_ops" path = "tests/int_synth_ops.rs" diff --git a/wai/wai-rs/tests/int_ops_differential.rs b/wai/wai-rs/tests/int_ops_differential.rs new file mode 100644 index 000000000..371ac7890 --- /dev/null +++ b/wai/wai-rs/tests/int_ops_differential.rs @@ -0,0 +1,298 @@ +//! Each integer decoder through the integer operator set against its direct decode +//! (`legacy_int`, test-only), on random parameter sets and payloads. The two must give +//! the same output, or both refuse with the same code. The only inputs on which they may +//! differ are the planned breaks the operator set makes (`extensions/integer-operators.md` +//! §15.3), which are new refusals: each case is classified from the payload and the set +//! alone, and a case that falls under a planned break must be refused by the decode +//! through the operator set, whatever the direct decode does there. +//! +//! The random cases run for a fixed count by default. `WAI_DIFF_SECS` runs each random +//! test for that many seconds instead (the nightly run, `wai-ops-differential.yml`), and +//! `WAI_DIFF_SEED` sets the first seed; a failing case names its seed. +#![cfg(feature = "legacy_int")] + +use std::panic::catch_unwind; +use std::path::PathBuf; +use std::time::{Duration, Instant}; + +use serde_json::{json, Value}; +use wai::int_synth::{legacy, ops, IntImage, IntSynthError}; + +fn wai() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..") +} + +/// A splitmix64 sequence. +struct Rng(u64); + +impl Rng { + fn next(&mut self) -> u64 { + self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15); + let mut z = self.0; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) + } + fn below(&mut self, n: u64) -> u64 { + self.next() % n + } + /// A value in `[−r, r]`. + fn signed(&mut self, r: i64) -> i64 { + (self.below(2 * r as u64 + 1) as i64) - r + } +} + +/// Run `case` on successive seeds: `n` of them, or for `WAI_DIFF_SECS` seconds. Each +/// seed is `WAI_DIFF_SEED` (or a fixed default) mixed with the case's index. Returns how +/// many ran. +fn cases(name: &str, n: u64, mut case: impl FnMut(&mut Rng, u64)) -> u64 { + let secs: Option = std::env::var("WAI_DIFF_SECS").ok().and_then(|s| s.parse().ok()); + let seed0: u64 = std::env::var("WAI_DIFF_SEED").ok().and_then(|s| s.parse().ok()).unwrap_or(0x0D2C); + let until = secs.map(|s| Instant::now() + Duration::from_secs(s)); + let mut k = 0u64; + loop { + match until { + Some(t) if Instant::now() >= t => break, + None if k >= n => break, + _ => {} + } + let seed = seed0 ^ k.wrapping_mul(0xD1B5_4A32_D192_ED03); + case(&mut Rng(seed), seed); + k += 1; + } + eprintln!("{name}: {k} cases from WAI_DIFF_SEED={seed0}"); + k +} + +// ------------------------------------------------------------------ int_synth (M1) + +const CAP: u128 = 1 << 26; + +/// The planned break of integer-operators §15.3 item 1 that a payload and set fall +/// under, if any, read from the bytes alone. `"shift 64 to 127"` is the one that is not a +/// refusal: such a shift now has its exact meaning, where the direct decode's result +/// depended on the build. +fn int_synth_planned_break(payload: &[u8], set: &[u8]) -> Option<&'static str> { + if payload.len() < 12 { + return None; + } + if payload[11] != 0 { + return Some("a reserved byte that is not 0"); + } + let c = u16::from_le_bytes([payload[8], payload[9]]) as u128; + if c == 0 { + return Some("C of 0"); + } + let s_log2 = payload[10] as u32; + if s_log2 >= 64 { + return None; + } + if (1u128 << (2 * s_log2)).checked_mul(c).is_none_or(|n| n > CAP) { + return Some("latents past 2^26"); + } + let Ok(root) = serde_json::from_slice::(set) else { return None }; + let Some(layers) = root.get("layers").and_then(Value::as_array) else { return None }; + if layers.is_empty() { + return Some("no layers"); + } + let field = |l: &Value, k: &str, default: Option| l.get(k).map_or(default, Value::as_u64); + let mut shift_64 = false; + let mut side = 1u128 << s_log2; + for l in layers { + let (Some(in_ch), Some(out_ch), Some(k), Some(stride), Some(pad), Some(out_pad), Some(shift)) = ( + field(l, "in_ch", None), field(l, "out_ch", None), field(l, "k", None), field(l, "stride", None), + field(l, "pad", None), field(l, "out_pad", Some(0)), field(l, "shift", Some(0)), + ) else { + return None; + }; + if [in_ch, out_ch, k, stride, pad, out_pad].iter().any(|&v| v > u32::MAX as u64) { + return Some("a field past 32 bits"); + } + if shift >= 128 { + return Some("a shift of 128 or more"); + } + if k == 0 || stride == 0 { + return Some("k or stride of 0"); + } + if out_ch == 0 { + return Some("out_ch of 0"); + } + shift_64 |= shift >= 64; + // The run-time plan's caps (integer-operators §3, §5.3): the uncropped sides and + // output, then the output. + let full = (side - 1) * stride as u128 + k as u128 + out_pad as u128; + if full > CAP || out_ch as u128 * full * full > CAP { + return Some("an uncropped output past 2^26"); + } + if full <= 2 * pad as u128 { + return None; + } + side = full - 2 * pad as u128; + if out_ch as u128 * side * side > CAP { + return Some("a tensor past 2^26"); + } + } + let lowered = wai_ops::lower::int_synth(set).ok()?; + if wai::int_ops::load(lowered.get("synth")?).is_err() { + return Some("a certificate that does not meet acc64"); + } + shift_64.then_some("shift 64 to 127") +} + +/// A random `int_synth.json` of 1 to 3 layers and a payload for it: kernels 1 to 4, +/// strides 1 to 3, paddings mostly below the kernel and output paddings mostly below the +/// stride, shifts 0 to 24, weights of up to 2^4, 2^10 or 2^14 (one set in eight up to +/// 2^20, where certificates start to fail), a quarter of them 0, and biases of up to +/// 2^18; latent channels 1 to 3, sides 1 to 4, a quarter of the latents at the ends of +/// their range. The payload's `H` and `W` are the output's sides, or (one case in eight) +/// not; one payload in thirty-two has a reserved byte that is not 0. +fn int_synth_case(r: &mut Rng) -> (Vec, Vec) { + let c0 = 1 + r.below(3) as usize; + let s_log2 = r.below(3) as u8; + let (mut c, mut side) = (c0, 1i64 << s_log2); + let n_layers = 1 + r.below(3) as usize; + let big = r.below(8) == 0; + let mut layers = Vec::new(); + for i in 0..n_layers { + let out = if i + 1 == n_layers && r.below(16) != 0 { 3 } else { 1 + r.below(4) as usize }; + let k = 1 + r.below(4) as usize; + let stride = 1 + r.below(3) as usize; + let pad = if r.below(16) == 0 { r.below(k as u64 + 2) } else { r.below(k as u64) } as usize; + let out_pad = if r.below(16) == 0 { r.below(stride as u64 + 2) } else { r.below(stride as u64) } as usize; + let shift = r.below(25); + let wmax = if big { 1 << 20 } else { [1i64 << 4, 1 << 10, 1 << 14][r.below(3) as usize] }; + let weight: Vec = (0..c * out * k * k).map(|_| if r.below(4) == 0 { 0 } else { r.signed(wmax) }).collect(); + let bias: Vec = (0..out).map(|_| r.signed(1 << 18)).collect(); + layers.push(json!({ + "in_ch": c, "out_ch": out, "k": k, "stride": stride, "pad": pad, "out_pad": out_pad, + "weight": weight, "bias": bias, "shift": shift, "relu": r.below(2) == 1, + })); + side = ((side - 1) * stride as i64 + k as i64 + out_pad as i64 - 2 * pad as i64).max(1); + c = out; + } + let (mut h, mut w) = (side as u32, side as u32); + if r.below(8) == 0 { + h += 1 + r.below(2) as u32; + w = w.saturating_sub(r.below(2) as u32); + } + let mut p = Vec::new(); + p.extend(h.to_le_bytes()); + p.extend(w.to_le_bytes()); + p.extend((c0 as u16).to_le_bytes()); + p.extend([s_log2, if r.below(32) == 0 { 1 + r.below(255) as u8 } else { 0 }]); + let n = c0 << (2 * s_log2); + p.extend((0..n).map(|_| match r.below(8) { + 0 => 0x80, + 1 => 0x7F, + _ => r.signed(127) as i8 as u8, + })); + (serde_json::to_vec(&json!({ "layers": layers })).unwrap(), p) +} + +/// The direct decode, a panic (an overflow-checked build's arithmetic overflow) caught. +fn direct(payload: &[u8], set: &[u8]) -> Option> { + catch_unwind(|| legacy::decode_from_spec(payload, set)).ok() +} + +/// Gate 4 of the migration: the decode through the operator set against the direct +/// decode on random sets and payloads. Outside the planned breaks both decode to the +/// same bytes or both refuse (one code, `payload_malformed`), and the direct decode never +/// panics; under a planned break the decode through the operator set refuses. +#[test] +fn int_synth_random_sets_and_payloads_decode_alike() { + let (mut alike, mut refused, mut breaks) = (0u64, 0u64, std::collections::BTreeMap::<&str, u64>::new()); + let n = cases("int_synth", 3000, |r, seed| { + let (set, pay) = int_synth_case(r); + let new = ops::decode_from_spec(&pay, &set); + let why = || format!("seed {seed}\nset {}\npayload {:02x?}", String::from_utf8_lossy(&set), pay); + match int_synth_planned_break(&pay, &set) { + Some("shift 64 to 127") => *breaks.entry("shift 64 to 127").or_default() += 1, + Some(b) => { + assert!(new.is_err(), "{b}: the decode through the operator set must refuse it\n{}", why()); + *breaks.entry(b).or_default() += 1; + } + None => match (&new, direct(&pay, &set)) { + (Ok(a), Some(Ok(b))) => { + assert!(*a == b, "the two decodes differ\n{}", why()); + alike += 1; + } + (Err(_), Some(Err(_))) => refused += 1, + (_, old) => panic!("new {new:?}, direct {old:?}\n{}", why()), + }, + } + }); + eprintln!("int_synth: {alike} decoded alike, {refused} refused by both, planned breaks {breaks:?}"); + assert!(alike * 2 > n, "the generator must mostly produce decodable cases: {alike} of {n}"); + assert!(n < 1000 || breaks.get("a certificate that does not meet acc64").is_some_and(|&b| b > 0), "no case reached a failing certificate"); +} + +/// One layer of `int_synth.json`: every weight 1, the biases 10, 20 and 30 (or, for +/// fewer channels, their first `out_ch`). +fn layer(in_ch: u64, out_ch: u64, k: u64, stride: u64, pad: u64, out_pad: u64, shift: u64) -> Value { + let bias: Vec = [10, 20, 30].into_iter().chain(std::iter::repeat(0)).take(out_ch as usize).collect(); + json!({ + "in_ch": in_ch, "out_ch": out_ch, "k": k, "stride": stride, "pad": pad, "out_pad": out_pad, + "weight": vec![1; (in_ch * out_ch * k * k) as usize], "bias": bias, "shift": shift, + }) +} + +fn set(layers: Vec) -> Vec { + serde_json::to_vec(&json!({ "layers": layers })).unwrap() +} + +/// A payload for an `h`×`h` output: `C` channels of side 1, reserved byte `reserved`, the +/// latents `lat`. +fn payload(h: u32, c: u16, reserved: u8, lat: &[i8]) -> Vec { + let mut p = Vec::new(); + p.extend(h.to_le_bytes()); + p.extend(h.to_le_bytes()); + p.extend(c.to_le_bytes()); + p.extend([0u8, reserved]); + p.extend(lat.iter().map(|&v| v as u8)); + p +} + +/// The planned breaks of integer-operators §15.3 item 1, each on a payload and set the +/// direct decode decodes (or, for a shift of 64 or more, decodes by build): the +/// classifier names it, and the decode through the operator set refuses it, or, for a +/// shift from 64 to 127, gives the shift its exact meaning. +#[test] +fn int_synth_planned_breaks_are_new_refusals() { + let one = set(vec![layer(1, 3, 1, 1, 0, 0, 0)]); + let five = payload(1, 1, 0, &[5]); + let expect = |b: &str, pay: &[u8], s: &[u8], direct_decodes: bool| { + assert_eq!(int_synth_planned_break(pay, s), Some(b), "{b}: the classifier"); + if direct_decodes { + assert!(matches!(direct(pay, s), Some(Ok(_))), "{b}: the direct decode decodes it"); + } + assert!(ops::decode_from_spec(pay, s).is_err(), "{b}: the decode through the operator set refuses it"); + }; + // The base decodes alike: 5·1 + the bias. + assert_eq!(int_synth_planned_break(&five, &one), None); + assert_eq!(ops::decode_from_spec(&five, &one).unwrap().rgb, [15, 25, 35]); + assert_eq!(direct(&five, &one), Some(ops::decode_from_spec(&five, &one))); + expect("a reserved byte that is not 0", &payload(1, 1, 7, &[5]), &one, true); + // C of 0: no latents; the set's one layer reads no channel, so the direct decode's + // output is the bias. + expect("C of 0", &payload(1, 0, 0, &[]), &set(vec![layer(0, 3, 1, 1, 0, 0, 0)]), true); + // No layers: the direct decode presents the latents themselves, clamped, as the image. + expect("no layers", &payload(1, 3, 0, &[5, -3, 100]), &set(vec![]), true); + // A layer of no channels: the next reads none, so the output is its bias. + expect("out_ch of 0", &five, &set(vec![layer(1, 0, 1, 1, 0, 0, 0), layer(0, 3, 1, 1, 0, 0, 0)]), true); + expect("k or stride of 0", &five, &set(vec![layer(1, 3, 0, 1, 0, 1, 0)]), true); + expect("k or stride of 0", &five, &set(vec![layer(1, 3, 1, 0, 0, 0, 0)]), true); + expect("a field past 32 bits", &five, &set(vec![layer(1, 3, 1, 1 << 32, 0, 0, 0)]), true); + // An uncropped output of 3·8193·8193 cropped to 1×1 that no tap reaches: the direct + // decode forms no uncropped plane, and its output is the bias. + expect("an uncropped output past 2^26", &five, &set(vec![layer(1, 3, 3, 1, 1 << 12, (1 << 13) - 2, 0)]), true); + // The certificate: the falsification set that passes i64 by one unit decodes a + // small latent directly. + let overflow = std::fs::read(wai().join("ops-conformance/sets/overflow_acc64/int_synth.json")).unwrap(); + expect("a certificate that does not meet acc64", &payload(1, 1, 0, &[1]), &overflow, true); + // A shift of 128 or more; the direct decode panics or masks, by build. + expect("a shift of 128 or more", &five, &set(vec![layer(1, 3, 1, 1, 0, 0, 128)]), false); + // A shift from 64 to 127 has its exact meaning: every sum here is below 2^64. + let s64 = set(vec![layer(1, 3, 1, 1, 0, 0, 64)]); + assert_eq!(int_synth_planned_break(&five, &s64), Some("shift 64 to 127")); + assert_eq!(ops::decode_from_spec(&five, &s64).unwrap().rgb, [0, 0, 0]); +} -- 2.54.0 (Apple Git-157) From 93bd919e12b46dbf66b21c75d9bf393d9b4d55a6 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Tue, 13 Oct 2026 07:48:15 -0400 Subject: [PATCH 08/10] D2c M1 gate 5: the fast kernels behind the interpreter, held to it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit wai-ops: interp::Kernels, a host's kernels for some operators (integer- operators §6), and run_with, which asks the host first for each operator and computes it itself where the host declines; the plan, every check and range policy, the state and every refusal stay the interpreter's. interp::rq exposes the requantisation so a host's outputs are the interpreter's. run and run_observed are unchanged (interp::Reference, a host that has none). wai-rs int_ops: Host lends the interpreter this crate's fast convolution and transposed convolution (int_kernels::conv_acc_i128, each output finished with interp::rq) under a fast Policy, and declines under the reference policy, for geometry the kernel does not cover, operands past its bound, or a value that would leave i64. run1 runs through it. Retargeted equivalence, both halves against the interpreter: - per operator (int_ops::equivalence): 400 random convolutions and transposed convolutions, every rounding mode, bias kind and multiplier choice, under every fast policy against the interpreter alone; the fast kernel must actually run; a check range refuses before any kernel; - per decoder (tests/int_synth_ops.rs): every committed int_synth vector, and the fixtures' base, through the lowering under the reference and four fast policies, the same bytes. The inverse-GDN arm the draft had waits for M4. Co-Authored-By: Claude Opus 5.5 --- wai/wai-ops/src/interp.rs | 56 +++++++++- wai/wai-ops/src/lib.rs | 2 +- wai/wai-rs/src/int_ops.rs | 179 +++++++++++++++++++++++++++++- wai/wai-rs/tests/int_synth_ops.rs | 28 +++++ 4 files changed, 256 insertions(+), 9 deletions(-) diff --git a/wai/wai-ops/src/interp.rs b/wai/wai-ops/src/interp.rs index 5383341eb..a8d5c9f91 100644 --- a/wai/wai-ops/src/interp.rs +++ b/wai/wai-ops/src/interp.rs @@ -151,17 +151,60 @@ fn to64(v: i128, at: u32) -> Res { i64::try_from(v).map_err(|_| wide(at)) } +/// A host's own kernels for some operators (§6). A sink may compute an operator's +/// output by other means (another order of the same sums, narrower types where the +/// values fit, threads, vector lanes) provided it gives the same integers; [`run_with`] +/// asks the host first and computes an operator itself only where the host declines. +/// Everything else stays the interpreter's: the plan, every scalar and input check, +/// every tensor's range policy, the state of a sequence and every refusal. +pub trait Kernels { + /// The outputs of `op` on its inputs `ins` (in the operator's input order), each + /// output `k` of the shape `out[k]` (`(C, H, W)`, from the run-time plan); or `None` + /// for the interpreter to compute them. An implementation that returns `Some` + /// returns exactly the integers the interpreter would, so it returns `None` for + /// anything it does not hold to that: an operator whose arithmetic would leave + /// its narrower types, or one the interpreter would refuse. + fn eval(&self, op: &crate::graph::Op, ins: &[&Tensor], out: &[(u32, u32, u32)]) -> Option>; +} + +/// The interpreter's own kernels for every operator: a host that has none. +pub struct Reference; + +impl Kernels for Reference { + #[inline(always)] + fn eval(&self, _: &crate::graph::Op, _: &[&Tensor], _: &[(u32, u32, u32)]) -> Option> { + None + } +} + +/// `rq_o(v)` (§5.1) for output channel `o`: the requantisation a host kernel applies to +/// the exact sum `v` it formed, so that its outputs are the interpreter's. `None` +/// where a value leaves `i128`, which no certified graph forms. +pub fn rq(rq: &Rq, o: usize, v: i128) -> Option { + rq_apply(rq, o, v, 0, &mut Quiet) +} + /// Run one decode of `g`, a graph that passed every load step ([`crate::graph::load`]). /// `inputs` holds the data inputs, in input-table order; each state input is taken /// from `seq` (§10.3). `scalars` holds every scalar's value. Returns the graph's /// outputs, in output-table order. A refused decode leaves `seq` as it was. pub fn run(g: &Loaded, inputs: &[Tensor], scalars: &[i64], seq: &mut Sequence) -> Res> { - run_observed(g, inputs, scalars, seq, &mut Quiet) + run_inner(g, inputs, scalars, seq, &mut Quiet, &Reference) +} + +/// [`run`], with the host's kernels `k` asked first for each operator ([`Kernels`]). +/// The outputs and refusals are [`run`]'s, provided `k` keeps its contract. +pub fn run_with(g: &Loaded, inputs: &[Tensor], scalars: &[i64], seq: &mut Sequence, k: &K) -> Res> { + run_inner(g, inputs, scalars, seq, &mut Quiet, k) } /// [`run`], reporting every named intermediate and producer result to `obs` /// ([`Observe`]). The outputs and refusals are [`run`]'s. pub fn run_observed(g: &Loaded, inputs: &[Tensor], scalars: &[i64], seq: &mut Sequence, obs: &mut O) -> Res> { + run_inner(g, inputs, scalars, seq, obs, &Reference) +} + +fn run_inner(g: &Loaded, inputs: &[Tensor], scalars: &[i64], seq: &mut Sequence, obs: &mut O, k: &K) -> Res> { let g: &Graph = g.graph(); // The inputs, data and state, in input-table order. let n_data = g.inputs.iter().filter(|i| i.kind == 0).count(); @@ -204,7 +247,16 @@ pub fn run_observed(g: &Loaded, inputs: &[Tensor], scalars: &[i64], vals[rec.tensor as usize] = Some(t); } for (i, op) in g.ops.iter().enumerate() { - let outs = eval(g, op, i as u32, &vals, &hw, scalars, obs)?; + let host = { + let ins: Vec<&Tensor> = op.ins.iter().map(|&t| get(&vals, t)).collect(); + let shapes: Vec<(u32, u32, u32)> = + op.outs.iter().map(|&t| (g.tensors[t as usize].channels, hw[t as usize].0 as u32, hw[t as usize].1 as u32)).collect(); + k.eval(op, &ins, &shapes) + }; + let outs = match host { + Some(o) => o, + None => eval(g, op, i as u32, &vals, &hw, scalars, obs)?, + }; for (k, ten) in outs.iter().enumerate() { for &v in &ten.data { obs.see(i as u32, OUT[k], v as i128); diff --git a/wai/wai-ops/src/lib.rs b/wai/wai-ops/src/lib.rs index 5df059449..598c589e1 100644 --- a/wai/wai-ops/src/lib.rs +++ b/wai/wai-ops/src/lib.rs @@ -49,4 +49,4 @@ pub mod xint; pub use bounds::Profile; pub use code::{Code, Refusal, Site}; pub use graph::{Graph, Loaded}; -pub use interp::{Sequence, Tensor, run}; +pub use interp::{Kernels, Sequence, Tensor, run, run_with}; diff --git a/wai/wai-rs/src/int_ops.rs b/wai/wai-rs/src/int_ops.rs index a70183ed2..18744dd26 100644 --- a/wai/wai-rs/src/int_ops.rs +++ b/wai/wai-rs/src/int_ops.rs @@ -5,13 +5,30 @@ //! sink does, from its canonical bytes, under its capability's profile: load steps 1–8 //! of §10.5, the last of which is the bounds pass, whose certificate must meet that //! profile (§8.6). A set whose certificate does not meet it is refused. It then runs -//! each decode through the operator set's interpreter, which owns the run-time plan, -//! every range check and every refusal (§4). +//! each decode through the operator set's interpreter ([`wai_ops::run_with`]), which +//! owns the run-time plan, every range check and every refusal (§4), and lends it this +//! crate's fast kernels ([`crate::int_kernels`]) for the operators they cover (§6), +//! under the current [`crate::int_kernels::Policy`]: +//! +//! - under [`KernelPath::Reference`] the interpreter computes every operator itself, so +//! a decode is the operator set's reference; +//! - under [`KernelPath::Fast`] a convolution and a transposed convolution run on the +//! fast kernels, which form the same exact sums (`int_kernels`' equivalence rule) and +//! finish each output with the operator set's own requantisation +//! ([`wai_ops::interp::rq`]). Where a fast kernel declines (operands past its narrowed +//! types, geometry it does not cover, a value that would leave `i64`), the interpreter +//! computes the operator, and refuses it where it refuses. +//! +//! Either way the integers are the interpreter's: the tests below check it operator by +//! operator on random graphs, and `tests/int_synth_ops.rs` decoder by decoder on every +//! committed vector under every policy. -use wai_ops::graph::{Graph, Loaded}; -use wai_ops::interp::{Sequence, Tensor}; +use wai_ops::graph::{Graph, Loaded, Op, Params}; +use wai_ops::interp::{Kernels, Sequence, Tensor}; use wai_ops::{Profile, Refusal}; +use crate::int_kernels::{policy, ConvGeom, KernelPath}; + /// The profile of every capability that decodes through the operator set /// (`extensions/integer-operators.md` §11): `acc64`, which every lowering of §13 /// declares. A set may meet `acc32` as well; its certificate shows that. @@ -23,8 +40,158 @@ pub fn load(g: &Graph) -> Result { wai_ops::graph::load(&g.to_bytes(), PROFILE) } -/// One decode of a loaded graph with one data input and no state: its first output. +/// One decode of a loaded graph with one data input and no state, through the +/// interpreter with this crate's kernels behind it ([`Host`]): its first output. pub fn run1(g: &Loaded, x: Tensor, scalars: &[i64]) -> Result { - let mut out = wai_ops::run(g, &[x], scalars, &mut Sequence::new())?; + let mut out = wai_ops::run_with(g, &[x], scalars, &mut Sequence::new(), &Host)?; Ok(out.swap_remove(0)) } + +/// This crate's fast kernels, lent to the interpreter (see the module docs). +pub struct Host; + +impl Kernels for Host { + fn eval(&self, op: &Op, ins: &[&Tensor], out: &[(u32, u32, u32)]) -> Option> { + if policy().path != KernelPath::Fast { + return None; + } + let x = ins.first()?; + let &(oc, oh, ow) = out.first()?; + let (cin, h, w) = (x.c as usize, x.h as usize, x.w as usize); + let u = |v: &u32| *v as usize; + let g = match &op.p { + Params::Conv { cout, kh, kw, sh, sw, ph, pw, groups, w: wt, .. } => { + ConvGeom::conv(cin, h, w, u(cout), u(groups), u(kh), u(kw), u(sh), u(sw), u(ph), u(pw), x.data.len(), wt.len())? + } + Params::ConvT { cout, kh, kw, sh, sw, ph, pw, oph, opw, w: wt, .. } => { + ConvGeom::transposed(cin, h, w, u(cout), u(kh), u(kw), u(sh), u(sw), u(ph), u(pw), u(oph), u(opw), x.data.len(), wt.len())? + } + _ => return None, + }; + let (Params::Conv { rq, w: wt, .. } | Params::ConvT { rq, w: wt, .. }) = &op.p else { return None }; + if (g.cout, g.ho, g.wo) != (oc as usize, oh as usize, ow as usize) { + return None; + } + Some(vec![Tensor::new(oc, oh, ow, conv(&g, &x.data, wt, rq)?)]) + } +} + +/// A (transposed) convolution on the fast kernel, each output finished with the +/// operator set's requantisation. `None` where the kernel declines, or where a +/// requantised value would leave `i64`, which no certified graph forms: the interpreter +/// then computes the operator, and refuses it. +fn conv(g: &ConvGeom, x: &[i64], wt: &[i64], rq: &wai_ops::graph::Rq) -> Option> { + use std::sync::atomic::{AtomicBool, Ordering}; + let wide = AtomicBool::new(false); + let out = crate::int_kernels::conv_acc_i128(g, x, wt, |oc, acc| match wai_ops::interp::rq(rq, oc, acc).map(i64::try_from) { + Some(Ok(v)) => v, + _ => { + wide.store(true, Ordering::Relaxed); + 0 + } + })?; + (!wide.load(Ordering::Relaxed)).then_some(out) +} + +/// Gate 5 of each decoder's migration, the per-operator half: the fast kernels behind +/// the interpreter against the interpreter alone, on random graphs of one convolution or +/// transposed convolution. Every output element must be the same integer under every +/// fast policy, and a refusal the same refusal. The whole-decoder half is +/// `tests/int_synth_ops.rs`. +#[cfg(test)] +mod equivalence { + use super::*; + use crate::int_kernels::testkit::{fast_conv_runs, Rng, FAST}; + use crate::int_kernels::{with_policy, Policy}; + use wai_ops::graph::{opcode, BiasKind, InputRec, Round, Rq, TensorRec, CAP}; + + fn graph(x: TensorRec, code: u8, cout: u32, p: Params) -> Graph { + Graph { + max_elems: CAP as u32, + scalars: vec![], + inputs: vec![InputRec { tensor: 0, max_h: 0, max_w: 0, kind: 0, src: 0, init: 0 }], + outputs: vec![1], + tensors: vec![x, TensorRec::derived(cout)], + ops: vec![Op { code, ins: vec![0], outs: vec![1], p }], + } + } + + fn rq(g: &mut Rng, cout: u32, round: Round, bias_kind: BiasKind, mult: bool) -> Rq { + let n = cout as usize; + Rq { + round, + bias_kind, + m_kind: if mult { 2 } else { 0 }, + s_kind: 1, + m_bytes: if mult { 8 } else { 0 }, + b_bytes: if bias_kind == BiasKind::None { 0 } else { 8 }, + mult: if mult { g.vec(n, 1 << 20, 0) } else { vec![] }, + shift: vec![g.below(40) as u8], + bias: if bias_kind == BiasKind::None { vec![] } else { g.vec(n, 1 << 24, 10) }, + } + } + + /// Run `g` on `x` under the interpreter alone and under every fast policy; the + /// results must agree. Returns whether the graph loaded and how many decodes the + /// fast kernel computed. + fn agree(g: &Graph, x: &Tensor) -> (bool, u64) { + let Ok(l) = load(g) else { return (false, 0) }; + let want = with_policy(Policy::reference(), || run1(&l, x.clone(), &[])); + let mut fast = 0; + for p in FAST { + let (got, runs) = fast_conv_runs(|| with_policy(p, || run1(&l, x.clone(), &[]))); + assert_eq!(got, want, "{p:?} on operator {}", g.ops[0].code); + fast += runs; + } + (true, fast) + } + + #[test] + fn convolutions_equal_the_interpreter() { + let mut r = Rng(0x0D2C_0001); + let (mut loaded, mut fast) = (0, 0); + for case in 0..400 { + let (k, s) = (1 + r.below(5), 1 + r.below(3)); + let (cin, cout) = (1 + r.below(5) as u32, 1 + r.below(6) as u32); + let (h, w) = (1 + r.below(6) as u32, 1 + r.below(8) as u32); + let round = [Round::Floor, Round::HalfUp, Round::HalfEven][r.below(3)]; + let bias_kind = [BiasKind::None, BiasKind::Pre, BiasKind::Post][r.below(3)]; + let with_mult = r.below(2) == 1; + let q = rq(&mut r, cout, round, bias_kind, with_mult); + let xmax = [127i64, 1 << 12, 1 << 20][r.below(3)]; + let wt = r.vec(cin as usize * cout as usize * k * k, 1 << 14, 10); + let pad = r.below(k) as u32; + let (k, s) = (k as u32, s as u32); + let (code, p) = if case % 2 == 1 { + let op = if s > 1 { r.below(s as usize) as u32 } else { 0 }; + (opcode::CONV_TRANSPOSE, Params::ConvT { cout, kh: k, kw: k, sh: s, sw: s, ph: pad, pw: pad, oph: op, opw: op, w_bytes: 8, rq: q, w: wt }) + } else { + (opcode::CONV, Params::Conv { cout, kh: k, kw: k, sh: s, sw: s, ph: pad, pw: pad, groups: 1, w_bytes: 8, rq: q, w: wt }) + }; + let g = graph(TensorRec::check(cin, -xmax, xmax), code, cout, p); + let x = Tensor::new(cin, h, w, r.vec((cin * h * w) as usize, xmax, 30)); + let (l, f) = agree(&g, &x); + loaded += l as u32; + fast += f; + } + assert!(loaded > 300, "only {loaded} of 400 random graphs loaded"); + assert!(fast > 600, "the fast kernel ran only {fast} times"); + } + + /// A value outside an input's `check` range is refused by the interpreter before any + /// kernel runs, under every policy alike. + #[test] + fn a_check_range_refuses_before_the_kernel() { + let p = Params::Conv { + cout: 1, kh: 1, kw: 1, sh: 1, sw: 1, ph: 0, pw: 0, groups: 1, w_bytes: 1, + rq: Rq::plain(Round::Floor, 0), w: vec![3], + }; + let g = graph(TensorRec::check(1, -10, 10), opcode::CONV, 1, p); + let l = load(&g).unwrap(); + for pol in [Policy::reference(), Policy::fast(1), Policy::fast(0)] { + assert_eq!(with_policy(pol, || run1(&l, Tensor::new(1, 1, 2, vec![10, -10]), &[])).unwrap().data, vec![30, -30]); + let r = with_policy(pol, || run1(&l, Tensor::new(1, 1, 2, vec![10, 11]), &[])).unwrap_err(); + assert_eq!(r.code, wai_ops::Code::FieldRange, "{pol:?}"); + } + } +} diff --git a/wai/wai-rs/tests/int_synth_ops.rs b/wai/wai-rs/tests/int_synth_ops.rs index 096663f71..954c27d4d 100644 --- a/wai/wai-rs/tests/int_synth_ops.rs +++ b/wai/wai-rs/tests/int_synth_ops.rs @@ -284,3 +284,31 @@ fn the_latent_range_refuses_one_past_each_end() { assert_eq!((r.code, r.at), (Code::FieldRange, Site::Input(0)), "{v}"); } } + +/// Gate 5 of the migration, the whole-decoder half: the decode through the lowering +/// under the reference kernels (the interpreter computes every operator) and under each +/// fast policy (the transposed convolutions on this crate's fast kernels, serial and +/// threaded) gives the same bytes on every committed vector, and on the fixtures' base +/// case. The per-operator half is `int_ops`' equivalence tests. +#[test] +fn every_kernel_policy_decodes_through_the_lowering_alike() { + use wai::int_kernels::{with_policy, Policy}; + const POLICIES: [Policy; 5] = [Policy::reference(), Policy::fast(1), Policy::fast(2), Policy::fast(5), Policy::fast(0)]; + let mut cases: Vec<(String, Vec, Vec)> = json("neural-conformance/expected_hashes.json") + .as_object() + .unwrap() + .keys() + .map(|name| { + let dir = format!("neural-conformance/vectors/{name}"); + (name.clone(), read(&format!("{dir}/latent.bin")), read(&format!("{dir}/int_synth.json"))) + }) + .collect(); + cases.push(("the fixtures' base".into(), payload(2, 2, 1, 0, 9, 0), set(vec![layer(1, 3, 2, 2, 0, 3)]))); + for (name, p, s) in &cases { + let want = with_policy(POLICIES[0], || wai::int_synth::ops::decode_from_spec(p, s)).unwrap_or_else(|e| panic!("{name}: {e}")); + for pol in &POLICIES[1..] { + let got = with_policy(*pol, || wai::int_synth::ops::decode_from_spec(p, s)); + assert!(got.as_ref() == Ok(&want), "{name}: {pol:?} differs from the interpreter alone"); + } + } +} -- 2.54.0 (Apple Git-157) From a6c61f3ce010cf0b4b126021041fe14530c7155a Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Tue, 13 Oct 2026 09:52:36 -0400 Subject: [PATCH 09/10] D2c M1 gate 6: int_synth decodes through wai-ops; the direct decode is test-only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The switch. int_synth::decode_from_spec (and so decode, decode_bytes, the int_payload dispatch, the neural sink and the browser crate) decodes through the integer operator set: the payload checked, the set lowered by int_synth/1, loaded under acc64 with its bounds certificate, run by the interpreter with the fast kernels behind it. The direct decode (int_synth::legacy, its serde specs, conv_transpose2d, reference_output_side and the scalar reference) is behind cfg(any(test, feature = "legacy_int")). byte-exact-conformance declares legacy_int; tests/int_synth_ops.rs, which compares the two, requires it. Every committed output is unchanged: the fast-kernel steps' tests in both profiles (int_kernels_vectors' corpus under every policy, int_payload, int_synth_ops, the differential), the int-synth corpus loop, byte-exact's goldens, and the browser crate's wasm32 decode of the corpus under Node. The planned breaks take effect. tests/int_kernels_geometry.rs: a stride past 32 bits, an uncropped output past 2^26 (pad 2^12; pad 2^9 still decodes to the bias) and the u32 and 2^62 fields of the size-overflow case are refused under every policy and in every build, where the direct decode returned or panicked. O12 (tests/int_synth_ops.rs): every committed int_synth set has layers, each with out_ch, k and stride at least 1, fields below 2^32 and a shift of at most 63, and lowers and loads under acc64 (overflow_acc64, the falsification set, alone fails); every committed payload has a reserved byte of 0, C >= 1, C*S*S <= 2^26, its exact length, and decodes. Text: integer-payloads §13 (a non-empty layers list, fields below 2^32, out_ch, k and stride at least 1; the reference decoder decodes through the lowering), integer-operators §13.1's load checks (at least one layer, the 32-bit fields, out_ch >= 1), §15.3 (no layers, out_ch 0, a field of 2^32 or more), its status note, SPEC §8 *Planned breaks*, and the CHANGELOG. Co-Authored-By: Claude Opus 5.5 --- wai/CHANGELOG.md | 24 +++++++ wai/SPEC.md | 12 ++++ wai/byte-exact-conformance/Cargo.toml | 7 +++ wai/extensions/integer-operators.md | 9 ++- wai/extensions/integer-payloads.md | 23 ++++--- wai/wai-rs/Cargo.toml | 2 +- wai/wai-rs/src/int_synth.rs | 32 +++++++--- wai/wai-rs/tests/int_kernels_geometry.rs | 74 ++++++++++++---------- wai/wai-rs/tests/int_synth_ops.rs | 80 ++++++++++++++++++++++-- 9 files changed, 204 insertions(+), 59 deletions(-) diff --git a/wai/CHANGELOG.md b/wai/CHANGELOG.md index 336fe5c54..8d4f99bf5 100644 --- a/wai/CHANGELOG.md +++ b/wai/CHANGELOG.md @@ -8,6 +8,30 @@ wire format may change and such changes are recorded here. ### `wai.neural.int_synth` through the integer operator set +The first capability whose reference decoder decodes through `wai-ops/1`. + +- **The decode.** `int_synth::decode_bytes` checks the payload + ([integer-payloads](extensions/integer-payloads.md) §13), lowers the set by + `int_synth/1` ([integer-operators](extensions/integer-operators.md) §13.1), loads the + graph under `acc64` (load steps 1–8, the last being the bounds certificate) and runs it + through the operator set's interpreter, with the fast transposed-convolution kernel + behind it (`wai-rs/src/int_ops.rs`). Every committed output is unchanged, byte for + byte, on every platform the byte-equality matrix covers and in the browser crate, and + every committed refusal keeps its code (`payload_malformed`). The direct decode it + replaced is kept, test-only (feature `legacy_int`): `tests/int_ops_differential.rs` + decodes random sets and payloads both ways and requires the same output or both + refusals outside the planned breaks, and `wai-ops-differential.yml` runs it for an + hour in each build profile every night. +- **Planned breaks** (SPEC §8, integer-operators §15.3): new refusals of input no + committed corpus contains. `wai.neural.int_synth` refuses a payload whose reserved byte + is not 0, whose `C` is 0 or whose latents exceed `2^26` values; a set with no layers, + with a layer whose `out_ch`, `k` or `stride` is 0, with a field of `2^32` or more, or + with a shift of 128 or more; a set or payload a cap refuses (a tensor, or a transposed + convolution's uncropped output, past `2^26` elements); and a set whose certificate does + not meet `acc64`. A shift from 64 to 127, which a build used to mask or panic on, now + has its exact meaning. A scan of every committed `int_synth` set and payload finds + none affected. The text now states the two rules the lowering already made and the + payload text did not: at least one layer, and `out_ch ≥ 1`. - **Its `ops` member in the registry** ([integer-operators](extensions/integer-operators.md) §11). `registry/capabilities.json` gives `wai.neural.int_synth` the member `{"set": "wai-ops/1", "profile": "acc64", "subset": "full", "lowering": diff --git a/wai/SPEC.md b/wai/SPEC.md index 4c6432447..7330ce110 100644 --- a/wai/SPEC.md +++ b/wai/SPEC.md @@ -2345,6 +2345,18 @@ envelope that uses any string a later revision registers. envelope that carried it as a `WAI1` `capability` without `refines`, as a `fallback`, or as a selectable rendition or a companion, and one that bound a companion to it. Such envelopes still parse. +- **Planned breaks.** A capability that comes to decode through the integer operator + set ([integer-operators](extensions/integer-operators.md) §13) refuses some inputs it + decoded before, or decoded differently by build. Each is a new refusal of input no + committed corpus contains, and no committed output changes; integer-operators §15.3 + states them, and the changelog records each when it lands. Landed in this revision: + - `wai.neural.int_synth`: a payload whose reserved byte is not 0, or whose `C` is 0, + or whose latents exceed `2^26` values; a set with no layers, with a layer whose + `out_ch`, `k` or `stride` is 0, with a field of `2^32` or more, or with a shift of + 128 or more; a set or payload that a cap refuses (a tensor, or a transposed + convolution's uncropped output, past `2^26` elements); and a set whose certificate + does not meet `acc64`. A shift from 64 to 127, which a build used to mask or panic + on, now has its exact meaning. --- diff --git a/wai/byte-exact-conformance/Cargo.toml b/wai/byte-exact-conformance/Cargo.toml index fdda025e1..99dae5607 100644 --- a/wai/byte-exact-conformance/Cargo.toml +++ b/wai/byte-exact-conformance/Cargo.toml @@ -25,3 +25,10 @@ sha2 = "0.10" # The integer operator set the included decoders run their networks through # (`wai-rs/src/int_ops.rs`, included at the crate root). no_std, no dependencies. wai-ops = { path = "../wai-ops" } + +[features] +# wai-rs's test-only switch for the integer decoders' direct decodes (`int_synth::legacy`), +# declared so the included source's `cfg` names a known feature. The goldens here decode +# as a sink does, through the integer operator set; the crate's own tests compile the +# direct decode's unit tests under `cfg(test)`. +legacy_int = [] diff --git a/wai/extensions/integer-operators.md b/wai/extensions/integer-operators.md index 57aae5309..5bbf742de 100644 --- a/wai/extensions/integer-operators.md +++ b/wai/extensions/integer-operators.md @@ -14,7 +14,7 @@ > > **What a reader can build from this text.** With the payload extensions it cites, an implementation of this text decodes `wai.video.int_motion`, `wai.audio.int_codec`, `wai.splat.int_codec`, `wai.neural.int_synth`, `wai.neural.int_hyper`, `wai.video.int_hyper`, `wai.neural.int_mlicpp` and `wai.{image,video,audio,splat}.int_refine`, and reproduces the symbols and scale indices that `wai.image.jpegai` entropy-decodes (§13.6.1). The payload text four of these depend on, `entropy.json`, the `int_synth` payload and the `WIM1` payload with its `WMB1` bundle, is in [integer-payloads](integer-payloads.md), and §15.2 says where. The integer renderer of `wai.world.model` and the other integer networks of `wai.image.jpegai` are informative (§13.5, §13.6.2). > -> Reference implementation: the `wai-ops` crate (`wai/wai-ops`): parse, lowering, interpreter, stage operators and bounds. Tool: `wai-verify bounds`. Conformance: `wai/ops-conformance/`. This Draft precedes all three. Until they land, each capability's reference implementation is the decoder its payload extension names, and §15.3 lists where this text and that decoder differ. +> Reference implementation: the `wai-ops` crate (`wai/wai-ops`): parse, lowering, interpreter, stage operators and bounds. Tool: `wai-verify bounds`. Conformance: `wai/ops-conformance/`. A capability's reference decoder decodes through this operator set from the revision that registers the capability's `ops` member (§11); in this revision, `wai.neural.int_synth`. Until then, a capability's reference implementation is the decoder its payload extension names, and §15.3 lists where this text and that decoder differ. > > The keywords **MUST**, **MUST NOT**, **SHOULD** and **MAY** are as defined in RFC 2119 and RFC 8174. @@ -1203,9 +1203,11 @@ Sizes of 0 mean that the input's size is set per decode (§10.2). A driver plans Then `clamp(0, 255)`. The graph's one output is the clamp's. **Load checks.** These are in addition to the payload's own checks, and every refusal is `payload_malformed`: +- there is at least one layer; - the layers chain: the first `in_ch` is `C`, and each later `in_ch` is the previous `out_ch`; - the parameter lengths are exact; -- `k ≥ 1`, `stride ≥ 1` and `shift ≤ 127`; +- `in_ch`, `out_ch`, `k`, `stride`, `pad` and `out_pad` are below `2^32`, the width of their `WHG1` fields (§10.4); +- `out_ch ≥ 1`, `k ≥ 1`, `stride ≥ 1` and `shift ≤ 127`; - the caps (§3); - the certificate meets `acc64`. @@ -1521,7 +1523,8 @@ No committed output changes, and no committed refusal fixture changes its code. 1. **`wai.neural.int_synth`.** - A set whose shift is 128 or more, whose `k` or `stride` is 0, or whose certificate fails `acc64`, is refused. - - A payload whose reserved byte is not 0, or whose `C` is 0, is refused. The reference decoder does not check either today. + - A set with no layers, or with a layer whose `out_ch` is 0, is refused, and so is one with a field of 2^32 or more. The direct decoder decoded each of these where the shapes allowed: no layers presented the latents themselves as the image, and a layer of no channels left the next with only its bias. + - A payload whose reserved byte is not 0, or whose `C` is 0, is refused. The direct decoder checked neither. - A shift from 64 to 127 now has its exact meaning. - The caps apply. 2. **`wai.world.model`'s integer renderer** refuses values outside its lowered `check` ranges, and a set whose `afrac` is outside `1..=30` or differs between its two networks. diff --git a/wai/extensions/integer-payloads.md b/wai/extensions/integer-payloads.md index 46b00e96e..ff6c367c2 100644 --- a/wai/extensions/integer-payloads.md +++ b/wai/extensions/integer-payloads.md @@ -600,9 +600,10 @@ SPEC §5 keeps this capability's payload format; this section writes it down. The format has no magic and no version. The payload is exactly `12 + C·S·S` bytes, and `C·S·S ≤ 2^26`. -The set is one file, `int_synth.json`: a JSON object whose member `layers` is an array -of layer objects. A layer has `in_ch`, `out_ch`, `k`, `stride` and `pad`, each a -non-negative integer; `out_pad`, a non-negative integer, 0 when absent; `weight`, +The set is one file, `int_synth.json`: a JSON object whose member `layers` is a +non-empty array of layer objects. A layer has `in_ch`, `out_ch`, `k`, `stride` and +`pad`, each a non-negative integer below `2^32`, with `out_ch`, `k` and `stride` at +least 1; `out_pad`, a non-negative integer below `2^32`, 0 when absent; `weight`, `in_ch·out_ch·k·k` integers each fitting `i32`, in the layout `[in_ch][out_ch][k][k]`; `bias`, `out_ch` integers each fitting `i32`; `shift`, in `0..=127`, 0 when absent; and `relu`, a boolean, false when absent. Other members are ignored. @@ -611,13 +612,15 @@ The decode is [integer-operators](integer-operators.md) §13.1's graph on the la Its output must be `[3, H, W]`, and it is presented as row-major HWC bytes. Every refusal of the payload, the set or the decode is `payload_malformed` (§8). -**Checks that wait for the lowering.** The reference decoder -(`int_synth::decode_bytes`) makes every check of this section except these, which it -makes once the capability decodes through its lowering -([integer-operators](integer-operators.md) §15.3): a reserved byte of 0; `C ≥ 1`; the -caps; `k ≥ 1`, `stride ≥ 1` and a shift of at most 127; and the certificate. Until -then it decodes a payload or set that fails one of them where it can, and with a shift -from 64 to 127 its result depends on how it was built. +The reference decoder (`int_synth::decode_bytes`) decodes through the lowering. It +checks the payload first: its header, a reserved byte of 0, `C ≥ 1`, `C·S·S ≤ 2^26` and +its length. It then lowers the set and loads the graph under `acc64`, which makes the +set's checks ([integer-operators](integer-operators.md) §13.1: at least one layer, the +layers chain, the lengths, fields below `2^32`, `out_ch`, `k` and `stride` at least 1, +a shift of at most 127, the caps and the certificate), and runs it. A shift from 64 to +127 has its exact meaning. Several of these checks are new refusals of input no +committed corpus contains ([integer-operators](integer-operators.md) §15.3, SPEC §8 +*Planned breaks*). ## 14. `WIM1` and `WMB1` — `wai.neural.int_mlicpp` diff --git a/wai/wai-rs/Cargo.toml b/wai/wai-rs/Cargo.toml index 26bfab576..7bbb1c1ee 100644 --- a/wai/wai-rs/Cargo.toml +++ b/wai/wai-rs/Cargo.toml @@ -754,7 +754,7 @@ required-features = ["legacy_int"] [[test]] name = "int_synth_ops" path = "tests/int_synth_ops.rs" -required-features = ["neural_int"] +required-features = ["legacy_int"] [[test]] name = "int_mlicpp" diff --git a/wai/wai-rs/src/int_synth.rs b/wai/wai-rs/src/int_synth.rs index 884a1b5ab..2ac117a7d 100644 --- a/wai/wai-rs/src/int_synth.rs +++ b/wai/wai-rs/src/int_synth.rs @@ -35,14 +35,16 @@ //! bias:[i32; out_ch], shift:u32, relu:bool }, … ] } //! The final layer MUST output 3 channels at H×W; values are clamped to [0,255]. //! -//! Two decodes of the same payload and set: [`legacy`], the direct path (each layer's -//! transposed convolution, bias, shift and ReLU computed here), and [`ops`], the graph -//! `synth` of the set's lowering `int_synth/1` (`extensions/integer-operators.md` -//! §13.1), loaded under `acc64` with its bounds certificate and run through the integer -//! operator set ([`crate::int_ops`]). [`decode_from_spec`] is [`legacy`]'s. +//! The decode is [`ops`]'s: the graph `synth` of the set's lowering `int_synth/1` +//! (`extensions/integer-operators.md` §13.1), loaded under `acc64` with its bounds +//! certificate and run through the integer operator set ([`crate::int_ops`]). The direct +//! decode it replaced is kept, test-only (feature `legacy_int`), as `legacy`: each +//! layer's transposed convolution, bias, shift and ReLU computed here. The differential +//! tests decode random sets and payloads both ways (`tests/int_ops_differential.rs`). use std::path::Path; +#[cfg(any(test, feature = "legacy_int"))] use serde::Deserialize; const HEADER: usize = 12; @@ -72,6 +74,7 @@ pub struct IntImage { pub height: u32, } +#[cfg(any(test, feature = "legacy_int"))] #[derive(Debug, Clone, Deserialize)] struct LayerSpec { in_ch: usize, @@ -93,6 +96,7 @@ struct LayerSpec { relu: bool, } +#[cfg(any(test, feature = "legacy_int"))] #[derive(Debug, Clone, Deserialize)] struct SynthSpec { layers: Vec, @@ -103,6 +107,7 @@ struct SynthSpec { /// ([`crate::int_kernels`]) unless the current [`crate::int_kernels::Policy`] selects /// the reference; both give the same integer for every output element, and every /// error case is left to the reference so errors are reported exactly as before. +#[cfg(any(test, feature = "legacy_int"))] fn conv_transpose2d( input: &[i64], cin: usize, @@ -143,6 +148,7 @@ fn conv_transpose2d( /// where the reference's size arithmetic fails. `ConvGeom::transposed`'s bound on /// the uncropped plane already keeps every term far below `i64::MAX`; this check /// states the rule where the reference's arithmetic is, independent of that cap. +#[cfg(any(test, feature = "legacy_int"))] fn reference_output_side(n: usize, l: &LayerSpec) -> Option { let v = (n as i64) .checked_sub(1)? @@ -154,6 +160,7 @@ fn reference_output_side(n: usize, l: &LayerSpec) -> Option { } /// The scalar reference kernel the fast path is checked against, kept verbatim. +#[cfg(any(test, feature = "legacy_int"))] mod reference { use super::{IntSynthError, LayerSpec}; @@ -265,12 +272,21 @@ pub fn decode_bytes(payload: &[u8], spec_json: &[u8]) -> Result Result { - legacy::decode_from_spec(payload, spec_json) + ops::decode_from_spec(payload, spec_json) } -/// The direct decode: the payload and set read here, each layer's transposed -/// convolution, bias, shift and ReLU computed by [`conv_transpose2d`]. +/// The direct decode, as the decoder ran before it decoded through the integer operator +/// set: the payload and set read here, each layer's transposed convolution, bias, shift +/// and ReLU computed by [`conv_transpose2d`]. Test-only (`legacy_int`): the differential +/// tests decode both ways, and the two differ only on the planned breaks of +/// `extensions/integer-operators.md` §15.3. +#[cfg(any(test, feature = "legacy_int"))] pub mod legacy { use super::*; diff --git a/wai/wai-rs/tests/int_kernels_geometry.rs b/wai/wai-rs/tests/int_kernels_geometry.rs index 8d7b6e4d7..3aa4479e6 100644 --- a/wai/wai-rs/tests/int_kernels_geometry.rs +++ b/wai/wai-rs/tests/int_kernels_geometry.rs @@ -14,9 +14,12 @@ //! The reference computes each of the first three in a handful of operations. Every //! policy must return the same integers (checked against a hand computation), and //! the fast path must not overflow, or allocate or loop in proportion to the stride -//! or the padding, where the reference does not. In the last, the reference panics -//! (or, for int_synth with overflow checks off, returns), and every policy must do -//! the same. The int_audio 1-D transposed convolution has the +//! or the padding, where the reference does not. In the last, the reference panics, +//! and every policy must do the same. int_synth decodes through its lowering +//! (integer-operators §13.1), whose fields are 32 bits and whose run-time plan caps +//! every size it forms at 2^26: where a case is past either, every policy refuses it, +//! one of the planned breaks of integer-operators §15.3. The int_audio 1-D transposed +//! convolution has the //! same stride check beside its kernel (`int_audio` unit tests). CI runs this file //! beside `int_kernels_vectors` on every `wai-conformance` leg, with overflow checks //! on and off. @@ -40,13 +43,6 @@ fn same_under_every_policy(what: &str, f: impl F want } -/// Whether this build traps on integer overflow. Detected by performing one, since -/// `cfg!(debug_assertions)` only usually coincides with `overflow-checks`. -#[cfg(target_pointer_width = "64")] -fn overflow_checks() -> bool { - std::panic::catch_unwind(|| std::hint::black_box(i64::MAX) + std::hint::black_box(1)).is_err() -} - /// Run under every policy, catching a panic. The outcome (the value, or that the /// call panicked) must be the same under every policy. Returns the reference /// kernels' outcome, `None` for a panic. The panic message is not compared: a fast @@ -129,8 +125,14 @@ fn a_1x1_input_upsampled_by_any_stride() { let got = same_under_every_policy(&format!("int_synth, stride {s}"), || { wai::int_synth::decode_from_spec(&payload, &spec).map(|img| img.rgb) }); - // centre taps 5, 14, 23 of the three output channels, times 5 - assert_eq!(got, Ok(vec![25, 70, 115]), "int_synth, stride {s}"); + if s <= u32::MAX as usize { + // centre taps 5, 14, 23 of the three output channels, times 5 + assert_eq!(got, Ok(vec![25, 70, 115]), "int_synth, stride {s}"); + } else { + // int_synth decodes through its lowering, whose `WHG1` fields are 32 bits: a + // stride past them is refused, a planned break. + assert!(got.is_err(), "int_synth, stride {s}: a stride past 32 bits is refused"); + } } } @@ -169,11 +171,22 @@ fn a_regular_conv_whose_stride_exceeds_its_padded_input() { fn padding_far_beyond_the_kernel() { let p = 1usize << 12; // Transposed: pad 2^12 with output padding 2^13 − 2 restores a 1×1 output that - // no tap reaches (it sits 2^12 rows into the uncropped plane), so it is the bias. + // no tap reaches (it sits 2^12 rows into the uncropped plane). int_synth decodes + // through its lowering, whose run-time plan caps the uncropped output a transposed + // convolution forms at 2^26 elements (integer-operators §5.3); this one is + // 3·8193·8193, so every policy refuses it before anything is allocated, a planned + // break. With pad 2^9 and output padding 2^10 − 2 the uncropped output, 3·1025·1025, + // is within the cap, and the decode gives the bias. let (payload, spec) = (synth_payload(5), synth_spec(1, p, 2 * p - 2, [10, 20, 30])); let got = same_under_every_policy("int_synth, pad 2^12", || { wai::int_synth::decode_from_spec(&payload, &spec).map(|img| img.rgb) }); + assert!(got.is_err(), "int_synth, pad 2^12: the uncropped output is past the cap"); + let q = 1usize << 9; + let (payload, spec) = (synth_payload(5), synth_spec(1, q, 2 * q - 2, [10, 20, 30])); + let got = same_under_every_policy("int_synth, pad 2^9", || { + wai::int_synth::decode_from_spec(&payload, &spec).map(|img| img.rgb) + }); assert_eq!(got, Ok(vec![10, 20, 30])); // Regular: a 1×1 input, 1×1 kernel, pad 2^12, stride 2^13 — a 2×2 output whose // samples all fall in the padding, so each is the bias. @@ -204,10 +217,11 @@ fn synth_payload_2x2(h: u32, w: u32, latents: [i8; 4]) -> Vec { // Transposed convolutions of an input with two or more rows and columns, whose // stride and padding make the reference's own size arithmetic overflow. The // int_hyper, int_audio and JPEG-AI references size an uncropped plane -// `cout·(ho + 2·pad)·(wo + 2·pad)` in unchecked `usize` arithmetic; the int_synth -// reference computes the output size in unchecked `i64`. With overflow checks on, -// the reference panics on that arithmetic. With them off it wraps and, for the -// uncropped plane, then indexes out of the wrapped buffer. A fast policy must have +// `cout·(ho + 2·pad)·(wo + 2·pad)` in unchecked `usize` arithmetic. With overflow +// checks on, the reference panics on that arithmetic. With them off it wraps and then +// indexes out of the wrapped buffer. (int_synth decodes through its lowering, whose +// plan forms every size in checked 64-bit arithmetic and caps it: it refuses these, +// below.) A fast policy must have // the same outcome: it may not return a value where the reference panics. A 1×1 // input cannot show this, since `(h − 1)·stride` is 0. The int_audio 1-D form is // tested beside its kernel. @@ -265,29 +279,25 @@ fn int_hyper_convt_where_the_reference_plane_size_overflows() { #[cfg(target_pointer_width = "64")] #[test] fn int_synth_where_the_reference_output_size_overflows_i64() { - // 2×2 latent, k = 3, stride = pad = out_pad = 2^62: a 3×3 output. The reference - // computes (2 − 1)·2^62 + 3 + 2^62 − 2·2^62 in `i64`, which overflows (2^63 + 3) - // although every `usize` step fits. With overflow checks on it panics; with them - // off the wrapped sum is still 3 and the decode returns. Only latent (1, 1) - // reaches the output, through every tap. + // 2×2 latent, k = 3, stride = pad = out_pad = 2^62: a 3×3 output. The direct + // decode computed (2 − 1)·2^62 + 3 + 2^62 − 2·2^62 in `i64`, which overflows + // (2^63 + 3) although every `usize` step fits: it panicked with overflow checks on + // and, with them off, returned the wrapped size's output. int_synth decodes through + // its lowering (integer-operators §13.1), whose `WHG1` fields are 32 bits: in every + // build and under every policy the decode is refused, not panicked. A planned break. let s = 1usize << 62; let (payload, spec) = (synth_payload_2x2(3, 3, [1, 2, 3, 4]), synth_spec(s, s, s, [0, 0, 0])); let got = same_outcome_under_every_policy("int_synth, 2×2 latent, stride = pad = out_pad = 2^62", || { wai::int_synth::decode_from_spec(&payload, &spec).map(|img| img.rgb) }); - if overflow_checks() { - assert_eq!(got, None, "the reference panics on its i64 output size"); - } else { - // rgb[(3y + x)·3 + c] = 4 · weight[c][y][x] = 4 · (9c + 3y + x + 1) - let want: Vec = (0..9).flat_map(|px| (0..3).map(move |c| (4 * (9 * c + px + 1)) as u8)).collect(); - assert_eq!(got, Some(Ok(want)), "the wrapped output size is 3"); - } + assert!(matches!(got, Some(Err(_))), "refused, not panicked: {got:?}"); - // The u32 fields: no `i64` step overflows, and int_synth's reference allocates - // no uncropped plane, so it returns. A 4×4 output that no tap reaches: the bias. + // The u32 fields fit `WHG1`, but the uncropped side (2 − 1)·(2^32 − 1) + 3 is past + // the run-time plan's cap of 2^26: refused before anything is allocated (the direct + // decode returned the bias), a planned break. let (payload, spec) = (synth_payload_2x2(4, 4, [1, 2, 3, 4]), synth_spec(S32, P32, 0, [10, 20, 30])); let got = same_outcome_under_every_policy("int_synth, 2×2 latent, u32 stride and pad", || { wai::int_synth::decode_from_spec(&payload, &spec).map(|img| img.rgb) }); - assert_eq!(got, Some(Ok([10u8, 20, 30].repeat(16))), "u32 fields: the bias alone"); + assert!(matches!(got, Some(Err(_))), "u32 fields: refused by the cap: {got:?}"); } diff --git a/wai/wai-rs/tests/int_synth_ops.rs b/wai/wai-rs/tests/int_synth_ops.rs index 954c27d4d..499cdfaf5 100644 --- a/wai/wai-rs/tests/int_synth_ops.rs +++ b/wai/wai-rs/tests/int_synth_ops.rs @@ -8,11 +8,12 @@ //! write those same bytes, and the load a sink makes must give the verdict the //! certificate records. //! -//! The decode through the lowering (`int_synth::ops`) must give, on every committed -//! vector, the bytes of the direct decode (`int_synth::legacy`) and the vector's -//! committed digests. -//! Every refusal of the direct decode is a refusal of the decode through the lowering, -//! with the same code: integer-payloads §13 gives one, `payload_malformed`. +//! The decode through the lowering (`int_synth::ops`, the one a sink runs) must give, on +//! every committed vector, the bytes of the direct decode it replaced +//! (`int_synth::legacy`, behind the test-only feature `legacy_int`, which this file +//! needs) and the vector's committed digests. Every refusal of the direct decode is a +//! refusal of the decode through the lowering, with the same code: integer-payloads §13 +//! gives one, `payload_malformed`. use std::path::PathBuf; @@ -312,3 +313,72 @@ fn every_kernel_policy_decodes_through_the_lowering_alike() { } } } + +/// The committed `int_synth` sets and payloads: every directory under +/// `neural-conformance/vectors/`, the browser demo's `wai-web/demo/int_synth/` and +/// `ops-conformance/sets/` that holds an `int_synth.json`, with its `latent.bin` where +/// it has one. Relative to `wai/`. +fn committed_sets_and_payloads() -> Vec<(String, Option)> { + let mut v = Vec::new(); + for root in ["neural-conformance/vectors", "wai-web/demo/int_synth", "ops-conformance/sets"] { + let mut dirs: Vec = std::fs::read_dir(wai().join(root)) + .unwrap() + .filter_map(|e| e.ok()) + .filter(|e| e.path().join("int_synth.json").exists()) + .map(|e| format!("{root}/{}", e.file_name().to_string_lossy())) + .collect(); + dirs.sort(); + for d in dirs { + let p = format!("{d}/latent.bin"); + let payload = wai().join(&p).exists().then_some(p); + v.push((format!("{d}/int_synth.json"), payload)); + } + } + v +} + +/// O12: the load rules the switch brings (integer-operators §15.3 item 1, and the two +/// the lowering makes that its text now states) affect no committed set or payload. +/// Every committed set has layers, each with `out_ch`, `k` and `stride` at least 1, +/// fields that fit 32 bits and a shift of at most 63 (so none from 64 to 127 changes +/// meaning either), and lowers and loads under `acc64`; the one exception is +/// `overflow_acc64`, the falsification set made to fail its certificate. Every +/// committed payload has a reserved byte of 0, `C ≥ 1` and `C·S·S ≤ 2^26`, is exactly +/// its length, and decodes through the lowering against its set. +#[test] +fn the_new_load_rules_affect_no_committed_set_or_payload() { + let all = committed_sets_and_payloads(); + let (mut sets, mut payloads) = (0, 0); + for (set_path, payload_path) in &all { + let bytes = read(set_path); + let v: Value = serde_json::from_slice(&bytes).unwrap(); + let layers = v["layers"].as_array().unwrap(); + assert!(!layers.is_empty(), "{set_path}: no layers"); + for (i, l) in layers.iter().enumerate() { + let f = |k: &str, d: u64| l.get(k).map_or(Some(d), Value::as_u64).unwrap_or_else(|| panic!("{set_path} layer {i}: {k}")); + for k in ["in_ch", "out_ch", "k", "stride", "pad"] { + assert!(f(k, u64::MAX) <= u32::MAX as u64, "{set_path} layer {i}: {k} past 32 bits"); + } + assert!(f("out_pad", 0) <= u32::MAX as u64, "{set_path} layer {i}: out_pad"); + assert!(f("out_ch", 0) >= 1 && f("k", 0) >= 1 && f("stride", 0) >= 1, "{set_path} layer {i}: out_ch, k or stride of 0"); + assert!(f("shift", 0) <= 63, "{set_path} layer {i}: a shift of 64 or more"); + } + let lowered = wai_ops::lower::int_synth(&bytes).unwrap_or_else(|r| panic!("{set_path}: does not lower: {r:?}")); + let loaded = wai::int_ops::load(lowered.get("synth").unwrap()); + if set_path.contains("/overflow_acc64/") { + assert!(loaded.is_err(), "{set_path}: the falsification set fails acc64"); + } else { + assert!(loaded.is_ok(), "{set_path}: does not load under acc64: {loaded:?}"); + } + sets += 1; + let Some(p) = payload_path else { continue }; + let b = read(p); + let c = u16::from_le_bytes([b[8], b[9]]) as u64; + let n = c << (2 * b[10] as u64); + assert_eq!(b[11], 0, "{p}: the reserved byte"); + assert!(c >= 1 && n <= 1 << 26 && b.len() as u64 == 12 + n, "{p}: C, the cap and the length"); + assert!(wai::int_synth::decode_from_spec(&b, &bytes).is_ok(), "{p}: decodes through the lowering"); + payloads += 1; + } + assert!(sets >= 11 && payloads >= 8, "the committed int_synth sets and payloads, at least those of this revision: {all:?}"); +} -- 2.54.0 (Apple Git-157) From c08d66f1a90dd2415d2c536c0b3976538a41353a Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Tue, 13 Oct 2026 22:28:16 -0400 Subject: [PATCH 10/10] D2c M1 gate 7: the direct path's guard code goes What the certified graph now carries, deleted from int_synth: - the direct decode's fast dispatch (its own conv_transpose2d over int_kernels::conv_acc_i64) and its guard reference_output_side, which kept the fast kernel to output sizes the scalar kernel's i64 arithmetic could form: the decode runs through the operator set, whose plan forms every size in checked 64-bit arithmetic under the 2^26 caps, and whose lowering refuses a field past 32 bits; - the kernel_equivalence module that held that dispatch to the scalar kernel (retargeted at gate 5: the fast kernels are held to the interpreter); - the ops module, folded into decode_from_spec and header(). The direct decode stays, test-only (legacy_int), as the differential's oracle: legacy::decode_from_spec over the scalar kernel alone (reference:: conv_transpose2d, kept verbatim, with its hand-computed unit tests). Callers of int_synth::ops now call int_synth::decode_from_spec (both wai-rs test files and byte-exact's lowering golden). int_kernels' documentation no longer describes an int_synth dispatch or its i64 size guard. Co-Authored-By: Claude Opus 5.5 --- wai/byte-exact-conformance/src/golden.rs | 9 +- wai/byte-exact-conformance/src/lib.rs | 4 +- wai/wai-rs/src/int_kernels.rs | 27 +- wai/wai-rs/src/int_synth.rs | 341 +++++------------------ wai/wai-rs/tests/int_ops_differential.rs | 12 +- wai/wai-rs/tests/int_synth_ops.rs | 12 +- 6 files changed, 105 insertions(+), 300 deletions(-) diff --git a/wai/byte-exact-conformance/src/golden.rs b/wai/byte-exact-conformance/src/golden.rs index 037e09dfa..b54754adb 100644 --- a/wai/byte-exact-conformance/src/golden.rs +++ b/wai/byte-exact-conformance/src/golden.rs @@ -139,13 +139,14 @@ fn int_synth_output_hash_is_golden() { ); } -/// The same golden through the integer operator set: the set's lowering `int_synth/1` -/// (integer-operators §13.1), loaded under `acc64` with its bounds certificate and run -/// by the operator set's interpreter, must give the same bytes on every architecture. +/// The same golden through the byte entry a sink without a filesystem calls +/// (`decode_from_spec`): the set's lowering `int_synth/1` (integer-operators §13.1), +/// loaded under `acc64` with its bounds certificate and run by the operator set's +/// interpreter, must give the same bytes on every architecture. #[test] fn int_synth_through_its_lowering_is_golden() { let (spec, payload) = int_synth_golden_case(); - let img = int_synth::ops::decode_from_spec(&payload, &serde_json::to_vec(&spec).unwrap()).expect("decode"); + let img = int_synth::decode_from_spec(&payload, &serde_json::to_vec(&spec).unwrap()).expect("decode"); assert_eq!((img.width, img.height), (32, 32)); assert_eq!( format!("{:x}", Sha256::digest(&img.rgb)), INT_SYNTH_GOLDEN_SHA256, diff --git a/wai/byte-exact-conformance/src/lib.rs b/wai/byte-exact-conformance/src/lib.rs index 121040768..f66085f04 100644 --- a/wai/byte-exact-conformance/src/lib.rs +++ b/wai/byte-exact-conformance/src/lib.rs @@ -27,8 +27,8 @@ pub mod int_entropy; pub mod int_kernels; // The bridge the integer decoders load their lowered graphs through and run them by -// (`wai-ops/1`, the integer operator set): int_synth's decode through its lowering -// (`int_synth::ops`) calls it, so the matrix checks that decode too. +// (`wai-ops/1`, the integer operator set): int_synth decodes through its lowering, so +// the matrix checks the decode a sink runs. #[path = "../../wai-rs/src/int_ops.rs"] pub mod int_ops; diff --git a/wai/wai-rs/src/int_kernels.rs b/wai/wai-rs/src/int_kernels.rs index 37c5999b1..1f0e8c5ae 100644 --- a/wai/wai-rs/src/int_kernels.rs +++ b/wai/wai-rs/src/int_kernels.rs @@ -8,7 +8,10 @@ //! them. The scalar convolution, transposed-convolution, IGDN, channel-attention //! (MDTA) and channel-LayerNorm kernels they replace are kept verbatim beside each //! caller (`reference` modules) and stay selectable ([`Policy::reference`]); they are -//! the executable specification. The JPEG-AI element-wise steps (the GDFN gate and +//! the executable specification. `int_synth` reaches the convolution kernels through +//! the integer operator set (`int_ops`): the interpreter lends them its convolutions +//! under a fast policy and computes every operator itself under the reference policy, +//! so for it the operator set's interpreter is the reference. The JPEG-AI element-wise steps (the GDFN gate and //! residuals in `int_transformer`, the activations and gates in `jpegai_synthesis`) //! were instead rewritten in place onto `par_map` / `par_zip`: each output element is //! the same pure function of its own inputs as before, and under the reference @@ -57,9 +60,10 @@ //! proportion to what the reference touches (below), and a transposed geometry //! whose sizes the reference's own arithmetic cannot form: an uncropped plane //! `cout·(ho + 2·ph)·(wo + 2·pw)` (the buffer the int_hyper, int_audio and -//! JPEG-AI references accumulate into before cropping) beyond the allocation cap, -//! or an int_synth output size that its reference's `i64` arithmetic overflows — -//! so such input gets the reference's own behaviour, in either profile. +//! JPEG-AI references accumulate into before cropping) beyond the allocation cap — +//! so such input gets the reference's own behaviour, in either profile. (Behind +//! the integer operator set's interpreter, `int_ops`, a declined operator is the +//! interpreter's, whose plan has refused every size past its caps already.) //! * **Work the fast path adds of its own stays in proportion to the reference's** //! and cannot overflow where the reference performs no such step. Its buffer //! sizes and indices are bounded by checked geometry and its worker-count @@ -113,8 +117,10 @@ //! kept output position (the int_hyper transposed and regular convolutions, //! including rows wide enough for the SIMD lanes), overflow only at a cropped border //! position (the int_hyper and int_audio transposed convolutions, `i64`, and the -//! JPEG-AI one, `i128`) and operands beyond the bound (int_synth); they assert -//! matching panics with overflow checks on and matching integers with them off. +//! JPEG-AI one, `i128`); they assert matching panics with overflow checks on and +//! matching integers with them off. Behind the interpreter (`int_ops`), the fast +//! convolutions are held to the interpreter instead, operator by operator on random +//! graphs and decoder by decoder on every committed vector. //! Geometry at the edges runs through the public decoders under every policy //! (`tests/int_kernels_geometry.rs`): a 1×1 input upsampled by strides up to //! `usize::MAX`, a regular convolution whose stride exceeds its padded input, @@ -714,9 +720,7 @@ impl ConvGeom { // wrapped size and then indexes out of it; where it is merely huge, the // reference's allocation is huge. The fast kernel never allocates the plane, // so it runs only while the plane is within the cap every other buffer is - // held to; beyond it the reference runs and fails (or not) its own way. The - // bound also keeps every term of int_synth's `i64` output-size expression - // (at most `full_h` or `full_w`) far from overflowing `i64`. + // held to; beyond it the reference runs and fails (or not) its own way. if cout.checked_mul(full_h)?.checked_mul(full_w)? > MAX_ELEMS { return None; } @@ -1611,8 +1615,9 @@ mod tests { // that can refuse it. At `padded == PAD_FACTOR · touched` the fast kernel // runs; beyond it the geometry is refused. That each caller then runs its // reference, with the same integers, is pinned beside each dispatch (the - // `padding_beyond_the_pad_factor_runs_the_reference` tests: int_hyper_synth, - // int_synth and int_transformer on these shapes, int_audio on 1-D ones). + // `padding_beyond_the_pad_factor_runs_the_reference` tests: int_hyper_synth + // and int_transformer on these shapes, int_audio on 1-D ones; int_synth's + // operators, declined, are the interpreter's). use testkit::{fast_conv_runs, FAST}; let measure = |g: &ConvGeom| { let lay = Layout::new(g).unwrap(); diff --git a/wai/wai-rs/src/int_synth.rs b/wai/wai-rs/src/int_synth.rs index 2ac117a7d..f43538503 100644 --- a/wai/wai-rs/src/int_synth.rs +++ b/wai/wai-rs/src/int_synth.rs @@ -35,12 +35,14 @@ //! bias:[i32; out_ch], shift:u32, relu:bool }, … ] } //! The final layer MUST output 3 channels at H×W; values are clamped to [0,255]. //! -//! The decode is [`ops`]'s: the graph `synth` of the set's lowering `int_synth/1` -//! (`extensions/integer-operators.md` §13.1), loaded under `acc64` with its bounds -//! certificate and run through the integer operator set ([`crate::int_ops`]). The direct +//! The decode ([`decode_from_spec`]) is the graph `synth` of the set's lowering +//! `int_synth/1` (`extensions/integer-operators.md` §13.1), loaded under `acc64` with its +//! bounds certificate and run through the integer operator set ([`crate::int_ops`]), +//! whose interpreter lends the transposed convolutions to the fast kernels. The direct //! decode it replaced is kept, test-only (feature `legacy_int`), as `legacy`: each -//! layer's transposed convolution, bias, shift and ReLU computed here. The differential -//! tests decode random sets and payloads both ways (`tests/int_ops_differential.rs`). +//! layer's transposed convolution, bias, shift and ReLU computed here by the scalar +//! kernel. The differential tests decode random sets and payloads both ways +//! (`tests/int_ops_differential.rs`). use std::path::Path; @@ -102,64 +104,10 @@ struct SynthSpec { layers: Vec, } -/// One integer transposed convolution + bias + requantize + optional ReLU — see -/// [`reference::conv_transpose2d`] for the definition. Runs the fast kernel -/// ([`crate::int_kernels`]) unless the current [`crate::int_kernels::Policy`] selects -/// the reference; both give the same integer for every output element, and every -/// error case is left to the reference so errors are reported exactly as before. -#[cfg(any(test, feature = "legacy_int"))] -fn conv_transpose2d( - input: &[i64], - cin: usize, - hin: usize, - win: usize, - l: &LayerSpec, -) -> Result<(Vec, usize, usize), IntSynthError> { - use crate::int_kernels::{conv_acc_i64, policy, ConvGeom, KernelPath}; - let (k, s, p, op) = (l.k, l.stride, l.pad, l.out_pad); - if policy().path == KernelPath::Fast - && Some(l.weight.len()) == cin.checked_mul(l.out_ch).and_then(|v| v.checked_mul(k)).and_then(|v| v.checked_mul(k)) - && l.bias.len() == l.out_ch - && let Some(g) = ConvGeom::transposed(cin, hin, win, l.out_ch, k, k, s, s, p, p, op, op, input.len(), l.weight.len()) - && reference_output_side(hin, l) == Some(g.ho) - && reference_output_side(win, l) == Some(g.wo) - { - let wt: Vec = l.weight.iter().map(|&v| v as i64).collect(); - let out = conv_acc_i64(&g, input, &wt, |oc, v| { - let mut x = (v + l.bias[oc] as i64) >> l.shift; - if l.relu && x < 0 { - x = 0; - } - x - }); - // `None`: operands outside the kernel's no-overflow bound — the reference runs. - if let Some(out) = out { - return Ok((out, g.ho, g.wo)); - } - } - reference::conv_transpose2d(input, cin, hin, win, l) -} - -/// One output side exactly as [`reference::conv_transpose2d`] computes it — in -/// `i64`, from the same `as` casts, in the same order — or `None` where that -/// expression overflows `i64` (the reference then panics with overflow checks on -/// and wraps with them off) or is negative. The fast path runs only where this -/// equals the side it computed in checked `usize` arithmetic, so it never returns -/// where the reference's size arithmetic fails. `ConvGeom::transposed`'s bound on -/// the uncropped plane already keeps every term far below `i64::MAX`; this check -/// states the rule where the reference's arithmetic is, independent of that cap. -#[cfg(any(test, feature = "legacy_int"))] -fn reference_output_side(n: usize, l: &LayerSpec) -> Option { - let v = (n as i64) - .checked_sub(1)? - .checked_mul(l.stride as i64)? - .checked_add(l.k as i64)? - .checked_add(l.out_pad as i64)? - .checked_sub(2i64.checked_mul(l.pad as i64)?)?; - usize::try_from(v).ok() -} - -/// The scalar reference kernel the fast path is checked against, kept verbatim. +/// The direct decode's transposed convolution, kept verbatim: the scalar kernel its fast +/// dispatch was checked against. That dispatch, and the guard that kept it to sizes this +/// kernel's own arithmetic forms, are gone: the decode runs through the integer operator +/// set, whose plan forms every size in checked arithmetic under its caps. #[cfg(any(test, feature = "legacy_int"))] mod reference { use super::{IntSynthError, LayerSpec}; @@ -273,19 +221,68 @@ pub fn decode_bytes(payload: &[u8], spec_json: &[u8]) -> Result Result { - ops::decode_from_spec(payload, spec_json) + let (h, w, c, s) = header(payload)?; + let refused = |what: &str, r: wai_ops::Refusal| IntSynthError::InvalidPayload(format!("int_synth {what}: {} at {:?}", r.code.as_str(), r.at)); + let set = wai_ops::lower::int_synth(spec_json).map_err(|r| refused("int_synth.json does not lower", r))?; + let g = crate::int_ops::load(set.get("synth").expect("int_synth/1 lowers to the graph synth")).map_err(|r| refused("set does not load", r))?; + let latents: Vec = payload[HEADER..].iter().map(|&b| b as i8 as i64).collect(); + let out = crate::int_ops::run1(&g, wai_ops::Tensor::new(c, s, s, latents), &[]).map_err(|r| refused("decode", r))?; + if (out.c, out.h as usize, out.w as usize) != (3, h, w) { + return Err(IntSynthError::InvalidPayload(format!( + "int_synth output [{},{},{}] != [3,{h},{w}]", out.c, out.h, out.w))); + } + // `clamp(0, 255)` ends the graph, so every value is a byte. + let hw = h * w; + let mut rgb = vec![0u8; hw * 3]; + for (k, &v) in out.data.iter().enumerate() { + rgb[(k % hw) * 3 + k / hw] = v as u8; + } + Ok(IntImage { rgb, width: w as u32, height: h as u32 }) +} + +/// The payload's header (`extensions/integer-payloads.md` §13): `(H, W, C, S)`, after +/// its checks. The side is `2^S_log2`; `C·S·S` is formed without overflow and is at +/// most `2^26`, and the payload is exactly `12 + C·S·S` bytes. +fn header(payload: &[u8]) -> Result<(usize, usize, u32, u32), IntSynthError> { + if payload.len() < HEADER { + return Err(IntSynthError::InvalidPayload(format!( + "int_synth payload {} B < {HEADER} B header", payload.len()))); + } + let h = u32::from_le_bytes(payload[0..4].try_into().unwrap()) as usize; + let w = u32::from_le_bytes(payload[4..8].try_into().unwrap()) as usize; + let c = u16::from_le_bytes(payload[8..10].try_into().unwrap()) as usize; + if payload[11] != 0 { + return Err(IntSynthError::InvalidPayload("int_synth payload's reserved byte is not 0".into())); + } + if c == 0 { + return Err(IntSynthError::InvalidPayload("int_synth payload has no latent channels".into())); + } + const CAP: usize = 1 << 26; + let n = 1usize.checked_shl(payload[10] as u32) + .and_then(|s| c.checked_mul(s)?.checked_mul(s).map(|n| (s, n))) + .filter(|&(_, n)| n <= CAP); + let Some((s, _)) = n.filter(|&(_, n)| HEADER.checked_add(n) == Some(payload.len())) else { + return Err(IntSynthError::InvalidPayload(format!( + "int_synth payload {} B is not {HEADER} + C·S·S with C = {c}, S = 2^{}, C·S·S ≤ 2^26", payload.len(), payload[10]))); + }; + Ok((h, w, c as u32, s as u32)) } /// The direct decode, as the decoder ran before it decoded through the integer operator /// set: the payload and set read here, each layer's transposed convolution, bias, shift -/// and ReLU computed by [`conv_transpose2d`]. Test-only (`legacy_int`): the differential -/// tests decode both ways, and the two differ only on the planned breaks of -/// `extensions/integer-operators.md` §15.3. +/// and ReLU computed by the scalar reference kernel. Test-only (`legacy_int`): the +/// differential tests decode both ways, and the two differ only on the planned breaks +/// of `extensions/integer-operators.md` §15.3. #[cfg(any(test, feature = "legacy_int"))] pub mod legacy { use super::*; @@ -321,7 +318,7 @@ pub mod legacy { return Err(IntSynthError::InvalidPayload(format!( "int_synth layer {i} in_ch {} != current {cin}", l.in_ch))); } - let (out, ho, wo) = conv_transpose2d(&cur, cin, ch, cw, l)?; + let (out, ho, wo) = reference::conv_transpose2d(&cur, cin, ch, cw, l)?; cur = out; cin = l.out_ch; ch = ho; @@ -346,73 +343,11 @@ pub mod legacy { } } -/// The decode through the integer operator set: the graph `synth` of the set's lowering -/// `int_synth/1` (`extensions/integer-operators.md` §13.1), loaded under `acc64`, the -/// bounds certificate included, and run on the latents ([`crate::int_ops`]). -pub mod ops { - use super::{IntImage, IntSynthError, HEADER}; - - /// Decode a payload against its `int_synth.json` through the integer operator set. - /// - /// The payload is checked first (`extensions/integer-payloads.md` §13): its header, a - /// reserved byte of 0, `C ≥ 1`, `C·S·S ≤ 2^26` and its exact length. Then the set is - /// lowered (integer-operators §13.1, whose checks include `k ≥ 1`, `stride ≥ 1` and a - /// shift of at most 127) and loaded under `acc64`: load steps 1–8, the last being the - /// bounds certificate. Then the graph runs on the latents, through the operator set's - /// run-time plan with its caps, and its output must be `[3, H, W]`. Every refusal is - /// [`IntSynthError::InvalidPayload`] (`payload_malformed`). - pub fn decode_from_spec(payload: &[u8], spec_json: &[u8]) -> Result { - let (h, w, c, s) = header(payload)?; - let refused = |what: &str, r: wai_ops::Refusal| IntSynthError::InvalidPayload(format!("int_synth {what}: {} at {:?}", r.code.as_str(), r.at)); - let set = wai_ops::lower::int_synth(spec_json).map_err(|r| refused("int_synth.json does not lower", r))?; - let g = crate::int_ops::load(set.get("synth").expect("int_synth/1 lowers to the graph synth")).map_err(|r| refused("set does not load", r))?; - let latents: Vec = payload[HEADER..].iter().map(|&b| b as i8 as i64).collect(); - let out = crate::int_ops::run1(&g, wai_ops::Tensor::new(c, s, s, latents), &[]).map_err(|r| refused("decode", r))?; - if (out.c, out.h as usize, out.w as usize) != (3, h, w) { - return Err(IntSynthError::InvalidPayload(format!( - "int_synth output [{},{},{}] != [3,{h},{w}]", out.c, out.h, out.w))); - } - // `clamp(0, 255)` ends the graph, so every value is a byte. - let hw = h * w; - let mut rgb = vec![0u8; hw * 3]; - for (k, &v) in out.data.iter().enumerate() { - rgb[(k % hw) * 3 + k / hw] = v as u8; - } - Ok(IntImage { rgb, width: w as u32, height: h as u32 }) - } - - /// The payload's header (`extensions/integer-payloads.md` §13): `(H, W, C, S)`, after - /// its checks. The side is `2^S_log2`; `C·S·S` is formed without overflow and is at - /// most `2^26`, and the payload is exactly `12 + C·S·S` bytes. - fn header(payload: &[u8]) -> Result<(usize, usize, u32, u32), IntSynthError> { - if payload.len() < HEADER { - return Err(IntSynthError::InvalidPayload(format!( - "int_synth payload {} B < {HEADER} B header", payload.len()))); - } - let h = u32::from_le_bytes(payload[0..4].try_into().unwrap()) as usize; - let w = u32::from_le_bytes(payload[4..8].try_into().unwrap()) as usize; - let c = u16::from_le_bytes(payload[8..10].try_into().unwrap()) as usize; - if payload[11] != 0 { - return Err(IntSynthError::InvalidPayload("int_synth payload's reserved byte is not 0".into())); - } - if c == 0 { - return Err(IntSynthError::InvalidPayload("int_synth payload has no latent channels".into())); - } - const CAP: usize = 1 << 26; - let n = 1usize.checked_shl(payload[10] as u32) - .and_then(|s| c.checked_mul(s)?.checked_mul(s).map(|n| (s, n))) - .filter(|&(_, n)| n <= CAP); - let Some((s, _)) = n.filter(|&(_, n)| HEADER.checked_add(n) == Some(payload.len())) else { - return Err(IntSynthError::InvalidPayload(format!( - "int_synth payload {} B is not {HEADER} + C·S·S with C = {c}, S = 2^{}, C·S·S ≤ 2^26", payload.len(), payload[10]))); - }; - Ok((h, w, c as u32, s as u32)) - } -} - #[cfg(test)] mod tests { use super::*; + // The direct decode's scalar kernel, against hand computation. + use super::reference::conv_transpose2d; fn layer(out_ch: usize, in_ch: usize, k: usize, stride: usize, weight: Vec) -> LayerSpec { LayerSpec { @@ -493,139 +428,3 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } } - -/// The fast transposed conv against the scalar reference, element by element — -/// results AND errors. -#[cfg(test)] -mod kernel_equivalence { - use super::*; - use crate::int_kernels::testkit::{Rng, FAST}; - use crate::int_kernels::with_policy; - - #[test] - fn conv_transpose2d_equals_reference() { - let mut g = Rng(0x5E7_0001); - for case in 0..500 { - let k = 1 + g.below(5); - let stride = 1 + g.below(3); - let pad = g.below(k + 1); - let out_pad = if stride > 1 { g.below(stride) } else { 0 }; - let (h, w) = (1 + g.below(6), 1 + g.below(8)); - let (cin, out_ch) = (1 + g.below(5), 1 + g.below(7)); - let mut weight: Vec = g.vec(cin * out_ch * k * k, 1 << 14, 10).into_iter().map(|v| v as i32).collect(); - let mut bias: Vec = g.vec(out_ch, 1 << 18, 0).into_iter().map(|v| v as i32).collect(); - // exercise the error paths too: wrong weight / bias length - match case % 25 { - 0 => { weight.pop(); } - 1 => { bias.push(0); } - _ => {} - } - let l = LayerSpec { - in_ch: cin, out_ch, k, stride, pad, out_pad, weight, bias, - shift: g.below(12) as u32, relu: g.below(2) == 1, - }; - let x = g.vec(cin * h * w, 127, 40); // int8 latents, sparse - let want = reference::conv_transpose2d(&x, cin, h, w, &l); - for p in FAST { - let got = with_policy(p, || conv_transpose2d(&x, cin, h, w, &l)); - assert_eq!(got, want, "int_synth {p:?} case {case}"); - } - } - } - - /// Operands beyond the fast kernel's no-overflow bound, laid out so the only - /// overflowing sums would be at border positions (a 1×1 input, 3×3 kernel, - /// pad 1, corner taps `2^22` on inputs `2^40`). This reference skips positions - /// outside the output rather than accumulating and cropping them, so it forms - /// no such sum and returns the kept centre `2·2^40 = 2^41` in every build. The - /// fast path hands these operands to the reference and must agree. - #[test] - fn operands_beyond_the_bound_follow_the_reference() { - let (cin, out_ch, k) = (2usize, 1usize, 3usize); - let mut weight = vec![0i32; cin * out_ch * k * k]; - for tap in weight.chunks_mut(k * k) { - for c in [0, 2, 6, 8] { - tap[c] = 1 << 22; - } - tap[4] = 1; - } - let l = LayerSpec { in_ch: cin, out_ch, k, stride: 1, pad: 1, out_pad: 0, weight, bias: vec![0], shift: 0, relu: false }; - let x = vec![1i64 << 40; cin]; - let want = reference::conv_transpose2d(&x, cin, 1, 1, &l); - assert_eq!(want, Ok((vec![1i64 << 41], 1, 1)), "the reference forms no border sum"); - for p in FAST { - assert_eq!(with_policy(p, || conv_transpose2d(&x, cin, 1, 1, &l)), want, "{p:?}"); - } - } - - /// Geometry whose padded input would be more than `PAD_FACTOR` times what the - /// reference reads and writes is refused by the fast path, and the reference - /// runs: the fast kernel is not entered and the result is the reference's. At - /// exactly the factor the fast kernel runs. The shapes and their sizes are worked - /// in `int_kernels`' `a_padded_input_beyond_pad_factor_is_left_to_the_reference`. - #[test] - fn padding_beyond_the_pad_factor_runs_the_reference() { - use crate::int_kernels::testkit::fast_conv_runs; - // 1×1 input and kernel, stride 1: (channels, pad, output pad, expected - // fast-kernel runs, output side). No tap reaches the kept output, so each - // output is the bias, shifted. - for (cin, pad, out_pad, runs, side) in [(2usize, 6usize, 13usize, 1u64, 2usize), (2, 7, 15, 0, 2), (1, 6, 12, 0, 1)] { - let l = LayerSpec { - in_ch: cin, out_ch: 1, k: 1, stride: 1, pad, out_pad, weight: vec![3; cin], bias: vec![14], shift: 1, relu: false, - }; - let x = vec![5i64; cin]; - let want = reference::conv_transpose2d(&x, cin, 1, 1, &l); - assert_eq!(want, Ok((vec![7; side * side], side, side)), "reference, pad {pad} out_pad {out_pad}"); - for p in FAST { - let got = fast_conv_runs(|| with_policy(p, || conv_transpose2d(&x, cin, 1, 1, &l))); - assert_eq!(got, (want.clone(), runs), "pad {pad} out_pad {out_pad} {p:?}"); - } - } - } - - /// The reference sizes its output in `i64`; the fast path runs only where that - /// arithmetic neither overflows nor disagrees with its own `usize` geometry. - /// `reference_output_side` evaluates the reference's expression step by step, - /// and here refuses every input on which the reference's arithmetic overflows. - /// Through the dispatch, the fast kernel is not entered for them, and every - /// policy has the reference's outcome: a panic with overflow checks on, and the - /// wrapped (here still correct) size with them off. - #[test] - fn the_reference_output_size_is_checked_in_its_own_arithmetic() { - use crate::int_kernels::testkit::{fast_conv_runs, overflow_checks}; - use crate::int_kernels::Policy; - let spec = |stride, pad, out_pad| LayerSpec { - in_ch: 1, out_ch: 1, k: 3, stride, pad, out_pad, weight: (1..=9).collect(), bias: vec![0], shift: 0, relu: false, - }; - // Ordinary: (4 − 1)·2 + 3 + 1 − 2·1 = 8. - assert_eq!(reference_output_side(4, &spec(2, 1, 1)), Some(8)); - // Not positive: 0·1 + 3 + 0 − 2·2 = −1. - assert_eq!(reference_output_side(1, &spec(1, 2, 0)), None); - #[cfg(target_pointer_width = "64")] - { - let s = 1usize << 62; - // (3 − 1)·2^62 = 2^63: the product overflows i64. - assert_eq!(reference_output_side(3, &spec(s, s, 0)), None); - // 2^62 + 3 + 2^62: the second addition overflows i64, although the - // wrapped result would be the true size, 3. - assert_eq!(reference_output_side(2, &spec(s, s, s)), None); - // 2·2^62 overflows i64 (0 + 3 + 0 − 2^63 would be negative anyway). - assert_eq!(reference_output_side(1, &spec(1, s, 0)), None); - - // Through the dispatch: a 2×2 input, stride = pad = output pad = 2^62, - // a 3×3 output that only input (1, 1) reaches, through every tap. - let l = spec(s, s, s); - let x = [1i64, 2, 3, 4]; - let run = |p: Policy| fast_conv_runs(|| std::panic::catch_unwind(|| with_policy(p, || conv_transpose2d(&x, 1, 2, 2, &l))).ok()); - let want = run(Policy::reference()); - if overflow_checks() { - assert_eq!(want, (None, 0), "the reference overflows i64 sizing its output"); - } else { - assert_eq!(want, (Some(Ok(((1..=9).map(|v| 4 * v).collect(), 3, 3))), 0)); - } - for p in FAST { - assert_eq!(run(p), want, "{p:?}"); - } - } - } -} diff --git a/wai/wai-rs/tests/int_ops_differential.rs b/wai/wai-rs/tests/int_ops_differential.rs index 371ac7890..9d8b7c80c 100644 --- a/wai/wai-rs/tests/int_ops_differential.rs +++ b/wai/wai-rs/tests/int_ops_differential.rs @@ -16,7 +16,7 @@ use std::path::PathBuf; use std::time::{Duration, Instant}; use serde_json::{json, Value}; -use wai::int_synth::{legacy, ops, IntImage, IntSynthError}; +use wai::int_synth::{decode_from_spec, legacy, IntImage, IntSynthError}; fn wai() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..") @@ -203,7 +203,7 @@ fn int_synth_random_sets_and_payloads_decode_alike() { let (mut alike, mut refused, mut breaks) = (0u64, 0u64, std::collections::BTreeMap::<&str, u64>::new()); let n = cases("int_synth", 3000, |r, seed| { let (set, pay) = int_synth_case(r); - let new = ops::decode_from_spec(&pay, &set); + let new = decode_from_spec(&pay, &set); let why = || format!("seed {seed}\nset {}\npayload {:02x?}", String::from_utf8_lossy(&set), pay); match int_synth_planned_break(&pay, &set) { Some("shift 64 to 127") => *breaks.entry("shift 64 to 127").or_default() += 1, @@ -265,12 +265,12 @@ fn int_synth_planned_breaks_are_new_refusals() { if direct_decodes { assert!(matches!(direct(pay, s), Some(Ok(_))), "{b}: the direct decode decodes it"); } - assert!(ops::decode_from_spec(pay, s).is_err(), "{b}: the decode through the operator set refuses it"); + assert!(decode_from_spec(pay, s).is_err(), "{b}: the decode through the operator set refuses it"); }; // The base decodes alike: 5·1 + the bias. assert_eq!(int_synth_planned_break(&five, &one), None); - assert_eq!(ops::decode_from_spec(&five, &one).unwrap().rgb, [15, 25, 35]); - assert_eq!(direct(&five, &one), Some(ops::decode_from_spec(&five, &one))); + assert_eq!(decode_from_spec(&five, &one).unwrap().rgb, [15, 25, 35]); + assert_eq!(direct(&five, &one), Some(decode_from_spec(&five, &one))); expect("a reserved byte that is not 0", &payload(1, 1, 7, &[5]), &one, true); // C of 0: no latents; the set's one layer reads no channel, so the direct decode's // output is the bias. @@ -294,5 +294,5 @@ fn int_synth_planned_breaks_are_new_refusals() { // A shift from 64 to 127 has its exact meaning: every sum here is below 2^64. let s64 = set(vec![layer(1, 3, 1, 1, 0, 0, 64)]); assert_eq!(int_synth_planned_break(&five, &s64), Some("shift 64 to 127")); - assert_eq!(ops::decode_from_spec(&five, &s64).unwrap().rgb, [0, 0, 0]); + assert_eq!(decode_from_spec(&five, &s64).unwrap().rgb, [0, 0, 0]); } diff --git a/wai/wai-rs/tests/int_synth_ops.rs b/wai/wai-rs/tests/int_synth_ops.rs index 499cdfaf5..5c091b6d0 100644 --- a/wai/wai-rs/tests/int_synth_ops.rs +++ b/wai/wai-rs/tests/int_synth_ops.rs @@ -8,7 +8,7 @@ //! write those same bytes, and the load a sink makes must give the verdict the //! certificate records. //! -//! The decode through the lowering (`int_synth::ops`, the one a sink runs) must give, on +//! The decode through the lowering (`int_synth::decode_from_spec`, the one a sink runs) must give, on //! every committed vector, the bytes of the direct decode it replaced //! (`int_synth::legacy`, behind the test-only feature `legacy_int`, which this file //! needs) and the vector's committed digests. Every refusal of the direct decode is a @@ -149,7 +149,7 @@ fn every_committed_vector_decodes_through_the_lowering_to_its_golden() { let dir = format!("neural-conformance/vectors/{name}"); let (payload, set) = (read(&format!("{dir}/latent.bin")), read(&format!("{dir}/int_synth.json"))); assert!(payload == read(&format!("wai-web/demo/int_synth/{name}/latent.bin")), "{name}: the demo's copy"); - let ops = wai::int_synth::ops::decode_from_spec(&payload, &set).unwrap_or_else(|e| panic!("{name}: {e}")); + let ops = wai::int_synth::decode_from_spec(&payload, &set).unwrap_or_else(|e| panic!("{name}: {e}")); let direct = wai::int_synth::legacy::decode_from_spec(&payload, &set).unwrap_or_else(|e| panic!("{name}: {e}")); assert!(ops == direct, "{name}: the decode through the lowering differs from the direct decode"); assert_eq!((ops.width as u64, ops.height as u64), (want["width"].as_u64().unwrap(), want["height"].as_u64().unwrap()), "{name}"); @@ -249,14 +249,14 @@ fn code(payload: &[u8], set: &[u8]) -> Result<(), &'static str> { #[test] fn every_refusal_of_the_direct_decode_is_refused_through_the_lowering() { let good = (payload(2, 2, 1, 0, 9, 0), set(vec![layer(1, 3, 2, 2, 0, 3)])); - let a = wai::int_synth::ops::decode_from_spec(&good.0, &good.1).expect("the fixtures' base decodes"); + let a = wai::int_synth::decode_from_spec(&good.0, &good.1).expect("the fixtures' base decodes"); assert_eq!(Ok(a.clone()), wai::int_synth::legacy::decode_from_spec(&good.0, &good.1)); assert_eq!(a.rgb, [14u8; 12], "(9·3 + 1) >> 1 at every sample"); assert_eq!(code(&good.0, &good.1), Ok(())); let fixtures = refusal_fixtures(); for (what, pay, s) in &fixtures { let direct = wai::int_synth::legacy::decode_from_spec(pay, s); - let ops = wai::int_synth::ops::decode_from_spec(pay, s); + let ops = wai::int_synth::decode_from_spec(pay, s); assert!(direct.is_err(), "{what}: the direct decode refuses it: {direct:?}"); assert!(ops.is_err(), "{what}: the decode through the lowering refuses it: {ops:?}"); assert_eq!(code(pay, s), Err("payload_malformed"), "{what}"); @@ -306,9 +306,9 @@ fn every_kernel_policy_decodes_through_the_lowering_alike() { .collect(); cases.push(("the fixtures' base".into(), payload(2, 2, 1, 0, 9, 0), set(vec![layer(1, 3, 2, 2, 0, 3)]))); for (name, p, s) in &cases { - let want = with_policy(POLICIES[0], || wai::int_synth::ops::decode_from_spec(p, s)).unwrap_or_else(|e| panic!("{name}: {e}")); + let want = with_policy(POLICIES[0], || wai::int_synth::decode_from_spec(p, s)).unwrap_or_else(|e| panic!("{name}: {e}")); for pol in &POLICIES[1..] { - let got = with_policy(*pol, || wai::int_synth::ops::decode_from_spec(p, s)); + let got = with_policy(*pol, || wai::int_synth::decode_from_spec(p, s)); assert!(got.as_ref() == Ok(&want), "{name}: {pol:?} differs from the interpreter alone"); } } -- 2.54.0 (Apple Git-157)